Skip to content

fix(refresh): keep per-refresh state per refresh, not on singleton services - #248

Open
jordanfelle wants to merge 2 commits into
Chaptarr:developfrom
jordanfelle:fix-refresh-singleton-shared-state
Open

jordanfelle wants to merge 2 commits into
Chaptarr:developfrom
jordanfelle:fix-refresh-singleton-shared-state

Conversation

@jordanfelle

@jordanfelle jordanfelle commented Sep 27, 2026 •

Copy link
Copy Markdown

Fixes #246.

Problem

RefreshAuthorCommand neither requires disk access nor is type exclusive, so per-author refreshes run concurrently (3 command threads by default, more with #222), but singleton services kept per-refresh state in plain fields:

  • RefreshBookService._bookMetadataCache: a plain Dictionary cleared at the start of every refresh while other refreshes read and write it (corruption, exceptions, wrong metadata).
  • RefreshAuthorService._authorRefreshRehomeBlueprint: one refresh could re-home editions from another author's snapshot, or null it under a refresh still using it.
  • _editionRefreshMatchingIndex / _bookRefreshMatchingIndex: one-entry memos that read the shared field twice, so a concurrent refresh could swap it between the reads and the caller got another list's index.

Fix

The first two are held in an AsyncLocal (one per refresh; the same approach as #218 and #181); the memos read the field once into a local. Single-threaded behaviour is unchanged. AuthorStatisticsService._deferredFileAuthorIds is a lock-protected shared debounce and is intentionally left alone.

Verification

Three tests that fail on the original code: two deterministic two-flow tests (metadata cache, rehome blueprint) and a contention test for the matching indexes (failed within 22 ms on the old code) (3,023 core tests pass). Note: touches lines near #218/#181 in RefreshBookService, so merging with those needs a trivial rebase.

Review update: the metadata cache is also reached from the single-book RefreshBookInfo(Book) path and the overload that takes a remote author, which never reset it. With a per-flow cache an executor thread that mostly runs single-book refreshes would have kept the previous cache between commands (stale author metadata, unbounded growth), so a scope helper now opens a fresh cache and restores the previous one on dispose (also on exceptions) at all three entry points. Test covers the scope semantics (3,024 core tests pass).

RefreshAuthor commands for different authors run concurrently (RefreshAuthorCommand neither requires disk access nor is type exclusive, and there are several command threads), but the singleton RefreshBookService and RefreshAuthorService kept per-refresh state in plain instance fields:

- RefreshBookService._bookMetadataCache (a Dictionary cleared at the start of every RefreshBookInfo while other refreshes read and write it) is now held per async flow.
- RefreshAuthorService._authorRefreshRehomeBlueprint (the remote snapshot used to re-home editions, set in GetRemoteChildren and nulled in a finally) is now held per async flow, so one refresh can no longer re-home editions using another author's snapshot or null it under a refresh still using it.
- The one-entry memos _editionRefreshMatchingIndex and _bookRefreshMatchingIndex checked the shared field and then read it again for the return value, so a concurrent refresh could swap it in between and the caller got another list's index. The field is now read once.

Tests fail on the previous code (two-flow isolation for the cache and the blueprint, and a contention stress test for the memos).
jordanfelle added a commit to jordanfelle/chaptarr that referenced this pull request Sep 27, 2026
jordanfelle added a commit to jordanfelle/chaptarr that referenced this pull request Sep 27, 2026
jordanfelle added a commit to jordanfelle/chaptarr that referenced this pull request Sep 27, 2026
The single-book refresh paths (RefreshBookInfo(Book) and the overload with a remote author) reached GetSkyhookData and its cache without ever opening or closing a cache; only the list overload reset it. With the cache now held per async flow, an executor thread kept the last cache between commands, so single-book refreshes could be served stale author metadata and the cache grew without bound. Open a scope (fresh cache, previous one restored on dispose, also on exceptions) at all three entry points.
jordanfelle added a commit to jordanfelle/chaptarr that referenced this pull request Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Per-refresh state on singleton services is shared between concurrent RefreshAuthor commands (plain Dictionary/HashSet fields)

1 participant