From 74fa4ecf2296db8e754653c2af84b6e0deb914f5 Mon Sep 17 00:00:00 2001 From: jordan Date: Sun, 27 Sep 2026 21:45:40 +0000 Subject: [PATCH 1/8] Run author delete as a background command instead of inline in the HTTP request AuthorController.DeleteAuthor and AuthorEditorController.DeleteAuthor (bulk) called AuthorService.DeleteAuthor(s) directly inside the request handler. That method loops every book belonging to the author(s) and publishes BookDeletedEvent synchronously per book, which still has several fully-synchronous IHandle subscribers doing real work per book: MediaFileService unlinking files (over this host's NFS-mounted media), ExtraFileService, DeferredCoverDownloadService, TrackedDownloadService, HistoryService. PR #259 already moved the Discord/webhook notification half of this off the sync path, but for a small author the rest was never noticeable. For a massive author (Charles Dickens, 10113 books) it's a different story: one HTTP request doing 10k+ sequential rounds of disk I/O and DB writes with nothing yielding control back, which hangs the request long enough to look like the whole instance froze. Add DeleteAuthorCommand and have AuthorService execute it the same way every other multi-book operation already runs (BulkMoveAuthorCommand, BulkRefreshAuthorCommand, ...): both delete endpoints now push the command and return immediately, and the actual deletion happens on the command queue where it shows up in Activity like any other long-running job instead of hanging the request. RequiresDiskAccess is instance-scoped to DeleteFiles so a metadata-only delete isn't serialized behind unrelated disk work in the "default" disk-access group (see PR #188). The readdAuthor branch of AuthorController.DeleteAuthor is untouched - it calls the separate DeleteAuthorForReadd path and depends on that finishing synchronously before the subsequent AddAuthorAsync call in the same request, per the existing comment there. --- .../Author/AuthorController.cs | 12 ++++++- .../Author/AuthorEditorController.cs | 8 ++++- .../Books/Commands/DeleteAuthorCommand.cs | 31 +++++++++++++++++++ .../Books/Services/AuthorService.cs | 8 ++++- 4 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs diff --git a/src/Chaptarr.Api.V1/Author/AuthorController.cs b/src/Chaptarr.Api.V1/Author/AuthorController.cs index 2be27c43..43b3427c 100644 --- a/src/Chaptarr.Api.V1/Author/AuthorController.cs +++ b/src/Chaptarr.Api.V1/Author/AuthorController.cs @@ -1235,7 +1235,17 @@ public async Task DeleteAuthor(int id, bool deleteFiles = false, b return Ok(); } - _authorService.DeleteAuthor(id, deleteFiles, addImportListExclusion); + // Deleting a large author (thousands of books) inline here would block this request for + // as long as every synchronous BookDeletedEvent subscriber (file unlink, history, extras, + // ...) takes to run against all of them. Route it through the command queue instead, same + // as every other multi-book operation, so the API returns immediately and the delete shows + // up in Activity like everything else. See backlog: "Chaptarr: run author delete as a + // background Command, not inline in the HTTP request". + _commandQueueManager.Push( + new DeleteAuthorCommand(new List { id }, deleteFiles, addImportListExclusion), + CommandPriority.Normal, + CommandTrigger.Manual); + return Ok(); } diff --git a/src/Chaptarr.Api.V1/Author/AuthorEditorController.cs b/src/Chaptarr.Api.V1/Author/AuthorEditorController.cs index 51b47b74..692c8d6b 100644 --- a/src/Chaptarr.Api.V1/Author/AuthorEditorController.cs +++ b/src/Chaptarr.Api.V1/Author/AuthorEditorController.cs @@ -340,7 +340,13 @@ private static bool HasCompatibleRootFolder(NzbDrone.Core.Books.Author author, L [HttpDelete] public object DeleteAuthor([FromBody] AuthorEditorResource resource) { - _authorService.DeleteAuthors(resource.AuthorIds, false); + // See AuthorController.DeleteAuthor - a bulk selection can add up to just as many books + // as one huge author, so this goes through the same background command instead of + // blocking the request on every synchronous BookDeletedEvent subscriber. + _commandQueueManager.Push( + new DeleteAuthorCommand(resource.AuthorIds, false), + CommandPriority.Normal, + CommandTrigger.Manual); return new { }; } diff --git a/src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs b/src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs new file mode 100644 index 00000000..ddb4db87 --- /dev/null +++ b/src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Books.Commands +{ + public class DeleteAuthorCommand : Command + { + public List AuthorIds { get; set; } + public bool DeleteFiles { get; set; } + public bool AddImportListExclusion { get; set; } + + public DeleteAuthorCommand() + { + } + + public DeleteAuthorCommand(List authorIds, bool deleteFiles, bool addImportListExclusion = false) + { + AuthorIds = authorIds; + DeleteFiles = deleteFiles; + AddImportListExclusion = addImportListExclusion; + } + + public override bool SendUpdatesToClient => true; + public override bool IsLongRunning => true; + + // Only contend with other disk-access commands (moves, renames, ...) when this delete + // will actually touch the author's files - a metadata-only delete shouldn't be serialized + // behind unrelated disk work. See PR #188 for why this group exists. + public override bool RequiresDiskAccess => DeleteFiles; + } +} diff --git a/src/NzbDrone.Core/Books/Services/AuthorService.cs b/src/NzbDrone.Core/Books/Services/AuthorService.cs index 82443e91..6ae266f9 100644 --- a/src/NzbDrone.Core/Books/Services/AuthorService.cs +++ b/src/NzbDrone.Core/Books/Services/AuthorService.cs @@ -6,6 +6,7 @@ using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Books.Commands; using NzbDrone.Core.Books.Events; using NzbDrone.Core.MediaCover.Commands; using NzbDrone.Core.MediaFiles; @@ -72,7 +73,7 @@ void EnsureMediaTypeMonitoring(int authorId, string mediaType) void ClearAuthorCache(); } - public class AuthorService : IAuthorService + public class AuthorService : IAuthorService, IExecute { private readonly IAuthorRepository _authorRepository; private readonly IEventAggregator _eventAggregator; @@ -320,6 +321,11 @@ public void DeleteAuthors(List authorIds, bool deleteFiles, bool addImportL DeleteAuthorsInternal(authorIds, deleteFiles, addImportListExclusion, false); } + public void Execute(DeleteAuthorCommand message) + { + DeleteAuthors(message.AuthorIds, message.DeleteFiles, message.AddImportListExclusion); + } + private List DeleteAuthorsInternal( List authorIds, bool deleteFiles, From d6b333e74d9a7dde4b1ed42ed39a63631c779b0f Mon Sep 17 00:00:00 2001 From: jordan Date: Sun, 27 Sep 2026 21:57:18 +0000 Subject: [PATCH 2/8] Address adversarial review: size-gate the async delete, fix status code, correct disk-access claim Three problems found in review of #260: 1. Queuing every delete unconditionally broke the old immediately-consistent contract: a client that deletes an author then re-adds it before the queued DeleteAuthorCommand actually runs would have the re-add silently wiped out once the stale delete finally executes, since AuthorLibraryService.AddAuthorAsync reuses the still-present row rather than treating it as new. Add AuthorService.DeleteAuthorsSyncOrQueue: below a 200-book threshold, delete inline (same as before this PR, so the old guarantee holds for the overwhelming majority of authors); only defer to the command queue once a delete is big enough that inline deletion is what caused the lockup in the first place. Both controllers now call this instead of always pushing the command. 2. The DeleteAuthorCommand.RequiresDiskAccess comment claimed it would keep this delete from overlapping concurrent moves/renames, but no move/rename command in the codebase opts into the disk-access group - so no such exclusion actually happens yet. Corrected the comment to state what's actually enforced today instead of overclaiming. 3. DeleteAuthor kept returning 200 OK even when the work was only queued, unlike the sibling DownloadAuthorMedia endpoint which returns 202 for the same push-then-return shape. DeleteAuthorsSyncOrQueue now reports whether it queued or ran inline, and both controllers return 202 Accepted when queued, 200 OK when actually done before responding. --- .../Author/AuthorController.cs | 18 ++++----- .../Author/AuthorEditorController.cs | 16 ++++---- .../Books/Commands/DeleteAuthorCommand.cs | 8 ++-- .../Books/Services/AuthorService.cs | 39 +++++++++++++++++++ 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/Chaptarr.Api.V1/Author/AuthorController.cs b/src/Chaptarr.Api.V1/Author/AuthorController.cs index 43b3427c..53d2b077 100644 --- a/src/Chaptarr.Api.V1/Author/AuthorController.cs +++ b/src/Chaptarr.Api.V1/Author/AuthorController.cs @@ -1237,16 +1237,14 @@ public async Task DeleteAuthor(int id, bool deleteFiles = false, b // Deleting a large author (thousands of books) inline here would block this request for // as long as every synchronous BookDeletedEvent subscriber (file unlink, history, extras, - // ...) takes to run against all of them. Route it through the command queue instead, same - // as every other multi-book operation, so the API returns immediately and the delete shows - // up in Activity like everything else. See backlog: "Chaptarr: run author delete as a - // background Command, not inline in the HTTP request". - _commandQueueManager.Push( - new DeleteAuthorCommand(new List { id }, deleteFiles, addImportListExclusion), - CommandPriority.Normal, - CommandTrigger.Manual); - - return Ok(); + // ...) takes to run against all of them. AuthorService routes it through the command + // queue once it's big enough that inline deletion is what caused this host to lock up in + // the first place, and keeps everything else on the old, immediately-consistent path. See + // backlog: "Chaptarr: run author delete as a background Command, not inline in the HTTP + // request". + var queued = _authorService.DeleteAuthorsSyncOrQueue(new List { id }, deleteFiles, addImportListExclusion); + + return queued ? Accepted() : Ok(); } [HttpPost("{id}/downloadmedia")] diff --git a/src/Chaptarr.Api.V1/Author/AuthorEditorController.cs b/src/Chaptarr.Api.V1/Author/AuthorEditorController.cs index 692c8d6b..e510c517 100644 --- a/src/Chaptarr.Api.V1/Author/AuthorEditorController.cs +++ b/src/Chaptarr.Api.V1/Author/AuthorEditorController.cs @@ -338,17 +338,19 @@ private static bool HasCompatibleRootFolder(NzbDrone.Core.Books.Author author, L } [HttpDelete] - public object DeleteAuthor([FromBody] AuthorEditorResource resource) + public IActionResult DeleteAuthor([FromBody] AuthorEditorResource resource) { // See AuthorController.DeleteAuthor - a bulk selection can add up to just as many books - // as one huge author, so this goes through the same background command instead of + // as one huge author, so this goes through the same size-gated path instead of always // blocking the request on every synchronous BookDeletedEvent subscriber. - _commandQueueManager.Push( - new DeleteAuthorCommand(resource.AuthorIds, false), - CommandPriority.Normal, - CommandTrigger.Manual); + var queued = _authorService.DeleteAuthorsSyncOrQueue(resource.AuthorIds, false); - return new { }; + if (queued) + { + return Accepted(new { }); + } + + return Ok(new { }); } } } diff --git a/src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs b/src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs index ddb4db87..6c402bc9 100644 --- a/src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs +++ b/src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs @@ -23,9 +23,11 @@ public DeleteAuthorCommand(List authorIds, bool deleteFiles, bool addImport public override bool SendUpdatesToClient => true; public override bool IsLongRunning => true; - // Only contend with other disk-access commands (moves, renames, ...) when this delete - // will actually touch the author's files - a metadata-only delete shouldn't be serialized - // behind unrelated disk work. See PR #188 for why this group exists. + // Scoped to DeleteFiles so a metadata-only delete isn't lumped into the "default" disk-access + // group at all. Note this only self-serializes against other RequiresDiskAccess commands that + // opt into the same group (see PR #188) - as of this PR that's just this command, so it does + // not yet protect against a concurrent move/rename touching the same author's files. Widening + // which commands opt in is a separate change. public override bool RequiresDiskAccess => DeleteFiles; } } diff --git a/src/NzbDrone.Core/Books/Services/AuthorService.cs b/src/NzbDrone.Core/Books/Services/AuthorService.cs index 6ae266f9..017b5588 100644 --- a/src/NzbDrone.Core/Books/Services/AuthorService.cs +++ b/src/NzbDrone.Core/Books/Services/AuthorService.cs @@ -71,6 +71,11 @@ void EnsureMediaTypeMonitoring(int authorId, string mediaType) List GetAuthorBooksFromCache(int authorId); List GetAuthorIdsByMetadataProfileId(int metadataProfileId); void ClearAuthorCache(); + // The default exists only for lightweight test doubles. Every production implementation must override it. + bool DeleteAuthorsSyncOrQueue(List authorIds, bool deleteFiles, bool addImportListExclusion = false) + { + throw new NotSupportedException(); + } } public class AuthorService : IAuthorService, IExecute @@ -321,6 +326,40 @@ public void DeleteAuthors(List authorIds, bool deleteFiles, bool addImportL DeleteAuthorsInternal(authorIds, deleteFiles, addImportListExclusion, false); } + // Below the threshold, delete inline and keep the old immediately-consistent contract - a + // caller that deletes then re-adds the same author expects the delete to have already + // happened by the time it gets a response, and that guarantee only breaks down once the + // command queue is actually in the picture. Only defer to the queue once an author (or a + // bulk selection) is large enough that inline deletion is what caused this host to lock up + // in the first place (Charles Dickens, 10113 books) - see PR #260. + private const int AsyncDeleteBookCountThreshold = 200; + + // Returns true if the delete was queued (caller should respond 202 Accepted, work not done + // yet), false if it ran inline before returning (caller should respond 200 OK, already done). + public bool DeleteAuthorsSyncOrQueue(List authorIds, bool deleteFiles, bool addImportListExclusion = false) + { + var distinctIds = (authorIds ?? new List()).Where(id => id > 0).Distinct().ToList(); + if (!distinctIds.Any()) + { + return false; + } + + var totalBooks = distinctIds.Sum(id => _bookRepository.GetBooksByAuthorId(id).Count); + + if (totalBooks <= AsyncDeleteBookCountThreshold) + { + DeleteAuthorsInternal(distinctIds, deleteFiles, addImportListExclusion, false); + return false; + } + + _commandQueueManager.Push( + new DeleteAuthorCommand(distinctIds, deleteFiles, addImportListExclusion), + CommandPriority.Normal, + CommandTrigger.Manual); + + return true; + } + public void Execute(DeleteAuthorCommand message) { DeleteAuthors(message.AuthorIds, message.DeleteFiles, message.AddImportListExclusion); From 047fb89c4d6b3b4c77552825f1402ac10e614133 Mon Sep 17 00:00:00 2001 From: jordan Date: Sun, 27 Sep 2026 23:22:11 +0000 Subject: [PATCH 3/8] Address adversarial review: fix UI premature-removal bug, batch the size-gate count query Three problems found in review of #260, one filed as a follow-up ticket: 1. createRemoveItemHandler (single-author delete) optimistically dispatched removeItem on any 2xx response, but DeleteAuthorsSyncOrQueue now returns 202 when the delete is only queued - the row is still fully present in the DB at that point. The author would vanish from the UI immediately while its 10k+ book rows were still being processed in the background; refreshing before the command finished would bring it back, and an immediate re-add by the same provider ID could hit a duplicate/conflict error against the not-yet-deleted row. Now checks jqXHR.status === 202 and skips the optimistic removeItem in that case - the item disappears naturally once the command finishes and the list next refreshes. 2. DeleteAuthorsSyncOrQueue's size check ran distinctIds.Sum(id => _bookRepository.GetBooksByAuthorId(id).Count) - one full-row-materializing query per author ID just to decide whether to run the delete inline or queue it, adding real synchronous DB load (and full Book row payloads, not just counts) to the same request path this exists to keep fast. Added IBookRepository.CountBooksByAuthorIds, a single grouped COUNT(*) query (chunked for SQLite's bind-variable limit), and switched the size check to use it. 3. Bulk delete's authorIndexActions handler unconditionally clears deleteError on any 2xx and relies on SignalR for actual list removal - already correct for the "don't remove until it's really gone" concern the single-delete handler had, so left as-is. A queued bulk delete that later fails asynchronously surfaces that failure via Activity/command history rather than the delete modal, same as every other command-based operation in this app (BulkRefreshAuthor, etc.) - not a new inconsistency this PR introduces. Filed as a backlog ticket rather than fixed here: the readdAuthor (purge & re-add) branch of AuthorController.DeleteAuthor still calls DeleteAuthorForReadd synchronously with no size gate, so purging and re-adding a massive author still reproduces the original freeze via that action. Not a quick fix - DeleteAuthorForReadd's synchronous completion is a hard dependency of the same-request AddAuthorAsync call that follows it, so queuing it requires restructuring that hand-off, not just copying DeleteAuthorsSyncOrQueue's threshold check. --- .../Creators/createRemoveItemHandler.js | 16 ++++++- .../Books/Repositories/BookRepository.cs | 47 +++++++++++++++++++ .../Books/Services/AuthorService.cs | 2 +- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js index 3e2b925d..cad3a7f1 100644 --- a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js +++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js @@ -28,7 +28,21 @@ function createRemoveItemHandler(section, url) { const promise = createAjaxRequest(ajaxOptions).request; - promise.done((data) => { + promise.done((data, textStatus, jqXHR) => { + // 202 means the delete was only queued (e.g. a large author delete run as a background + // command instead of inline) - the row hasn't actually been removed yet, so pulling it out + // of the UI now would show it as gone while it's still fully present in the database. + // Leave it in place; it'll disappear once the command finishes and the list next refreshes. + if (jqXHR.status === 202) { + dispatch(set({ + section, + isDeleting: false, + deleteError: null + })); + + return; + } + dispatch(batchActions([ set({ section, diff --git a/src/NzbDrone.Core/Books/Repositories/BookRepository.cs b/src/NzbDrone.Core/Books/Repositories/BookRepository.cs index 50726452..72cdf0f3 100644 --- a/src/NzbDrone.Core/Books/Repositories/BookRepository.cs +++ b/src/NzbDrone.Core/Books/Repositories/BookRepository.cs @@ -26,6 +26,11 @@ public interface IBookRepository : IBasicRepository List GetLastBooks(IEnumerable authorIds); List GetNextBooks(IEnumerable authorIds); List GetBooksByAuthorId(int authorId); + // The default exists only for lightweight test doubles. Every production implementation must override it. + Dictionary CountBooksByAuthorIds(IEnumerable authorIds) + { + throw new NotSupportedException(); + } List GetBooksForRefresh(int authorId, IEnumerable providerIds); List GetBooksByFileIds(IEnumerable fileIds); Book FindByTitle(int authorId, string title); @@ -198,6 +203,48 @@ public List GetBooksByAuthorId(int authorId) return Query(s => s.AuthorId == authorId); } + // A COUNT(*) ... GROUP BY, not one GetBooksByAuthorId(id) per author - that would issue N + // full-row-materializing queries just to size-check a bulk delete before it can even decide + // whether to run it inline or queue it, adding real synchronous DB load on the same request + // path this is meant to keep fast. + public Dictionary CountBooksByAuthorIds(IEnumerable authorIds) + { + var idList = (authorIds ?? Enumerable.Empty()).Distinct().ToList(); + + if (!idList.Any()) + { + return new Dictionary(); + } + + const string sql = "SELECT \"AuthorId\" AS \"Key\", CAST(COUNT(*) AS INTEGER) AS \"Value\" FROM \"Books\" WHERE \"AuthorId\" IN @Ids GROUP BY \"AuthorId\""; + + using (var conn = _database.OpenConnection()) + { + var result = new Dictionary(); + + // SQLite has a default ~999 bind-variable limit; Dapper expands IN lists into many parameters. + if (_database.DatabaseType == DatabaseType.SQLite && idList.Count > SqliteVariableLimit.MaxParameters) + { + foreach (var batch in idList.Chunk(SqliteVariableLimit.MaxParameters)) + { + foreach (var row in conn.Query>(sql, new { Ids = batch.ToArray() })) + { + result[row.Key] = row.Value; + } + } + + return result; + } + + foreach (var row in conn.Query>(sql, new { Ids = idList.ToArray() })) + { + result[row.Key] = row.Value; + } + + return result; + } + } + public List GetBooksForRefresh(int authorId, IEnumerable providerIds) { // Refresh must be author-scoped. Provider-ID lookups are handled downstream by the diff --git a/src/NzbDrone.Core/Books/Services/AuthorService.cs b/src/NzbDrone.Core/Books/Services/AuthorService.cs index 017b5588..0a65dd32 100644 --- a/src/NzbDrone.Core/Books/Services/AuthorService.cs +++ b/src/NzbDrone.Core/Books/Services/AuthorService.cs @@ -344,7 +344,7 @@ public bool DeleteAuthorsSyncOrQueue(List authorIds, bool deleteFiles, bool return false; } - var totalBooks = distinctIds.Sum(id => _bookRepository.GetBooksByAuthorId(id).Count); + var totalBooks = _bookRepository.CountBooksByAuthorIds(distinctIds).Values.Sum(); if (totalBooks <= AsyncDeleteBookCountThreshold) { From 58d4e82401df1d1705450f9124b73a9dfa6883f7 Mon Sep 17 00:00:00 2001 From: jordan Date: Sun, 27 Sep 2026 23:37:03 +0000 Subject: [PATCH 4/8] Address second round of review: correct a misleading comment, scope the 202 UI fix, add coverage Fixed: 1. DeleteAuthorCommand.RequiresDiskAccess's comment claimed no other command shared its disk-access group - wrong. Command's own base class default (DiskAccessGroup => RequiresDiskAccess ? "default" : null) already puts MoveAuthorCommand, RenameAuthorCommand, BulkMoveAuthorCommand, RescanFoldersCommand, ManualImportCommand, and others in the same "default" group this command uses, so CommandQueue's disk-access serialization already protects against a concurrent move/rename. Corrected the comment. 2. The 202-handling added to createRemoveItemHandler last round was author-delete-specific behavior baked into a generic factory shared by ~20 unrelated delete flows (books, download clients, tags, cancel-command, ...) - any of them starting to legitimately return 202 for an unrelated reason would silently inherit "leave it in the list" behavior with no guarantee a SignalR removal broadcast exists for that resource. Made it opt-in via an { allowQueuedResponse: true } option, and only authorActions' DELETE_AUTHOR registration passes it - every other consumer is completely unaffected. 3. Added AuthorServiceDeleteAuthorsSyncOrQueueFixture covering the core decision this method makes (queue + return true above the threshold, delete inline + return false at/below it) - this exact logic had already needed two rounds of behavioral fixes (the threshold check itself, then the queued-vs-inline return value driving the controllers' 202/200 split) with no test catching either. Accepted as a documented tradeoff, not fixed: the size-gate's book count and the delete that follows it aren't in one atomic transaction, so a concurrent import adding books to a selected author between the count and the delete could in theory push it over the threshold without the gate seeing it. Worst case this falls back to the original synchronous behavior for that one request - not data loss, just the pre-PR behavior in a narrow race window. Closing it fully would mean locking rows across the decision and the delete, which is a larger change than this size gate calls for. --- .../Creators/createRemoveItemHandler.js | 8 +- frontend/src/Store/Actions/authorActions.js | 4 +- ...rServiceDeleteAuthorsSyncOrQueueFixture.cs | 153 ++++++++++++++++++ .../Books/Commands/DeleteAuthorCommand.cs | 9 +- 4 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 src/Chaptarr.Core.Test/Books/AuthorServiceDeleteAuthorsSyncOrQueueFixture.cs diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js index cad3a7f1..bea7bae2 100644 --- a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js +++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js @@ -3,7 +3,11 @@ import { batchActions } from 'redux-batched-actions'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import { removeItem, set } from '../baseActions'; -function createRemoveItemHandler(section, url) { +// allowQueuedResponse: opt-in for endpoints that can respond 202 (queued, not done yet) instead +// of always meaning "already deleted" - e.g. an author delete large enough to run as a background +// command. Left off (default) for every other consumer of this shared factory, whose endpoints +// have never returned anything but a completed 2xx for a delete. +function createRemoveItemHandler(section, url, { allowQueuedResponse = false } = {}) { return function(getState, payload, dispatch) { const { id, @@ -33,7 +37,7 @@ function createRemoveItemHandler(section, url) { // command instead of inline) - the row hasn't actually been removed yet, so pulling it out // of the UI now would show it as gone while it's still fully present in the database. // Leave it in place; it'll disappear once the command finishes and the list next refreshes. - if (jqXHR.status === 202) { + if (allowQueuedResponse && jqXHR.status === 202) { dispatch(set({ section, isDeleting: false, diff --git a/frontend/src/Store/Actions/authorActions.js b/frontend/src/Store/Actions/authorActions.js index 02d7b898..852cbe8b 100644 --- a/frontend/src/Store/Actions/authorActions.js +++ b/frontend/src/Store/Actions/authorActions.js @@ -341,7 +341,9 @@ export const actionHandlers = handleThunks({ return abortRequest; }, [SAVE_AUTHOR]: createSaveProviderHandler(section, '/author', { getAjaxOptions: getSaveAjaxOptions }), - [DELETE_AUTHOR]: createRemoveItemHandler(section, '/author'), + // A large author delete runs as a background command and responds 202 (queued, not done yet) + // instead of a completed 2xx - see AuthorService.DeleteAuthorsSyncOrQueue. + [DELETE_AUTHOR]: createRemoveItemHandler(section, '/author', { allowQueuedResponse: true }), [TOGGLE_AUTHOR_MONITORED]: (getState, payload, dispatch) => { const { diff --git a/src/Chaptarr.Core.Test/Books/AuthorServiceDeleteAuthorsSyncOrQueueFixture.cs b/src/Chaptarr.Core.Test/Books/AuthorServiceDeleteAuthorsSyncOrQueueFixture.cs new file mode 100644 index 00000000..cbf7f0c6 --- /dev/null +++ b/src/Chaptarr.Core.Test/Books/AuthorServiceDeleteAuthorsSyncOrQueueFixture.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using NLog; +using NUnit.Framework; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Books; +using NzbDrone.Core.Books.Commands; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; + +namespace Chaptarr.Core.Test.Books +{ + // AuthorService.DeleteAuthorsSyncOrQueue has already needed two rounds of behavioral fixes + // (the 200-book threshold, the queued-vs-inline return value driving the controllers' 202 vs + // 200 response) with no test coverage catching either round - covering the core decision here. + [TestFixture] + public class AuthorServiceDeleteAuthorsSyncOrQueueFixture + { + private class ThrowingProxy : DispatchProxy where T : class + { + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + throw new NotImplementedException($"Test proxy does not implement {typeof(T).Name}.{targetMethod?.Name}"); + } + } + + private class CountOnlyBookRepositoryProxy : DispatchProxy + { + public Dictionary Counts { get; set; } = new(); + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + if (string.Equals(targetMethod?.Name, nameof(IBookRepository.CountBooksByAuthorIds), StringComparison.Ordinal)) + { + return Counts; + } + + throw new NotImplementedException($"Test proxy does not implement IBookRepository.{targetMethod?.Name}"); + } + } + + private class RecordingCommandQueueManagerProxy : DispatchProxy + { + public List PushedCommands { get; } = new(); + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + if (string.Equals(targetMethod?.Name, "Push", StringComparison.Ordinal) && + args?.Length >= 1 && args[0] is Command command) + { + PushedCommands.Add(command); + return null; + } + + throw new NotImplementedException($"Test proxy does not implement IManageCommandQueue.{targetMethod?.Name}"); + } + } + + private class RecordingAuthorRepositoryProxy : DispatchProxy + { + public Dictionary Authors { get; set; } = new(); + public List DeleteManyCalls { get; } = new(); + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + if (string.Equals(targetMethod?.Name, "Get", StringComparison.Ordinal) && + args?.Length == 1 && args[0] is IEnumerable getIds) + { + return getIds.Select(id => Authors.TryGetValue(id, out var author) ? author : null) + .Where(author => author != null) + .ToList(); + } + + if (string.Equals(targetMethod?.Name, "DeleteMany", StringComparison.Ordinal) && + args?.Length == 1 && args[0] is IEnumerable deleteIds) + { + DeleteManyCalls.AddRange(deleteIds); + return null; + } + + throw new NotImplementedException($"Test proxy does not implement IAuthorRepository.{targetMethod?.Name}"); + } + } + + private sealed class NoOpEventAggregator : IEventAggregator + { + public void PublishEvent(TEvent @event) + where TEvent : class, IEvent + { + } + } + + [Test] + public void should_queue_and_return_true_when_book_count_exceeds_the_threshold() + { + var bookRepository = DispatchProxy.Create(); + ((CountOnlyBookRepositoryProxy)(object)bookRepository).Counts = new Dictionary { { 1, 10113 } }; + + var commandQueue = DispatchProxy.Create(); + var commandQueueRecorder = (RecordingCommandQueueManagerProxy)(object)commandQueue; + + var service = new AuthorService( + DispatchProxy.Create>(), + new NoOpEventAggregator(), + null, + null, + commandQueue, + new CacheManager(), + bookRepository, + null, + LogManager.GetCurrentClassLogger()); + + var queued = service.DeleteAuthorsSyncOrQueue(new List { 1 }, deleteFiles: true); + + Assert.That(queued, Is.True); + var command = commandQueueRecorder.PushedCommands.OfType().Single(); + Assert.That(command.AuthorIds, Is.EquivalentTo(new[] { 1 })); + Assert.That(command.DeleteFiles, Is.True); + } + + [Test] + public void should_delete_inline_and_return_false_when_book_count_is_at_or_under_the_threshold() + { + var author = new Author { Id = 1, Name = "Small Author" }; + + var bookRepository = DispatchProxy.Create(); + ((CountOnlyBookRepositoryProxy)(object)bookRepository).Counts = new Dictionary { { 1, 5 } }; + + var authorRepository = DispatchProxy.Create(); + var authorRepoRecorder = (RecordingAuthorRepositoryProxy)(object)authorRepository; + authorRepoRecorder.Authors = new Dictionary { { author.Id, author } }; + + var service = new AuthorService( + authorRepository, + new NoOpEventAggregator(), + null, + null, + DispatchProxy.Create>(), + new CacheManager(), + bookRepository, + null, + LogManager.GetCurrentClassLogger()); + + var queued = service.DeleteAuthorsSyncOrQueue(new List { author.Id }, deleteFiles: false); + + Assert.That(queued, Is.False); + Assert.That(authorRepoRecorder.DeleteManyCalls, Does.Contain(author.Id)); + } + } +} diff --git a/src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs b/src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs index 6c402bc9..28ed66fb 100644 --- a/src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs +++ b/src/NzbDrone.Core/Books/Commands/DeleteAuthorCommand.cs @@ -24,10 +24,11 @@ public DeleteAuthorCommand(List authorIds, bool deleteFiles, bool addImport public override bool IsLongRunning => true; // Scoped to DeleteFiles so a metadata-only delete isn't lumped into the "default" disk-access - // group at all. Note this only self-serializes against other RequiresDiskAccess commands that - // opt into the same group (see PR #188) - as of this PR that's just this command, so it does - // not yet protect against a concurrent move/rename touching the same author's files. Widening - // which commands opt in is a separate change. + // group at all. When it does apply, it lands in the same "default" group every other + // RequiresDiskAccess command uses via Command's own default DiskAccessGroup (MoveAuthorCommand, + // RenameAuthorCommand, BulkMoveAuthorCommand, RescanFoldersCommand, ManualImportCommand, ...), + // so CommandQueue's disk-access serialization (see PR #188) already keeps this from running + // concurrently with a move/rename touching the same author's files. public override bool RequiresDiskAccess => DeleteFiles; } } From 07afe0ec466b6a5a5645d4ee99a30725a1f8590b Mon Sep 17 00:00:00 2001 From: jordan Date: Mon, 28 Sep 2026 02:29:41 +0000 Subject: [PATCH 5/8] Fix live production bug: CountBooksByAuthorIds sent an invalid IN clause to Postgres Confirmed live on this host: deleting author 2049 (5420 books) threw Npgsql.PostgresException: 42601 syntax error at or near "$1" - Dapper's automatic "IN @Ids" list-expansion (relied on to turn one array parameter into IN (@Ids1,@Ids2,...)) did not fire against this connection, so Postgres received a literal single positional parameter directly after IN, which is invalid syntax. This broke every author delete, not just large ones, since DeleteAuthorsSyncOrQueue calls this method first regardless of author size. Stopped relying on Dapper's magic list expansion and build the parameterized IN clause by hand (named @Id0, @Id1, ... parameters via DynamicParameters, joined into the SQL text directly). Verified the resulting placeholder shape against the live Postgres database directly via a PREPARE/EXECUTE before deploying. The existing unit test for this code path uses a mocked repository and doesn't touch a real database, so it passed before this fix and continues to pass after - it was never going to catch a real-SQL-dialect bug like this one. Noted as a gap: this class of bug (works against every in-process test double, breaks against the actual Postgres driver) isn't something the current test suite can catch at all. --- .../Books/Repositories/BookRepository.cs | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/NzbDrone.Core/Books/Repositories/BookRepository.cs b/src/NzbDrone.Core/Books/Repositories/BookRepository.cs index 72cdf0f3..869f52eb 100644 --- a/src/NzbDrone.Core/Books/Repositories/BookRepository.cs +++ b/src/NzbDrone.Core/Books/Repositories/BookRepository.cs @@ -216,29 +216,36 @@ public Dictionary CountBooksByAuthorIds(IEnumerable authorIds) return new Dictionary(); } - const string sql = "SELECT \"AuthorId\" AS \"Key\", CAST(COUNT(*) AS INTEGER) AS \"Value\" FROM \"Books\" WHERE \"AuthorId\" IN @Ids GROUP BY \"AuthorId\""; - using (var conn = _database.OpenConnection()) { var result = new Dictionary(); - // SQLite has a default ~999 bind-variable limit; Dapper expands IN lists into many parameters. - if (_database.DatabaseType == DatabaseType.SQLite && idList.Count > SqliteVariableLimit.MaxParameters) + // Dapper's automatic "IN @Ids" list expansion doesn't fire against this connection + // (confirmed live: Postgres received a literal single "$1" placeholder for the whole + // array and rejected it) - build the parameter list by hand instead of relying on it. + // SQLite also has a default ~999 bind-variable limit, so batch there regardless. + var chunkSize = _database.DatabaseType == DatabaseType.SQLite + ? SqliteVariableLimit.MaxParameters + : idList.Count; + + foreach (var batch in idList.Chunk(Math.Max(chunkSize, 1))) { - foreach (var batch in idList.Chunk(SqliteVariableLimit.MaxParameters)) + var parameters = new DynamicParameters(); + var placeholders = new List(batch.Length); + + for (var i = 0; i < batch.Length; i++) { - foreach (var row in conn.Query>(sql, new { Ids = batch.ToArray() })) - { - result[row.Key] = row.Value; - } + var name = $"Id{i}"; + placeholders.Add("@" + name); + parameters.Add(name, batch[i]); } - return result; - } + var sql = $"SELECT \"AuthorId\" AS \"Key\", CAST(COUNT(*) AS INTEGER) AS \"Value\" FROM \"Books\" WHERE \"AuthorId\" IN ({string.Join(",", placeholders)}) GROUP BY \"AuthorId\""; - foreach (var row in conn.Query>(sql, new { Ids = idList.ToArray() })) - { - result[row.Key] = row.Value; + foreach (var row in conn.Query>(sql, parameters)) + { + result[row.Key] = row.Value; + } } return result; From 8bafbccc3acd6708da801716f221b4d643fffb59 Mon Sep 17 00:00:00 2001 From: jordan Date: Tue, 29 Sep 2026 20:11:19 +0000 Subject: [PATCH 6/8] Queue the async author delete at High priority so it doesn't wait behind background searches DeleteAuthorsSyncOrQueue pushed DeleteAuthorCommand at Normal priority. A delete is an interactive UI action (the user is watching the author page wait for it), but at Normal it queues behind every background MissingBookSearch already waiting, and all command threads can be busy with rate-limited indexer searches for many minutes. Seen live: deleting a 208-book author sat at "queued" on the author page for 10+ minutes with 10 MissingBookSearch commands holding every thread and 6 more queued ahead of it. Push it at High, the same treatment ManualImportCommand gets in CommandController. It still needs a free thread, so it starts as soon as one search finishes rather than after the whole backlog. Test: the queueing test now also asserts the command is pushed at High (fails at Normal). --- .../Books/AuthorServiceDeleteAuthorsSyncOrQueueFixture.cs | 3 +++ src/NzbDrone.Core/Books/Services/AuthorService.cs | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Chaptarr.Core.Test/Books/AuthorServiceDeleteAuthorsSyncOrQueueFixture.cs b/src/Chaptarr.Core.Test/Books/AuthorServiceDeleteAuthorsSyncOrQueueFixture.cs index cbf7f0c6..14b1e0cf 100644 --- a/src/Chaptarr.Core.Test/Books/AuthorServiceDeleteAuthorsSyncOrQueueFixture.cs +++ b/src/Chaptarr.Core.Test/Books/AuthorServiceDeleteAuthorsSyncOrQueueFixture.cs @@ -45,6 +45,7 @@ protected override object Invoke(MethodInfo targetMethod, object[] args) private class RecordingCommandQueueManagerProxy : DispatchProxy { public List PushedCommands { get; } = new(); + public List PushedPriorities { get; } = new(); protected override object Invoke(MethodInfo targetMethod, object[] args) { @@ -52,6 +53,7 @@ protected override object Invoke(MethodInfo targetMethod, object[] args) args?.Length >= 1 && args[0] is Command command) { PushedCommands.Add(command); + PushedPriorities.Add(args.Length >= 2 && args[1] is CommandPriority priority ? priority : CommandPriority.Normal); return null; } @@ -119,6 +121,7 @@ public void should_queue_and_return_true_when_book_count_exceeds_the_threshold() var command = commandQueueRecorder.PushedCommands.OfType().Single(); Assert.That(command.AuthorIds, Is.EquivalentTo(new[] { 1 })); Assert.That(command.DeleteFiles, Is.True); + Assert.That(commandQueueRecorder.PushedPriorities, Is.EqualTo(new[] { CommandPriority.High }), "an interactive delete must not wait behind background searches at Normal priority"); } [Test] diff --git a/src/NzbDrone.Core/Books/Services/AuthorService.cs b/src/NzbDrone.Core/Books/Services/AuthorService.cs index 0a65dd32..2dbd1b6c 100644 --- a/src/NzbDrone.Core/Books/Services/AuthorService.cs +++ b/src/NzbDrone.Core/Books/Services/AuthorService.cs @@ -352,9 +352,14 @@ public bool DeleteAuthorsSyncOrQueue(List authorIds, bool deleteFiles, bool return false; } + // High, not Normal: this is an interactive UI action (the user is watching the author page + // wait for it), and at Normal it queues behind every background MissingBookSearch already + // waiting - all command threads can be busy with rate-limited indexer searches for many + // minutes, leaving a delete "stuck" on the author page. Same treatment ManualImportCommand + // gets in CommandController. _commandQueueManager.Push( new DeleteAuthorCommand(distinctIds, deleteFiles, addImportListExclusion), - CommandPriority.Normal, + CommandPriority.High, CommandTrigger.Manual); return true; From 637bc86f494a5e769340d813d499db85f7f06e28 Mon Sep 17 00:00:00 2001 From: jordan Date: Tue, 29 Sep 2026 20:17:11 +0000 Subject: [PATCH 7/8] Tell the user a queued author delete is happening in the background A large author delete runs as a durable background command and can sit queued behind other work, but the UI gave no sign of it: after confirming Delete the author page just stayed as it was. - createRemoveItemHandler: an onQueued(dispatch, getState, payload) hook, called on the 202 "queued" response (opt-in alongside allowQueuedResponse; other consumers unchanged). - authorActions: on a queued author delete, show an info toast ("Deleting in the background. It will be removed when a worker is free - you can leave this page."). - AuthorDetailsConnector: detect a queued/started DeleteAuthor command whose authorIds include this author (mirrors the existing RenameAuthor tracking), passed as isDeletingAuthor / isDeleteAuthorQueued. - AuthorDetails: while it is queued or running, show an info banner (queued vs running wording) and disable the toolbar Delete button so it cannot be submitted twice. - commandNames: DELETE_AUTHOR. --- frontend/src/Author/Details/AuthorDetails.js | 17 +++++++++++++++++ .../Author/Details/AuthorDetailsConnector.js | 14 ++++++++++++++ frontend/src/Commands/commandNames.js | 1 + .../Actions/Creators/createRemoveItemHandler.js | 9 ++++++++- frontend/src/Store/Actions/authorActions.js | 16 +++++++++++++++- 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/frontend/src/Author/Details/AuthorDetails.js b/frontend/src/Author/Details/AuthorDetails.js index cf4a5cf9..cabd3e4c 100644 --- a/frontend/src/Author/Details/AuthorDetails.js +++ b/frontend/src/Author/Details/AuthorDetails.js @@ -259,6 +259,8 @@ class AuthorDetails extends Component { saveError, isDeleting, deleteError, + isDeletingAuthor, + isDeleteAuthorQueued, statistics = {}, selectedMediaType, onMediaTypeChange, @@ -368,6 +370,7 @@ class AuthorDetails extends Component { @@ -412,6 +415,18 @@ class AuthorDetails extends Component { className={styles.contentBody} innerClassName={styles.innerContentBody} > + { + isDeletingAuthor ? + + { + isDeleteAuthorQueued ? + 'This author is queued for deletion. It will be deleted in the background once a worker is free, and it will disappear from the library when it finishes. You can leave this page.' : + 'This author is being deleted in the background. It will disappear from the library when it finishes. You can leave this page.' + } + : + null + } + -1 ); + // A large author delete runs as a background command and can sit queued behind other work for a + // while; surface that (and the running state) so the page is not silently unchanged. + const deleteAuthorCommand = _.find(commands, (command) => ( + command.body && + command.body.name === commandNames.DELETE_AUTHOR && + isCommandExecuting(command) && + Array.isArray(command.body.authorIds) && + command.body.authorIds.indexOf(author.id) > -1 + )); + const isDeletingAuthor = !!deleteAuthorCommand; + const isDeleteAuthorQueued = !!deleteAuthorCommand && deleteAuthorCommand.status === 'queued'; + const isFetching = isBooksFetching || isSeriesFetching || isBookFilesFetching; const isPopulated = isBooksPopulated && isSeriesPopulated && isBookFilesPopulated; @@ -404,6 +416,8 @@ function createMapStateToProps() { isSearching, isRenamingFiles, isRenamingAuthor, + isDeletingAuthor, + isDeleteAuthorQueued, isFetching, isPopulated, booksError, diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js index db9a4a0e..5ad1dd36 100644 --- a/frontend/src/Commands/commandNames.js +++ b/frontend/src/Commands/commandNames.js @@ -6,6 +6,7 @@ export const CLEAR_BLOCKLIST = 'ClearBlocklist'; export const CHECK_HEALTH = 'CheckHealth'; export const CLEAR_LOGS = 'ClearLog'; export const CUTOFF_UNMET_BOOK_SEARCH = 'CutoffUnmetBookSearch'; +export const DELETE_AUTHOR = 'DeleteAuthor'; export const DELETE_LOG_FILES = 'DeleteLogFiles'; export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles'; export const DOWNLOADED_BOOKS_SCAN = 'DownloadedBooksScan'; diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js index bea7bae2..ccef6298 100644 --- a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js +++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js @@ -7,7 +7,10 @@ import { removeItem, set } from '../baseActions'; // of always meaning "already deleted" - e.g. an author delete large enough to run as a background // command. Left off (default) for every other consumer of this shared factory, whose endpoints // have never returned anything but a completed 2xx for a delete. -function createRemoveItemHandler(section, url, { allowQueuedResponse = false } = {}) { +// +// onQueued(dispatch, getState, payload): optional feedback hook for that 202 case, so the user is told the +// delete is happening in the background instead of the page silently staying as it was. +function createRemoveItemHandler(section, url, { allowQueuedResponse = false, onQueued = null } = {}) { return function(getState, payload, dispatch) { const { id, @@ -44,6 +47,10 @@ function createRemoveItemHandler(section, url, { allowQueuedResponse = false } = deleteError: null })); + if (onQueued) { + onQueued(dispatch, getState, payload); + } + return; } diff --git a/frontend/src/Store/Actions/authorActions.js b/frontend/src/Store/Actions/authorActions.js index 852cbe8b..8341cf53 100644 --- a/frontend/src/Store/Actions/authorActions.js +++ b/frontend/src/Store/Actions/authorActions.js @@ -343,7 +343,21 @@ export const actionHandlers = handleThunks({ [SAVE_AUTHOR]: createSaveProviderHandler(section, '/author', { getAjaxOptions: getSaveAjaxOptions }), // A large author delete runs as a background command and responds 202 (queued, not done yet) // instead of a completed 2xx - see AuthorService.DeleteAuthorsSyncOrQueue. - [DELETE_AUTHOR]: createRemoveItemHandler(section, '/author', { allowQueuedResponse: true }), + [DELETE_AUTHOR]: createRemoveItemHandler(section, '/author', { + allowQueuedResponse: true, + onQueued: (dispatch, getState, payload) => { + const author = (getState().authors.items || []).find((item) => item.id === payload.id); + const name = author ? author.authorName : 'the author'; + + dispatch(showMessage({ + id: `author-delete-queued-${payload.id}`, + name: 'AuthorDeleteQueued', + message: `Deleting ${name} in the background. It will be removed when a worker is free - you can leave this page.`, + type: 'info', + hideAfter: 15 + })); + } + }), [TOGGLE_AUTHOR_MONITORED]: (getState, payload, dispatch) => { const { From 5c9eb8c9130e32344c1134c9926bf8cd9a65d36d Mon Sep 17 00:00:00 2001 From: jordan Date: Tue, 29 Sep 2026 20:19:38 +0000 Subject: [PATCH 8/8] Fetch commands after a queued author delete so the queued banner actually shows Adversarial review of the delete-feedback commit: CommandQueueManager.Push publishes no CommandUpdatedEvent (the first push to the client is when the command starts) and the 202 response has no body, so the queued DeleteAuthor command never reached the UI store and the 'queued' banner state could only appear after a reload. Dispatch fetchCommands() from the onQueued hook so the queued command is in the store right away; SignalR then carries queued -> started -> completed. --- frontend/src/Store/Actions/authorActions.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/Store/Actions/authorActions.js b/frontend/src/Store/Actions/authorActions.js index 8341cf53..c1f08cfd 100644 --- a/frontend/src/Store/Actions/authorActions.js +++ b/frontend/src/Store/Actions/authorActions.js @@ -12,6 +12,7 @@ import translate from 'Utilities/String/translate'; import { showMessage } from './appActions'; import { set, update, updateItem } from './baseActions'; import { fetchBooks } from './bookActions'; +import { fetchCommands } from './commandActions'; import createHandleActions from './Creators/createHandleActions'; import createRemoveItemHandler from './Creators/createRemoveItemHandler'; import createSaveProviderHandler from './Creators/createSaveProviderHandler'; @@ -356,6 +357,11 @@ export const actionHandlers = handleThunks({ type: 'info', hideAfter: 15 })); + + // The server publishes no update when a command is only queued (the first push to the client is + // when it starts), and the 202 response has no body, so pull the command list once now: that puts + // the queued DeleteAuthor command in the store and lets the author page show its "queued" banner. + dispatch(fetchCommands()); } }),