Skip to content

perf(cache): hot-dataset caching — book-detail DTO, reviews, bounded catalog (availability stays live) (#387) - #389

Open
fabiodalez-dev wants to merge 4 commits into
perf/caching-measure-opcache-querycachefrom
perf/step4-hot-dataset-cache
Open

perf(cache): hot-dataset caching — book-detail DTO, reviews, bounded catalog (availability stays live) (#387)#389
fabiodalez-dev wants to merge 4 commits into
perf/caching-measure-opcache-querycachefrom
perf/step4-hot-dataset-cache

Conversation

@fabiodalez-dev

Copy link
Copy Markdown
Owner

Step 4 of #387, stacked on #388 (uses its generation-key invalidation). Availability is NEVER cached — the design separates the static DTO (cached) from live copie_disponibili/copie_totali/stato (read fresh every request).

Cached, and how each invalidates

  • Book-detail static DTO book_detail_{locale}_{id} (TTL 300): metadata + authors/publishers/series/related, availability stripped before storage; re-read LIVE every request (fetchLiveAvailability, PK IN + deleted_at IS NULL), soft-deleted book 404s. Invalidated by booksChanged().
  • Reviews book_reviews_{locale}_{id}: invalidated by new ContentCache::reviewsChanged() from RecensioniRepository approve/reject/delete.
  • Bounded catalog pages catalog_page_{locale}_{md5(bounded filters+sort+page)}: only the finite low-cardinality filter space (facet bounding + page<=10 + canonical sort); rows availability-stripped, merged live per request, soft-deleted dropped. Invalidated by booksChanged() (fires from every write-path + DataIntegrity availability recompute).

Not cached (deliberate)

Autocomplete/search-preview: SearchController carries live availability, and the preview key embeds free text -> unbounded key space = disk-fill vector the codebase forbids. Left live.

Safety / tests

No migration/new config/new extension; cold cache = identical behavior. tests/hot-dataset-cache-387.unit.php (26 checks) proves via real renders that a warm cache serves fresh availability while keeping cached metadata. Full suite 139/139, PHPStan level 5 clean, soft-delete guard clean.

Part of #387.

@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9f67d806-efc7-414c-bd8e-5a63808abf2e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…g pages — availability stays live (#387)

Step 4 of the caching overhaul. Cache the remaining hot public datasets while
NEVER caching real-time availability (a stale copie_disponibili is a double-loan bug).

- Book-detail (book_detail_{locale}_{id}, TTL 300): the static DTO — book row
  minus copie_disponibili/copie_totali/stato, plus authors/publishers/series/related —
  is cached; availability is stripped before storage and re-read LIVE on every
  request (fetchLiveAvailability, PK IN(...) with deleted_at IS NULL) for the book
  and its related cards. A soft-deleted book 404s from the live read.
- Reviews cached separately (book_reviews_{locale}_{id}), invalidated by
  ContentCache::reviewsChanged() from RecensioniRepository approve/reject/delete.
- Bounded catalog listing pages cached only for the finite low-cardinality filter
  space (facet-cache bounding, page <= 10, canonical sort); rows availability-
  stripped, merged live per request, soft-deleted rows dropped.
- Register book_detail_/book_reviews_ in QueryCache::NAMESPACE_PREFIXES; booksChanged()
  also bumps book_detail_ and already fires from every write-path and from
  DataIntegrity availability recompute, so catalog+detail invalidate on every
  loan-driven availability change.

Autocomplete/search-preview deliberately NOT cached: SearchController returns live
availability and the preview key would embed free text (unbounded key space =
disk-fill vector the codebase forbids). Left live.

Upgrade-safe: no migration/new config/new extension; cold cache = identical behavior.
New tests/hot-dataset-cache-387.unit.php (26 checks) proves via real renders that a
warm cache serves fresh availability while cached metadata stays. Full suite 139/139,
PHPStan clean, soft-delete clean.
@fabiodalez-dev
fabiodalez-dev force-pushed the perf/step4-hot-dataset-cache branch from 6988507 to f20914f Compare August 27, 2026 11:07
@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

Review manuale completa (CodeRabbit salta automaticamente le PR con base non-default). Correzioni in f20914f4:

  • invalidazione recensioni anche dai write-path mobile edit/delete;
  • rimosso l’email del recensore dal DTO pubblico persistente;
  • availability-only invalidation separata, così i DTO statici book-detail restano caldi;
  • chiusi statement MySQL e aggiunto limit alla chiave catalogo.

Test: hot-dataset 30/30 su DB isolato; cache regressions 55/55; APCu 10/10; PHPStan pulito. Branch ribasato sulla PR #388 aggiornata.

@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

🔍 adamsreview --full — PR #389 (hot-dataset caching)

Ran diff-local + structural/opus lenses over the stacked diff, then validated against the code. The availability-never-cached invariant holds — the real value here is a set of invalidation gaps: write-paths that mutate now-cached columns but don't bump the cache. These are regressions the caching introduces (before this PR every page rebuilt from the DB, so a missing invalidation was invisible). None are blockers (all bounded by the short TTL), but the cover/series ones are worth fixing.

🟠 F1 — cover updates don't invalidate the cache (medium)

LibriController::fetchCover() (UPDATE libri SET copertina_url=…) and syncCovers() (bulk) never call ContentCache::booksChanged(). copertina_url is now in the cached book_detail_ DTO (l.* minus the 3 availability fields) and in cached catalog rows, so a changed cover stays hidden on the public book page and catalog grid for up to the TTL (300s/120s). Fix: add ContentCache::booksChanged() after both cover updates.

🟠 F2 — series mutations don't invalidate book-detail (medium)

The CollaneController series endpoints (saveDescription/rename/merge/updateOrder/bulkAssign/removeBook) mutate libri.collana / numero_serie via SeriesRepository, which has no ContentCache call (only createParentWork bumps). The cached DTO stores collana, numero_serie and the whole seriesBooks sibling list, so adding/removing/renaming/merging/reordering a series leaves stale series data on the book page and every sibling volume's page for up to the 300s TTL. availabilityChanged() (from recalculateBookAvailability) does not cover this — it bumps catalog_/home_, never book_detail_. Fix: have the SeriesRepository mutations call booksChanged().

🟡 F3 / F4 — lower-severity staleness (low)

  • Profile name edit (ProfileController UPDATE utenti SET nome,cognome) doesn't call reviewsChanged(); the reviews block caches CONCAT(nome,' ',cognome), so a renamed reviewer shows the old name until TTL. Cosmetic, self-heals.
  • Plugin writes (z39-server SBN ids, frbr-lrm opera_id) update libri without ContentCache — those columns enter the l.* DTO but are not rendered by the book-detail view today, so no user-visible staleness now; latent for any future view.

✅ Verified clean (positive confirmations)

  • No availability leak: stripLiveAvailability covers exactly stato/copie_totali/copie_disponibili; seriesBooks carries no availability columns; catalog rows are stripped and merged live. Availability is never served from cache.
  • Bounded key space: limit=12, page cap 10, whitelisted filters (free-text/genre/publisher/year/author rejected), canonical sort → finite (locale × 4 states × 6 sorts × 10 pages). No attacker/free-text disk-fill.
  • Locale embedded in every key; no cross-locale leak. Soft-delete bumps booksChanged() synchronously AND the live read 404s a since-deleted book (defense in depth), so the canonical-301-before-404 edge is effectively closed.
  • Removing u.email from the reviews query is a PII-minimization win (email would otherwise sit in the cached block).

Verdict: mergeable. F1/F2 are real staleness regressions worth a follow-up (add the missing booksChanged()); F3/F4 are cosmetic/latent.

… profile-name changes (adamsreview #389, #387)

Step-4 caching introduced staleness on write-paths that mutate now-cached columns
but didn't bump the cache (before caching, every page rebuilt from the DB so the
gap was invisible). Add the missing invalidations:

- LibriController::fetchCover() (single) → ContentCache::booksChanged();
  syncCovers() (bulk loop) → deferBooksChanged() (collapses to one bump at
  shutdown). copertina_url is in the cached book_detail_ DTO and catalog rows.
- SeriesRepository mutations (assignPrimarySeries, updatePrimaryOrder,
  removeBookFromSeries, deleteSeries, renameSeries, mergeSeries) →
  deferBooksChanged(). collana/numero_serie and the sibling-volumes list are in
  the cached DTO, so a series change was leaving stale data on every sibling's
  page. Repository-level so every caller (CollaneController endpoints) is covered.
- ProfileController profile update → ContentCache::reviewsChanged(); the cached
  reviews block stores CONCAT(nome,' ',cognome), so a name change showed the old
  reviewer name until TTL.

Not fixed (deliberate): the z39-server SBN ids and frbr-lrm opera_id plugin
writes land in the l.* DTO but are read by NO view (0 occurrences in
book-detail.php), so invalidating them is a functional no-op today — left latent
rather than touching two plugins for zero user-visible benefit.

hot-dataset test 30/30, full suite 139/139, PHPStan clean.
fabiodalez-dev added a commit that referenced this pull request Aug 27, 2026
The book-detail page cache refactor (#389) moved the DTO build into
buildBookDetailStatic(), where the $collana series name is a local
variable used only for the sibling query and never returned in the DTO.
bookDetail() therefore left $collana undefined in the render scope, so
the view rendered the "Nella stessa collana" section (driven by
$seriesBooks, which IS in the DTO) with an empty series name for every
book that belongs to a collana.

Re-derive $collana in the render scope from the cached book row; the
field survives stripLiveAvailability(), which removes only copie_*/stato.

Fixes the deep-regression failure issue-75 test 6 (frontend same-series
section) and the equivalent user-facing rendering regression.
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.

1 participant