Skip to content

perf(metadata): cache provider searches and deduplicate requests - #2615

Merged
vavallee merged 6 commits into
vavallee:mainfrom
magrhino:codex/implement-caching-and-deduplication
Oct 6, 2026
Merged

vavallee merged 6 commits into
vavallee:mainfrom
magrhino:codex/implement-caching-and-deduplication

Conversation

@magrhino

@magrhino magrhino commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Commits signed off with git commit -s
  • Tests added or updated
  • README.md and docs/DEPLOYMENT.md updated; provider cache contract documented in CONTRIBUTING.md and godoc
  • Added changelog.d/2594-metadata-request-caching.md
  • template.env updated — N/A: no environment variables or configuration format changes
  • CHANGELOG.md updated — N/A: repository policy uses fragments and assembles the changelog at release time
  • Wiki pages updated — N/A: no series/UI workflow changes; cache behavior is documented in the deployment guide

Test plan

Rebased onto main at 84a0ab24. Go checks at fb7b4753 on macOS with GOTOOLCHAIN=go1.26.1:

  • go vet ./...
  • go test -race -count=3 ./internal/metadata ./internal/metadata/hardcover
  • go test -race -count=5 -run TestDiscoverAuthorBooks_ConcurrentBulkRefreshCreatesOnceAnnouncesOnce ./internal/api
  • New regression tests fail with their fix reverted (unconfigured enricher caching, panic recovery, sync summary clone)
  • GOTOOLCHAIN=go1.26.6 go run golang.org/x/vuln/cmd/govulncheck@d1f380186385b4f64e00313f31743df8e4b89a77 ./...: zero affected vulnerabilities; one required-module advisory with no called vulnerable symbols
  • git diff --check
  • make test: all packages pass except 7 TestManualImportScan_* tests in internal/api, which fail identically on a clean 84a0ab24 checkout on macOS (/var vs /private/var); tracked in macOS: manual-import scan tests from #2480 fail and gosec G115 flags samedevice_unix.go #2868
  • go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run --timeout=10m ./...: one macOS-only gosec G115 in internal/api/samedevice_unix.go, also present on a clean 84a0ab24; tracked in macOS: manual-import scan tests from #2480 fail and gosec G115 flags samedevice_unix.go #2868. Zero issues in files this PR touches
  • tests/run-all.sh: N/A, backend validation uses the repository's Make targets
  • Frontend build/tests: N/A, no frontend changes
  • Docker or Compose validation: N/A, no container changes

The 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.

@github-actions github-actions Bot added the bindery-notified Discord notification already sent for this PR label Sep 14, 2026
@codecov

codecov Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

@vavallee

Copy link
Copy Markdown
Owner

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.

Comment thread internal/metadata/request_cache.go Fixed
@magrhino

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 84a0ab24 (rebase, no merge) and force-pushed. The branch is three signed-off commits:

  1. 2c23684f perf(metadata): cache searches and deduplicate requests (Caching and request deduplication #2594): the original commit, with conflicts resolved
  2. 1c97cbeb fix(metadata): keep enrichment cached without a hardcover token and recover fetch panics (Caching and request deduplication #2594)
  3. fb7b4753 fix(metadata): copy the thin cluster sample when cloning a cached author (Caching and request deduplication #2594)

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 2c23684f)

  • internal/metadata/aggregator.go
  • internal/metadata/aggregator_author_works.go
    • GetAuthorWorks, GetAuthorWorksUnenriched and GetAuthorWorksForAuthor bind provider scope first, then consumeCacheBypass, so nested edition, cover and ISBN lookups keep their caches.
    • rawPrimaryAuthorWorks goes through cachedRequest. refreshPrimaryAuthorWorks uses the same scoped raw key through a new primaryAuthorWorksSource.
    • A refresh deletes the enriched authorworks:<scope>:<id> entry, so it still cannot shadow fresher raw works.
  • internal/metadata/aggregator_enrichment.go
  • internal/metadata/hardcover/throttle.go: imports both metadata (for SchedulingDeadline) and main's new providererr.

Full suite against merged main

As you suggested, I ran make test on the merged result. Two tests that landed after this branch's last CI run (#2662, #2663) failed. Both pass on plain main.

  • TestCreateAuthorCore_DefaultParamsMatchHandler
    • The fixture's drain calls jobs.Group.Shutdown, which cancels the job context before the sync reaches the provider. It passed only because the stub ignores cancellation, while cachedRequest honours it up front.
    • The test now waits for the works lookup before draining.
  • TestDiscoverAuthorBooks_ConcurrentBulkRefreshCreatesOnceAnnouncesOnce
    • It required two provider GetAuthor calls, but the concurrent refresh now shares discovery's in-flight lookup.
    • The stub is wrapped in an unsharedMetaProvider whose ResolveCacheProvider returns a fresh scope per request. Both syncs reach the provider again, and the original assertion (the refresh's call arrives while discovery is gated) is unchanged.

Fixes to the PR found in re-review (1c97cbeb, fb7b4753)

  • Enrichment was never cached on a default install.
  • Provider panics. The shared fetch runs on its own goroutine, outside net/http's recovery. recoverFetch now turns a panic into a generic errFetchPanicked for every waiter. The log keeps the panic and stack with only the key's operation prefix: no query and no token hash.
  • Author clone. AuthorSyncSummary.SkippedThinClusterSample was added on main after cloneAuthor was written, so a clone shared it with the cache entry. It is now copied, and a reflection test covers every slice on the summary.
  • Docs. The caching section of docs/DEPLOYMENT.md now notes that a manual Refresh Metadata still skips the cached profile and catalogue.

Each new test fails with its fix reverted.

Validation at fb7b4753 (macOS, GOTOOLCHAIN=go1.26.1)

Follow-ups

@vavallee vavallee left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@magrhino
magrhino force-pushed the codex/implement-caching-and-deduplication branch from fb7b475 to 6aff65c Compare October 4, 2026 00:58
@magrhino

magrhino commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

@vavallee thanks, both done as you suggested, in 38632444. I rebased onto d7ac8dcd and added a test commit, 6aff65cd.

Failing enrichers on cache hits. Did what you proposed: when a snapshot isn't cacheable because an enricher failed, enrichBook stores it in the 5-minute requests.searches cache, reached through a small shortCache() helper. Lookups check the 24h cache first, then that one.

  • A failing enricher is now asked once per window rather than once per GetBook, and the result stays retryable.
  • Locked books and cancelled requests still store nothing.
  • TestEnrichmentFailureRemainsRetryable now steps the clock with synctest: five calls send one request; after the window the enricher is asked again; once it recovers, the complete snapshot holds for 24h.
  • New tests cover the locked and cancelled cases and show that a complete 24h snapshot beats a short one under the same key.
  • docs/DEPLOYMENT.md notes that these snapshots share the 1,000-entry cache with searches.

CodeQL 750. cachedRequest takes op as an explicit argument, and every caller passes a literal. recoverFetch logs that, not anything cut from the key, so the taint flow is gone.

HMAC fingerprint. Will switch hardcover/cache.go to it once #2699 lands.

Locally at 6aff65cd: make test, go vet, -race on internal/metadata, and pinned golangci-lint v2.11.4 all pass.

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>
@magrhino
magrhino force-pushed the codex/implement-caching-and-deduplication branch from 6aff65c to bfb1bdf Compare October 6, 2026 15:29

@vavallee vavallee left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vavallee
vavallee enabled auto-merge (squash) October 6, 2026 21:41
@vavallee
vavallee merged commit 20296fa into vavallee:main Oct 6, 2026
41 checks passed
vavallee added a commit that referenced this pull request Oct 8, 2026
… 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>
vavallee added a commit that referenced this pull request Oct 8, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bindery-notified Discord notification already sent for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GetBook, GetAuthor and GetEditions hand every caller the cached object, and two callers mutate it Caching and request deduplication

3 participants