Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ protected override object Invoke(MethodInfo targetMethod, object[] args)
return Editions.Where(e => e.BookId == bookId).ToList();
}

if (targetMethod?.Name == nameof(IEditionService.GetEditionsByBook) &&
args?.Length == 1 &&
args[0] is IEnumerable<int> bookIds)
{
var wanted = bookIds.ToHashSet();
return Editions.Where(e => wanted.Contains(e.BookId)).ToList();
}

throw new NotImplementedException($"Test proxy does not implement IEditionService.{targetMethod?.Name}");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,13 @@ public List<Edition> GetEditionsByBook(IEnumerable<int> bookIds)
public void UpdateMany(List<Edition> editions) => throw new NotImplementedException();
public void DeleteMany(List<Edition> editions) => throw new NotImplementedException();
public List<Edition> GetEditionsForRefresh(int bookId) => throw new NotImplementedException();
public List<Edition> GetEditionsByAuthor(int authorId) => throw new NotImplementedException();
public int AuthorLookupCount { get; private set; }

public List<Edition> GetEditionsByAuthor(int authorId)
{
AuthorLookupCount++;
return _editions.ToList();
}
public Edition FindByTitle(int authorId, string title) => throw new NotImplementedException();
public Edition FindByTitleInexact(int authorId, string title) => throw new NotImplementedException();
public List<Edition> GetCandidates(int authorId, string title) => throw new NotImplementedException();
Expand Down Expand Up @@ -333,6 +339,83 @@ private static BookService BuildService(StubBookRepository repository, StubAutho
logger: LogManager.GetCurrentClassLogger());
}

private sealed class CountingLazyEditions : NzbDrone.Core.Datastore.LazyLoaded<List<Edition>>
{
private readonly Counter _counter;

public CountingLazyEditions(Counter counter)
{
_counter = counter;
}

public override void LazyLoad()
{
if (IsLoaded)
{
return;
}

_counter.Count++;
_value = new List<Edition>();
IsLoaded = true;
}
}

private sealed class Counter
{
public int Count { get; set; }
}

[Test]
public void update_many_should_load_author_editions_in_one_query_instead_of_one_per_book()
{
var author = BuildAuthor(1);
var counter = new Counter();
var books = Enumerable.Range(0, 25)
.Select(i => BuildBook(10 + i, author.Id, i % 2 == 0 ? BookMediaType.Audiobook : BookMediaType.Ebook, $"hc:work-{i / 2}", monitored: false))
.ToList();
foreach (var book in books)
{
book.LazyEditions = new CountingLazyEditions(counter);
}

var editions = books.Select(book => new Edition { Id = book.Id * 10, BookId = book.Id, Asin = $"ASIN{book.Id}" }).ToList();
var editionService = new StubEditionService(editions);
var repository = new StubBookRepository(books);
var service = BuildService(repository, new StubAuthorService(new[] { author }), editionService: editionService);

var changed = books[0];
changed.SetMonitored(true);
service.UpdateMany(new List<Book> { changed });

Assert.That(counter.Count, Is.EqualTo(0), "no per-book lazy Editions load should be needed");
Assert.That(editionService.AuthorLookupCount, Is.EqualTo(1), "exactly one author-wide query; the changed books use the by-book query");
}

[Test]
public void update_many_should_give_each_book_only_its_own_editions_and_not_touch_loaded_books()
{
var author = BuildAuthor(1);
var first = BuildBook(10, author.Id, BookMediaType.Audiobook, "hc:work-1", monitored: false);
var second = BuildBook(11, author.Id, BookMediaType.Ebook, "hc:work-1", monitored: false);
first.LazyEditions = new CountingLazyEditions(new Counter());
var preloaded = new List<Edition> { new Edition { Id = 999, BookId = 11, Asin = "KEEP" } };
second.Editions = preloaded;

var editionService = new StubEditionService(new[]
{
new Edition { Id = 100, BookId = 10, Asin = "A" },
new Edition { Id = 110, BookId = 11, Asin = "B" }
});
var service = BuildService(new StubBookRepository(new[] { first, second }), new StubAuthorService(new[] { author }), editionService: editionService);

first.SetMonitored(true);
service.UpdateMany(new List<Book> { first });

Assert.That(first.Editions.Select(e => e.Id), Is.EqualTo(new[] { 100 }));
Assert.That(second.Editions, Is.SameAs(preloaded), "an already-loaded book must keep its in-memory editions");
}

[Test]
public void set_book_monitored_should_enable_one_sibling_format()
{
Expand Down
78 changes: 75 additions & 3 deletions src/NzbDrone.Core/Books/Services/BookService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,64 @@ private static bool HasMonitoringChanged(Book book, MonitoredStateSnapshot snaps
book.EbookMonitored != snapshot.EbookMonitored;
}

// Book.Editions is lazy-loaded, and both BuildWorkGroups (BookIdentity.GetProviderIdentityTokens ->
// BookEditionIdentity.GetOrderedEditions) and CloneStoredBook (GetAsin) read it. Handing a whole author's
// books to them therefore cost one Editions query per book, on every save that reaches the format-sync
// pass (for an author with thousands of books, most of a refresh's wall-clock time). Fill the books that
// have not loaded their editions from a single per-author query instead; already-loaded books are left
// alone, and the rows are the same ones the lazy loader would have returned (Editions by BookId).
private void PreloadEditions(int authorId, IEnumerable<Book> books)
{
if (_editionService == null || books == null)
{
return;
}

var unloaded = books
.Where(book => book != null && book.Id > 0 && (book.LazyEditions == null || !book.LazyEditions.IsLoaded))
.ToList();

if (unloaded.Count == 0)
{
return;
}

var editionsByBookId = (_editionService.GetEditionsByAuthor(authorId) ?? new List<Edition>())
.ToLookup(edition => edition.BookId);

foreach (var book in unloaded)
{
book.Editions = editionsByBookId[book.Id].ToList();
}
}

// Same idea as PreloadEditions, for a caller that holds a specific set of books rather than a whole
// author's: one Editions-by-BookId query for the ones that have not loaded them.
private void PreloadEditionsByBook(IEnumerable<Book> books)
{
if (_editionService == null || books == null)
{
return;
}

var unloaded = books
.Where(book => book != null && book.Id > 0 && (book.LazyEditions == null || !book.LazyEditions.IsLoaded))
.ToList();

if (unloaded.Count == 0)
{
return;
}

var editionsByBookId = (_editionService.GetEditionsByBook(unloaded.Select(book => book.Id).Distinct().ToList()) ?? new List<Edition>())
.ToLookup(edition => edition.BookId);

foreach (var book in unloaded)
{
book.Editions = editionsByBookId[book.Id].ToList();
}
}

private static Book CloneStoredBook(Book book)
{
if (book == null)
Expand Down Expand Up @@ -1483,6 +1541,7 @@ private List<BookMonitoringSyncUpdate> GetSyncUpdatesForMutations(List<Book> cha
}

var repositoryBooks = _bookRepository.GetBooksByAuthorId(authorBooks.Key) ?? new List<Book>();
PreloadEditions(authorBooks.Key, repositoryBooks);
var authorStoredById = repositoryBooks.ToDictionary(book => book.Id, CloneStoredBook);
var authorBooksById = repositoryBooks.ToDictionary(book => book.Id);

Expand All @@ -1496,6 +1555,10 @@ private List<BookMonitoringSyncUpdate> GetSyncUpdatesForMutations(List<Book> cha
authorBooksById[changedBook.Id] = changedBook;
}

// The caller's changed books replaced their repository copies above; fill only those (a small
// IN query) rather than re-running the author-wide query.
PreloadEditionsByBook(authorBooks);

var changedBookIds = authorBooks.Select(book => book.Id).ToHashSet();
var baseStates = authorBooksById.ToDictionary(pair => pair.Key, pair => SnapshotMonitoredState(pair.Value));

Expand Down Expand Up @@ -1541,6 +1604,7 @@ private void ApplyInsertSyncDefaults(List<Book> books)
var combinedBooks = (_bookRepository.GetBooksByAuthorId(authorBooks.Key) ?? new List<Book>())
.Concat(insertedBooks)
.ToList();
PreloadEditions(authorBooks.Key, combinedBooks);

foreach (var workGroup in BuildWorkGroups(combinedBooks))
{
Expand Down Expand Up @@ -1607,6 +1671,8 @@ private List<BookMonitoringSyncUpdate> BuildReconcileSyncUpdates(IEnumerable<int
continue;
}

PreloadEditions(authorId, authorBooks);

var storedById = authorBooks.ToDictionary(book => book.Id, CloneStoredBook);
var baseStates = authorBooks.ToDictionary(book => book.Id, SnapshotMonitoredState);

Expand Down Expand Up @@ -1725,7 +1791,9 @@ public void UpdateMany(List<Book> books)
// Ensure unique TitleSlugs for duplicate books when updating
EnsureUniqueTitleSlugs(books);
books.ForEach(EnsureBookDbFields);
var storedById = _bookRepository.Get(books.Select(book => book.Id)).ToDictionary(book => book.Id, CloneStoredBook);
var storedBooks = _bookRepository.Get(books.Select(book => book.Id)).ToList();
PreloadEditionsByBook(storedBooks);
var storedById = storedBooks.ToDictionary(book => book.Id, CloneStoredBook);
var syncUpdates = GetSyncUpdatesForMutations(books, storedById);
var booksToUpdate = books
.Concat(syncUpdates.Select(update => update.Book))
Expand Down Expand Up @@ -1756,9 +1824,11 @@ private List<Book> PersistWithLifecycle(List<Book> books)
}

var bookIds = changedBooks.Select(book => book.Id).ToList();
var storedById = _bookRepository.FindExisting(bookIds)
var storedBooks = _bookRepository.FindExisting(bookIds)
.Where(book => book != null)
.ToDictionary(book => book.Id, CloneStoredBook);
.ToList();
PreloadEditionsByBook(storedBooks);
var storedById = storedBooks.ToDictionary(book => book.Id, CloneStoredBook);

changedBooks = changedBooks.Where(book => storedById.ContainsKey(book.Id)).ToList();
if (!changedBooks.Any())
Expand Down Expand Up @@ -2111,6 +2181,7 @@ public void SetBookMonitored(int bookId, bool monitored)
public void SetMonitored(IEnumerable<int> ids, bool monitored)
{
var books = _bookRepository.Get(ids).ToList();
PreloadEditionsByBook(books);
var storedById = books.ToDictionary(book => book.Id, CloneStoredBook);

foreach (var book in books)
Expand Down Expand Up @@ -2155,6 +2226,7 @@ public void SetMonitoredForMediaType(IEnumerable<int> ids, string mediaType, boo
}

var books = _bookRepository.Get(ids).ToList();
PreloadEditionsByBook(books);
var storedById = books.ToDictionary(book => book.Id, CloneStoredBook);
var booksToMutate = new List<Book>();

Expand Down