PRD — Availability Cache-Warming (“Processing”) Indicator
Status: Drafted from grilling; ready for review
Branch: continues perf/hhserver-vendor-availability-major
Tracking: EPIC: Availability cache-warming (PROCESSING) indicator #8317 - tracked on PR #8274
Audience: HungryHub Engineering + CS-Ops
Relationship to prior work: Direct continuation of the shipped bookability
diagnosis feature (docs/PRD-bookability-diagnosis.md) — adds a third verdict
state to its cache-vs-config attribution.
1. Problem
When someone edits availability-affecting config (agenda days, package active/visible/dates, pricing, reservation duration), the availability cache is recomputed asynchronously. For a short window the diagnosis page (and the customer surface) can show availability that does not yet reflect the edit.
Today the bookability diagnosis page classifies a not-bookable result as either a CACHE BUG (fresh DB says bookable, cache disagrees) or a CONFIG MISTAKE (fresh DB itself says not bookable). It has no concept of “still recomputing.” So a page viewed mid-warm — where the cache legitimately hasn’t caught up yet — is falsely attributed as a CACHE BUG, generating exactly the kind of false ticket the diagnosis feature was built to eliminate.
We want to surface a third, honest state: “⏳ Recomputing availability — this is expected, not a bug. Started Ns ago.”
2. Grounded reality (what the codebase already does)
This materially de-risks the feature versus a naive “build async warming” plan:
- Invalidation is already asynchronous via Kafka. On an availability-
affecting save,
update_inv_checker_restaurant_package_level_cache(app/models/hh_package/restaurant_package.rb:594, alsoagenda.rb:173,package_attr.rb:225,pricing.rb:148) checks a monitored field set —auto_extend, start_date, end_date, deleted_at, active, active_for_third_party_only, is_visible, is_visible_for_staff, reservation_duration— and, when one changed, firesKarafka.producer.produce_asynctoEVENTS::INVENTORY::CACHE::TOPIC. - A consumer already re-warms.
Mirage::CacheConsumer(karafka.rb:79, consumer groupinventory_service, 2 partitions) consumes that topic and re-warms the cache. - Warming primitives exist.
VendorAvailability::BulkPrecomputer.warm_dates/warm_range;ReadServicealso warms inline on read-miss as a fallback;find_available_date(legacy cache) vscalc_find_available_date(fresh DB truth) are the two sides the diagnosis already compares.
Conclusion: the async “processing window” (edit → Kafka event →
Mirage::CacheConsumer re-warm) already exists. This feature surfaces
that window; it does not build async warming from scratch. The only new
backend mechanism is a lightweight warm-status record so the UI can tell
whether a re-warm is in flight.
3. Goal
Make the bookability diagnosis page (admin/CS) correctly show a PROCESSING state while a config-triggered re-warm is in flight, and flip to up to date when it completes — so a mid-warm page is never mis-attributed as a cache bug.
4. Primary user & surface
- Primary: CS-Ops / admin on the bookability diagnosis page, who currently risk reading a mid-warm page as a cache bug.
- v1 surface: admin diagnosis page only. Customer-facing “recomputing” UI is a non-goal for v1 (flagged as future).
5. Solution
5.1 Warm-status record (the one new backend mechanism)
A small status entry, written when a cache-invalidation event is produced and updated when the consumer finishes:
- Key shape:
availability_warm_status:{restaurant_id}:{slug|all}(Redis hash recommended — cheap, TTL-able, no migration). Fields:state(pending|processing|done|failed),window(date range or “default”),enqueued_at,started_at,finished_at,source(which model/field triggered it),kafka_offset|job_id(for debugging). - Lifecycle:
- Producer side (
update_inv_checker_restaurant_package_level_cache): on producing the Kafka event, writestate: pending, enqueued_at: now. - Consumer side (
Mirage::CacheConsumer): on pickup setprocessing, started_at; on successful warm setdone, finished_at; on errorfailed.
- Producer side (
- TTL / stuck semantics: status key TTL ~15 min. If
stateispending|processingbutenqueued_atis older than a stuck threshold (e.g. 2 min), the UI shows “taking longer than expected” rather than spinning forever.failedsurfaces a distinct “warm failed — refresh or escalate.” - Idempotency: writing status must not break the existing Kafka path if it
errors (wrap in the same
safe_call/APM-report pattern used in the presenter).
Note: if Redis status proves insufficient (e.g. need history/audit), fall back to a DB row; v1 uses Redis for zero-migration speed.
5.2 Third diagnosis state: PROCESSING
Extend Admin::BookabilityPresenter#attribution:
- Before classifying CACHE BUG vs CONFIG MISTAKE, check the warm-status record.
- If
state ∈ {pending, processing}(and within the stuck threshold) and the cache disagrees with fresh → return PROCESSING, not CACHE BUG. - If fresh and cache agree → normal verdict (warming irrelevant).
- If
failedor stuck → a distinct “warm stalled” advisory (still not a silent cache-bug claim).
5.3 UI (reuse PR #7960’s pattern only)
- A banner on the diagnosis page: “⏳ Recomputing availability for this restaurant (started Ns ago). Values below may not reflect your latest edit yet.”
- A polling JS controller (modeled on #7960’s
inventory_override_controller.js, built fresh & minimal) that hits a small JSON endpoint returning the warm-status for(restaurant, slug?)and flips the banner to “✓ Up to date” whendone. - Endpoint:
GET /admin/restaurants/:restaurant_id/availability_warm_status(admin-namespaced, read-only JSON).
5.4 Synchronous fallback (safety)
ReadService already warms inline on read-miss. We do not remove that. The
indicator is informational; if the async warm is slow, a read still self-warms,
so the customer path is never slower or staler than today.
6. Acceptance criteria
- After editing a monitored field (e.g. closing Mondays on an agenda — the tested flow), the diagnosis page for an affected date shows PROCESSING within X seconds (target ≤ the produce→consume latency, ~a few seconds).
- When
Mirage::CacheConsumerfinishes the re-warm, the page (via polling) flips to up to date without a manual reload. - A mid-warm not-bookable page is never attributed as CACHE BUG.
- A stuck/failed warm shows a distinct “taking longer / failed” state, not an infinite spinner and not a false CACHE BUG.
- Sync fallback intact: disabling the indicator must not change cache freshness or read latency vs today.
- No regression to the existing Kafka producer /
Mirage::CacheConsumerwarm path — status writes are additive and failure-isolated. - Status writes/reads add no measurable latency to the edit-save path or the diagnosis page load (Redis hash get/set only).
7. Non-goals (v1)
- Not changing the availability algorithm or the Kafka/Mirage warm pipeline.
- Not the write-side inventory-override batch progress (that is PR #7960’s scope — see §9).
- No customer-facing “recomputing” UI (future).
- No new Sidekiq worker unless §8 shows the Kafka consumer can’t host the status write (see decision in §8).
8. Open decision — Sidekiq vs Kafka-consumer for status
The grilling assumed Sidekiq. The codebase shows the warm already runs in the
Karafka consumer, not Sidekiq. Recommendation: write the status from the
existing producer + Mirage::CacheConsumer (no new Sidekiq worker) — fewer moving
parts, and the status naturally tracks the actual warm. Only introduce a
Sidekiq worker if status must be written from a context where Karafka isn’t
available. This must be confirmed during implementation (engineer to verify
Mirage::CacheConsumer can write the status cheaply and idempotently).
9. Relationship to PR #7960
PR #7960 (feat/inv-progress, 46 files, +1411/−1364) is a write-side
inventory-override batch-progress feature (InventoryBulkOverrideWorker,
override grid, batch columns on updated_inventories). It is a different
feature from this read-side freshness indicator. The user is not confident in
it.
Recommendation: Close #7960 and build this focused feature. Reuse only the UI pattern (status record + polling controller + banner). If write-side override progress is still wanted, scope it separately later, smaller.
10. Risks
- Status/warm divergence: if the consumer crashes after marking
processingbut beforedone, status could be stuck — mitigated by TTL + stuck threshold. - Partition ordering: 2 Kafka partitions mean events for one restaurant could
interleave; key status per
(restaurant, slug)and treat the latestenqueued_atas authoritative. - Over-signaling: showing PROCESSING too eagerly (for warms that finish in ms) is noise; only show it when the cache actually disagrees with fresh AND a warm is in flight.
11. Source-of-truth file map
- Producer/trigger:
app/models/hh_package/restaurant_package.rb:594,agenda.rb:173,package_attr.rb:225,pricing.rb:148 - Kafka topic:
EVENTS::INVENTORY::CACHE::TOPIC; consumerMirage::CacheConsumer(karafka.rb:79) - Warm primitives:
app/services/vendor_availability/bulk_precomputer.rb,read_service.rb - Diagnosis attribution to extend:
app/presenters/admin/bookability_presenter.rb(attribution) - UI pattern reference (do not merge): PR #7960
app/javascript/controllers/admin/inventory_override_controller.js