Repository navigation
perf(metadata): cache provider searches and deduplicate requests - #2615
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
Also scoped on #2594 this morning; pointing here so it's where you'll see it. This one wants to land after #2699, since it builds on the quota carrying client, and it needs a rebase rather than a merge: it's about seventy commits behind, and two files it touches moved underneath it (`aggregator_enrichment.go` with the field locks in #2767, and `GetBookByISBNWithOutcome`'s signature in `aggregator.go`). Please run the full suite against merged main after, since two individually green PRs on one file have broken main here before. Something I found today that makes this more valuable than I'd credited: `cachedRequest` clones on store, on hit and for singleflight followers, which fixes #2809, where main's `GetBook`, `GetAuthor` and editions lookups hand every caller the cached object and two callers mutate it. Worth a `Closes #2809` once rebased. Heads up that your genres clone in `aggregator_enrichment.go` is line for line the same as #2787, which is approved, so whichever lands second gets a trivial conflict. |
62e8d7b to
fb7b475
Compare
|
Rebased onto
The rebase needed more than conflict resolution. While this branch was behind, main changed the same functions it converts: #2601 (manual refresh skips the cache), #2767 (field locks in enrichment caching), #2783 (genres cloned on store and read), and the search fan-out rewrite that reports failed providers. Conflict resolution (in
|
vavallee
left a comment
There was a problem hiding this comment.
Thanks for this, the isolation and dedup side held up to everything I threw at it: clones share nothing at any depth, keys carry the provider instance and token scope, waiters cancel independently, and a panic in a shared fetch is recovered. Two things before it merges.
Cache hits now re-run failing enrichers. GetBook caches the book before enrichment and calls enrichBook on every hit, and the enrichment snapshot is only stored when every enricher succeeds (aggregator_enrichment.go:336, :403). So with a revoked Hardcover token, Hardcover 429s or a Google Books outage, five GetBook calls send five requests to the failing enricher, where main sends one because it cached the enriched book for 24h. Healthy enrichers also get asked again after the 5 minute search TTL. Suggest storing the snapshot in the 5 minute requests.searches cache when it isn't cacheable, so it stays retryable but bounded. TestEnrichmentFailureRemainsRetryable would need a clock step.
CodeQL alert 750 (request_cache.go:200) is a false positive as you said, but passing op into recoverFetch as an explicit argument instead of recovering it with strings.Cut(key, ":") breaks the taint flow, so it costs nothing to clear.
Minor, take or leave: once #2699 lands the token scope in hardcover/cache.go:16 could reuse its HMAC fingerprint instead of a bare sha256.
fb7b475 to
6aff65c
Compare
|
@vavallee thanks, both done as you suggested, in Failing enrichers on cache hits. Did what you proposed: when a snapshot isn't cacheable because an enricher failed,
CodeQL 750. HMAC fingerprint. Will switch Locally at |
Signed-off-by: magrhino <12738722+magrhino@users.noreply.github.com>
…ecover fetch panics (vavallee#2594) An enricher that answers ErrProviderNotConfigured (Hardcover is registered on every install, token or not) no longer keeps the enrichment snapshot out of the cache, so default installs stop repeating per-work enrichment searches and cover lookups. The detached shared fetch recovers a provider panic and hands every waiter a generic error, logging only the operation prefix, so a provider bug cannot take down the process or leak its detail. Adapt two tests that main added against the uncached behaviour: wait for the works lookup before the job drain cancels, and give the concurrent-refresh stub a per-request cache scope so both syncs still reach the provider while gated. Note in the deployment docs that a manual Refresh Metadata still skips the cached profile and catalogue. Signed-off-by: magrhino <12738722+magrhino@users.noreply.github.com>
…hor (vavallee#2594) main added AuthorSyncSummary.SkippedThinClusterSample after cloneAuthor was written, so a clone shared that slice with the cache entry. Copy it, and cover every slice on the summary by reflection so a field added later fails the test instead of silently aliasing. Signed-off-by: magrhino <12738722+magrhino@users.noreply.github.com>
…etch op (vavallee#2594) GetBook caches the provider book before enrichment and enriches every hit, and an incomplete enrichment snapshot was never stored, so a failing or rate limited enricher was asked once per call where main asked once per day. Keep an incomplete snapshot in the five minute cache instead: it stays retryable, at most once per window. Pass the operation name into cachedRequest explicitly and log only that, rather than cutting it from the cache key, so nothing derived from the key (provider scope, query) reaches the panic log. This clears CodeQL alert 750. Signed-off-by: magrhino <12738722+magrhino@users.noreply.github.com>
…e#2594) Cover that a failing enricher stores a short-lived snapshot only for an unlocked book on a live request, and that a complete 24h snapshot wins over a short-lived one under the same key. Note in the deployment docs that those snapshots share the five minute cache with search responses. Signed-off-by: magrhino <12738722+magrhino@users.noreply.github.com>
6aff65c to
bfb1bdf
Compare
vavallee
left a comment
There was a problem hiding this comment.
Thanks, both look good. A failing enricher now gets asked once per 5 minute window, locked books and cancelled requests still store nothing, and a clean run still caches for 24h. I reverted the short cache write and your tests caught it, which is what I wanted. CodeQL 750 shows as fixed on the PR ref.
I also merged current main locally since #3038 added guarded writes in the scheduler refresh and ABS, and everything they read from the cache comes back as a clone, so no shared objects reach those writes. Build, vet, tests and race on metadata all pass. The two red checks are from a test merge older than #3036 and #2983, so I've merged main into the branch to rerun them. Only nit is a stray blank line at aggregator_enrichment.go:318, not worth a push on its own.
No need to wait on #2699 since nothing here depends on it, so this goes in first once CI is green. The HMAC fingerprint tweak can follow after #2699.
… cover_edition, rebind editions (#3102) * fix(metadata): copy and scope cached ISBN and ASIN lookups (#2869) GetBookByISBNWithOutcome and GetCanonicalBookByASIN cached the enriched book for 24 hours under a key with no provider scope and returned the cached pointer on a hit, so a caller editing its book rewrote the entry for every later caller, and a provider configuration change kept serving the old answer. Both now bind the provider snapshot, key on its scope, fetch through the bound providers, and clone on store and on hit. enrichBook now reports whether its enrichment was complete. A record built while an enricher (or the full-record fetch) failed goes in the five minute cache instead of the 24 hour one, matching what #2615 did for GetBook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(metadata): resolve supplement translations for the language filter (#3091) With OpenLibrary primary and Hardcover supplementing, Hardcover's translated works reached the language filter with no language and passed under the default unknown "pass", or were stamped with the author's majority language and passed even under "fail". - GetAuthorWorkLanguageEvidence now asks every enricher that implements the evidence capability, not only the primary. A supplement's failure or missing token costs only its own evidence. - Hardcover evidence follows up on works left indeterminate only because they carry no language of their own: one bounded distinct_on query for any edition with a language. The first query has already proved there is no allowed edition, so a known language marks the work not allowed. Best effort; a failure leaves them indeterminate as before. - OpenLibrary edition sampling skips works it does not own (hc:, audible:, dnb:), which were a guaranteed "not found" request each. - The author majority fallback skips works whose title is in a different script from the majority's titles, and works already proved not allowed. Indeterminate evidence still gets the fallback, as #2754 intended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(metadata): derive OpenLibrary work language and cover from cover_edition (#1779) The cover and language samplers read the first five entries of /works/{id}/editions.json, whose order means nothing, so a work whose first few entries were translations was read as foreign language or got a translated cover. #2197 fixed the truncated GetEditions half; this is the sampler half. sampleWorkEditions now reads the work record and, when it names a cover_edition, that edition: the one OpenLibrary's own work page features. Only what it lacks comes from the editions sample, as before, and a sampled cover now prefers an edition in the sampled language, then one with no language, then any. A work-level cover from the record is preferred to a sampled one. Cost: a work whose featured edition carries both costs two plain document fetches instead of one editions sample; any other work costs the work record plus the sample. The round trip test is updated to pin that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(rebind): drop the previous work's provider editions (#2781) Rebind changed the book's identity and hydrated the new work but left every edition persisted for the old one attached. Search loads all of a book's editions and CriteriaISBN takes the selected or first usable ISBN, so the corrected book could keep searching on the wrong work's ISBN, and the unique foreign_id kept the old edition from its rightful book. After the guarded identity update, rebind now deletes the book's provider editions when the identity actually changed. Kept: Calibre and Audiobookshelf importer editions (calibre:, abs:), the selected edition, and any edition a download or Calibre delivery references. The delete is conditioned on the book already carrying its new identity, and UpsertMetadata refuses old-identity writes once that identity has changed, so an earlier in-flight edition write is removed and a later one cannot land. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(metadata): read Japanese titles as one script for the majority language (#3091) titleScript told Han, Hiragana and Katakana apart, so a katakana heavy Japanese title such as "ノルウェイの森" was judged foreign to an author whose other titles are mostly kanji, lost the majority language and was rejected under unknown_language_behavior = fail. The three now share one CJK bucket. Hangul stays separate, since Korean titles are written almost entirely in it and a Japanese or Chinese translation in a Korean catalogue is plainly foreign. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(hardcover): don't reject a blank work for an allowed known language (#3091) The follow up query for blank-language works rejected the work on any edition language it found, on the reasoning that the allowed-language query had already ruled allowed languages out. But that query matches code2 and code3 only, while the follow up also normalises the language name, so an edition recorded as "English" with no codes was missed by the first and then rejected by the second. The language found is now checked against the profile, and an allowed one keeps the work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(metadata): use cover_edition in OpenLibrary GetBook, state sampler cost (#1779) GetBook now takes the featured cover_edition's cover when the work record has no cover of its own: one extra request, only in that case, best effort. The work's own covers[0] still wins when present. The sampler doc comment and changelog understated the cost. Against the single editions sample it replaced, it is up to two extra requests per sampled work: two when the featured edition carries both a language and a cover, three when it lacks either, two when there is no featured edition. A test now pins the three request path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> --------- Signed-off-by: vavallee <vavallee@protonmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fold the 41 changelog.d fragments into a v1.41.0 section with upgrade notes for proxy auth, NZB content failure blocklisting, the Fix match default and the queued library scan, credit contributors the fragments did not name (#2615, #3042, #3053, #3057, #2995, #3098), and clear changelog.d. Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Repeated searches and concurrent metadata cache misses currently send redundant provider requests, while routed and explicit Hardcover edition lookups can miss each other's cache entries. This change reuses successful provider responses and combines identical in-flight fetches while preserving account isolation, cancellation, complete editions, and retryable failures. Closes #2594. Closes #2809: cached books, authors and editions are cloned on store, on hit and for shared waiters, so callers no longer mutate the cache entry. Refs #2592.
Scope
When integrating #2593, retain quota state in token-bound clients, keep cache hits and shared waiters outside attempt accounting, and verify quota headroom for mixed interactive/background callers. This PR does not implement quota accounting.
Checklist
git commit -sREADME.mdanddocs/DEPLOYMENT.mdupdated; provider cache contract documented inCONTRIBUTING.mdand godocchangelog.d/2594-metadata-request-caching.mdtemplate.envupdated — N/A: no environment variables or configuration format changesCHANGELOG.mdupdated — N/A: repository policy uses fragments and assembles the changelog at release timeTest plan
Rebased onto
mainat84a0ab24. Go checks atfb7b4753on macOS withGOTOOLCHAIN=go1.26.1:go vet ./...go test -race -count=3 ./internal/metadata ./internal/metadata/hardcovergo test -race -count=5 -run TestDiscoverAuthorBooks_ConcurrentBulkRefreshCreatesOnceAnnouncesOnce ./internal/apiGOTOOLCHAIN=go1.26.6 go run golang.org/x/vuln/cmd/govulncheck@d1f380186385b4f64e00313f31743df8e4b89a77 ./...: zero affected vulnerabilities; one required-module advisory with no called vulnerable symbolsgit diff --checkmake test: all packages pass except 7TestManualImportScan_*tests ininternal/api, which fail identically on a clean84a0ab24checkout on macOS (/varvs/private/var); tracked in macOS: manual-import scan tests from #2480 fail and gosec G115 flags samedevice_unix.go #2868go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run --timeout=10m ./...: one macOS-only gosec G115 ininternal/api/samedevice_unix.go, also present on a clean84a0ab24; tracked in macOS: manual-import scan tests from #2480 fail and gosec G115 flags samedevice_unix.go #2868. Zero issues in files this PR touchestests/run-all.sh: N/A, backend validation uses the repository's Make targetsThe rebased patch passed independent high-risk review after the conflict resolution and follow-up fixes. Remote CI is not certified by these local results; see the PR comment for the full rebase notes.