fix(metadata): manual author refresh fetches past the 24 hour cache (#2601) - #2611
Conversation
…2601) Refresh Metadata on an author read the profile (GetAuthor) and the catalogue (GetAuthorWorksForAuthor) through the aggregator's 24 hour TTL cache, so a bio, photo or new book added upstream stayed hidden for up to a day after the user explicitly asked for it. metadata.WithCacheBypass marks a context as an explicit refresh. GetAuthor and the author works lookups skip their cached entry under it, ask the provider, and write the answer back so the next ordinary read sees it. A failed provider call keeps the old entry. The works lookups consume the flag before nested calls and hand rawPrimaryAuthorWorks an explicit fresh parameter, so per work cover enrichment, editions and ISBN canonicalisation keep their cache. Provider throttles and retries sit below the cache and still apply. Only the single author Refresh handler sets it. Bulk refresh, Refresh all metadata, relink, the add flows and the scheduled refresh keep the cache: they span many authors, and bulk refresh fans out concurrently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…short (#2601) Review fixes for the manual refresh cache bypass. A works answer cut short by a failed call replaced a complete cached catalogue. OpenLibrary returns a failed later works page as a successful short list, and GetAuthorWorks dropped the signal, so one flaky click left bulk refresh, the scheduled refresh, ISBN canonicalisation and GetAuthorWorksUnenriched on the short list for a day. OpenLibrary now exposes GetAuthorWorksForRefresh, whose intact flag is false only when an upstream call failed. The snapshot's complete flag is not usable here: it is false for every author with more than 200 works in the search index, so gating on it would stop the refresh writing back for exactly the prolific authors. A refresh caches only an intact answer. Otherwise the sync gets the cached works plus any new ones the fresh answer carried, with the cached copy winning for works in both, and a provider error with works cached gives the sync the cached works instead of failing it. A failed Hardcover supplement under the bypass handed the sync a list the compilation prune never ran on. GetAuthorWorksForAuthor now falls back to the cached merged catalogue in that case. GetAuthorAudiobooks honours the bypass, and keeps and returns the cached list if Audible fails. An intact raw works refresh drops the enriched authorworks: entry, so GetAuthorWorksUnenriched cannot serve the list from before the refresh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
The author page refetched as soon as the 202 landed, while the sync was still running, and never looked again, so the click showed the state from before it. The author payload now carries syncInProgress, and the page polls it every 2 seconds for up to a minute after a manual Refresh, then reloads the author and the book list. Five quick clicks started five concurrent full syncs, each past the cache. The handler counts running syncs per author and answers 409 while one is running, like Refresh all and the imports do, and the page treats that 409 as a sync to wait for. The manual refresh also passes the bypass to the Audible lookup now. The wiki, API docs and changelog fragment describe the final behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
… empty (#2601) Only OpenLibrary reports a works answer cut short by a failed call. Every other primary provider got the trust a cache miss gets, so an empty list from DNB replaced a warm cached catalogue on a manual refresh. DNB answers a failed author record lookup by falling back to a query that usually matches nothing, and returns that empty list with no error. refreshPrimaryAuthorWorks now treats an empty answer as short while the cache holds works for the author: the cached list stays and is what the sync gets. With a cold cache an empty answer is still the answer. The cache_bypass.go doc comment, the user guide and the changelog fragment now say what each provider guarantees instead of claiming a refresh is never worse off than the cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…urrent filters (#2601) The Refresh POST answers 409 for any running sync of the author, and the scheduled, bulk, Refresh all and add syncs all read the metadata cache. The page waited for that sync and showed its cached result. Now, when the first POST gets 409, the page waits for the running sync to end, posts Refresh once more and waits for that one. A second 409 stops there and shows the result without looping. The reload after the wait used the showExcluded value captured at click time, so switching to Excluded during the wait had its rows overwritten. handleRefresh no longer fetches books itself; it bumps a reload key the load effect depends on, so the reload reads the filters in force. That reload keeps the page on screen instead of showing the loading state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
Passing ctx instead of metaCtx to GetAuthorAudiobooks in fetchAuthorBooks left every api test green. TestAuthorRefresh_ManualRefreshBypassesAudibleCache uses a fake Audible catalogue, through the new Aggregator.WithAudibleCatalogue hook, to show the manual Refresh reaches Audible past a warm cache while the bulk and Refresh all sync keeps the cached list. The running sync mark tests from review pin that the claim Refresh takes is released on a works error, a panic the jobs group recovers, the Calibre relink early return, and a jobs group that refused the sync. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
There was a problem hiding this comment.
Solid fix, no blocking issues. A few things to consider:
GetAuthorAudiobooks reads but doesn't consume the bypass (aggregator_enrichment.go:194–209).
GetAuthorWorks* all call consumeCacheBypass to strip the flag before nested calls; GetAuthorAudiobooks does not. That's intentional and safe today since the Audible call has no nested aggregator-cache lookups. Just worth noting that if a nested cached lookup is ever added here, the bypass will need to be consumed first, or it will leak into those calls the same way the GetAuthorWorks* entry points prevent.
waitForSync leaks a dangling setTimeout past unmount (AuthorDetailPage.tsx:300–312).
The session.active guard prevents any state write, so there's no observable effect. But the timer still fires 2 s after unmount and the Promise resolves into a no-op. Clean cancellation would be AbortController/clearTimeout. Low priority.
emptyOverCache logic relies on an implicit contract (aggregator_author_works.go:334–338).
An empty answer from a provider without authorWorksRefreshProvider (DNB, etc.) gets intact=true from primaryAuthorWorksForRefresh, then the empty-over-cache guard fires. That's the intended path and the doc comment on authorWorksRefreshProvider explains it, but the dependency between "no GetAuthorWorksForRefresh" → intact=true → "empty-over-cache guard runs" is implicit. A short comment at the guard site pointing back to authorWorksRefreshProvider would make it easier for a future provider addition to get this right.
None of these block the change. The concurrency model (authorSyncsRunning count vs. set, syncClaimed, and the jobs.Go refusal path), the context-key scoping (consumed at entry points, invisible to per-book enrichment), the intact semantics between the OL complete vs. interrupted distinction, and the frontend session.active + reloadKey pattern all look correct. Tests cover the gaps well.
— 🤖 Bindery triage bot (automated). Reply to correct me; a human will see it.
Summary
Refresh metadata on an author read the profile and the catalogue through the metadata aggregator's 24 hour cache, so a bio, photo or new book added upstream could stay hidden for up to a day after the user explicitly asked for it. The single author Refresh now asks the providers for current data, writes an answer back into the cache only when it is whole, and the author page waits for the background sync to finish before showing the result. Ordinary reads and background work keep their cache policy. Closes #2601.
Confirmed call path (current main)
POST /author/{id}/refreshinternal/api/authors.goRefresh(was :1491) launchesfetchAuthorBooksAsyncwithdiscovery: truefetchAuthorBookscallsrefreshAuthorProfile(was :1846), which callsh.meta.GetAuthor(was :1658)fetchAuthorBookscallsh.meta.GetAuthorWorksForAuthor(was :1858)fetchAuthorBookscallsh.meta.GetAuthorAudiobooksfor audiobook and both setups (was :1885)internal/metadata/aggregator.goGetAuthorreturned theauthor:entry before calling the provider (was :740)aggregator_author_works.goGetAuthorWorksForAuthorreturned the mergedauthorworks-author:entry (was :200), and below itrawPrimaryAuthorWorksreturned its ownauthorworks-raw:entry (was :267)NewAggregatorbuilds the cache withnewTTLCache(24 * time.Hour)(:46)The raw works layer matters: bypassing only the merged entry would have re-merged a day old primary list with a fresh supplement.
Implementation notes
metadata.WithCacheBypass(ctx)/metadata.CacheBypassed(ctx)ininternal/metadata/cache_bypass.go. No context key pattern existed ininternal/metadata, so this uses the plain unexported struct key the rest of the codebase uses (origBodyCtxKeyininternal/api/maxbody.go).GetAuthor,GetAuthorWorks,GetAuthorWorksUnenriched,GetAuthorWorksForAuthorandGetAuthorAudiobooks. The works lookups consume the flag before nested calls, so per work cover enrichment, editions, book lookups and the ISBN canonicalisation path (aggregator_canonical.go, which callsrawPrimaryAuthorWorksdirectly) keep their cache, and a refresh of a prolific author does not repeat the ABS import stalls indefinitely on a large folder-backed item (no timeout, no error) and auto-resumes into the same stall #2578 enrichment fan out on every click.authorworks:entry droppedGetAuthorfailsopenlibrary.Client.GetAuthorWorksForRefresh, which shares its body withGetAuthorWorksSnapshotand reports whether any upstream call failed. It cannot be the snapshot'scompleteflag: that is also false for any author with more than 200 works in the search index (the search call is capped at 200), so gating the write back on it would stop the refresh working for exactly the prolific authors. Providers without the method cannot report a short answer, so a non empty answer from them gets the trust a cache miss gives it; a list that lost some works to a fault the provider swallows can still replace the cache. An empty one is treated as short while works are cached.catalogueSyncOptions.refreshFromProvidercarries the intent from the handler into the async sync;fetchAuthorBooksappliesWithCacheBypassto the profile, catalogue and Audible lookups only.authorSyncsRunningininternal/api/author_sync_summary.go).Refreshclaims the author before answering and returns 409a refresh for this author is already runningwhile any sync for it runs, matching Refresh all and the imports.GET /author/{id}reportssyncInProgress.AuthorDetailPageno longer refetches the moment the 202 lands. It pollsGET /author/{id}every 2 seconds untilsyncInProgressclears, for up to 60 seconds per wait, then bumps a reload key so the page's load effect reloads the author and book list with the filters in force at that moment. The button stays on "Refreshing…" meanwhile; moving to another author stops the poll.metadata.CacheBypassed(ctx), which the entry points check before consuming the flag.Which entry points bypass
POST /author/{id}/refresh)BulkHandler.fanOutRefreshes)bulkSearchConcurrency) over an arbitrary selectionPOST /authors/refresh-all)Scheduler.refreshMetadata)GetBookFromProvider, which never read the cacheI kept bulk on the cache rather than bounding it. A bounded bypass would need a new knob and still hit providers harder than today on every repeat run; the per author button covers the case the issue describes. Worth noting: the cache is in memory, so the first Refresh all after a restart is already a cold run.
Rate limits and quotas
The provider clients sit below the aggregator cache, so bypassed calls go through the same guards as a cache miss: Hardcover's shared adaptive throttle and bounded retries (
internal/metadata/hardcover/throttle.go) and OpenLibrary'sgetJSONretry with Retry-After backoff (internal/metadata/openlibrary/client.go). There is no Hardcover daily quota in the codebase to check; the only per day quota is Google Books, which is not on the author refresh path. The 409 guard caps a single author at one refresh sync at a time.Review fixes
refreshPrimaryAuthorWorkscaches only an intact answer; otherwise cached works plus new onesGetAuthorWorksForAuthorfalls back to the cached merged catalogueGetAuthorAudiobookshonours the bypass; the handler passesmetaCtxsyncInProgress, then reloadauthorworks:entry shadowed the refreshed raw worksshowExcludedcaptured at click timectxinstead ofmetaCtxtoGetAuthorAudiobooksleft every api test greenAggregator.WithAudibleCatalogue)Checklist
docs/User-Guide-Wiki.mddescribes what Refresh fetches, the wait, the 409 and the fallbacks;docs/API.mddocuments the 409 andsyncInProgress; godoc onWithCacheBypassandGetAuthorWorksForRefreshchangelog.d/2601-refresh-bypasses-cache.mdTest plan
go build ./... && go vet ./...go test ./internal/metadata/... ./internal/api ./internal/scheduler/... -count=1go test -race -count=3on the new metadata, OpenLibrary and API testsgolangci-lint run(v2.11.4) oninternal/metadata/...,internal/api/...,internal/models/...: 0 issuescd web && npm run lint(0 errors; no warnings in touched files),npm run typecheck,npx vitest run(929 passed),npm run buildgofmt -l internal/metadata internal/apiprints nothingNew tests:
TestGetAuthor_CacheBypassRefetchesAndRefreshesCacheTestGetAuthor_CacheBypassErrorKeepsCachedEntryTestGetAuthorWorksForAuthor_CacheBypassRefetchesAndRefreshesCacheTestCacheBypass_LeavesBookCacheAloneTestGetAuthorWorksForAuthor_CacheBypassPartialAnswerKeepsCachedCatalogueTestGetAuthorWorksForAuthor_CacheBypassProviderErrorServesCachedCatalogueTestGetAuthorWorksForAuthor_CacheBypassSupplementFailureServesCachedCatalogueTestGetAuthorWorksUnenriched_ServesRefreshedRawOverStaleEnrichedTestGetAuthorAudiobooks_CacheBypassRefetchesAndKeepsCacheOnErrorTestGetAuthorWorksForRefresh_HTTP_IntactOnlyWhenNoCallFailedTestAuthorRefresh_ManualRefreshBypassesMetadataCacheRefreshAuthorBooks) stays on the cache;POST /author/{id}/refreshreaches the provider with the bypass, persists the new bio and book, and writes the fresh profile backTestAuthorRefresh_RefusesSecondRefreshWhileSyncRunssyncInProgressis set while it runs and clears after; a later click starts a syncTestRefreshMetadata_KeepsMetadataCacheAuthorDetailPage: manual refresh(3 vitest cases)TestGetAuthorWorksForAuthor_CacheBypassEmptyAnswerKeepsCachedWorksTestAuthorRefresh_ManualRefreshBypassesAudibleCacheTestAuthorRefresh_RunningMarkReleasedOnEarlyExit,TestAuthorRefresh_RunningMarkReleasedWhenJobsRefuseAuthorDetailPage: manual refresh(3 more vitest cases)Fail before (first commit's tests against the unfixed aggregator and handler):
Fail before (review fix tests against the first commit):
Fail before and mutation evidence (second review round):
The running sync mark tests pass on both sides: the release paths were already right, and the tests keep them that way.
TestGetAuthorWorksForRefresh_HTTP_IntactOnlyWhenNoCallFailedcovers a new method, so it has no fail before run; its "search capped by design" case asserts the precondition that the existing snapshot reports that catalogue as incomplete, which is why the snapshot flag could not be used.TestCacheBypass_LeavesBookCacheAloneandTestRefreshMetadata_KeepsMetadataCacheare guards and pass on both sides.Follow-ups (not in this PR)
WithCacheBypassthe same way.🤖 Generated with Claude Code
https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9