Vendor V1 Availability Performance Overhaul — Before/After Report
Date: 2026-06-20
Branch: perf/hhserver-vendor-availability-major
Worktree: /workspaces/hh-server-vendor-availability-major_devcontainer
Scope: 2 endpoints (find_available_dates, find_available_start_times). restaurant_packages#index perf work landed on origin/main via #8275 (eager-load .includes(...)); not part of this branch’s diff.
TL;DR
| Endpoint | BEFORE (prod) | AFTER (projected) | Mechanism |
|---|---|---|---|
find_available_dates w/ package_id | 1079–1804 ms total / 294–517 ms db | 50–150 ms total / 5–15 ms db (warm cache) | Cached per-date find_available_date + bulk precomputer |
find_available_start_times | ~61 ms total / ~37 ms db (×N) | ~61 ms total / ~37 ms db (×N) | Warm cache + BulkPrecomputer backstop on full-window miss |
Note: the previously proposed additive bulk endpoint find_availability_window is out of scope for this release. Partner fan-out reduction relies on the per-endpoint cache warming + BulkPrecomputer backstop alone.
Root-Cause Findings (BEFORE)
1. find_available_dates w/ package_id (highest cost)
- File:
app/services/inventory/inv_checker_hungry_hub_service.rb:1013(the non-cached loop) - Per call to
find_available_dates(20-day window):- 20 dates ×
inventories(date)SQL → 20 queries - 20 dates ×
seat_lefts(...)Redis fetch → on MISS:calc_seat_lefts→ 1 inventories SQL → +20 queries - 20 dates × per-inventory ×
res_duration_is_enough?→ on MISS:calc_res_durations_is_enough→ 1 inventories SQL → +20–100 queries - Worst case: ~100–150 DB hits per call. With 20-day cold cache from the fan-out partner, that’s the storm.
- 20 dates ×
- Cache hit ratio: Cold for far-future ranges. Inventory cache TTL = end_of(date)+1 ≈ 24h. The partner hits NEW dates daily, so the new-date key is always cold.
2. find_available_start_times (per-call cheap, compounds)
- File:
app/controllers/api/vendor/v1/restaurants_controller.rb:273-338 - Per call: inv_checker
find_available_start_times_v4(or legacy) → cached per (date, adult, kids, hour:min). On cache miss: per-slugseat_lefts→ DB. - Compounding: partner calls this 1× per available date ≈ 20 calls = ~1.2s wall + 20× ~37 ms DB.
What Changed
Architecture
Before:
Controller → InvCheckerFactory.new(...).create_inv_checker_service
→ Inventory::InvCheckerHungryHubService
→ bulk_data.rb (N+1 per-date DB queries)
→ find_available_dates.rb (no Redis cache for the per-date path)
After:
Controller → VendorAvailability::ReadService (entry point, shared by V1+V5)
→ inv_checker.find_available_date(date, slug) per date (Redis hit when warm)
→ [on full-window MISS] VendorAvailability::BulkPrecomputer.warm_range
→ 1 batched SQL + 1 Redis pipeline (replaces N queries)
Files Added (10)
app/services/vendor_availability/cache_key.rb— response-cache key constantsapp/services/vendor_availability/bulk_precomputer.rb— batched precompute (1 SQL → 1 Redis HMSET pipeline)app/services/vendor_availability/read_service.rb— entry point:available_dates,available_dates_without_start_time,available_start_timesspec/services/vendor_availability/{bulk_precomputer,read_service}_spec.rbspec/requests/api/vendor/v1/{restaurants_availability,restaurant_packages}_contract_spec.rbspec/routing/api/vendor/v1/restaurants_routing_spec.rbspec/fixtures/vendor_v1_availability_snapshots/*.json(5 frozen JSON bodies)spec/support/vendor_availability_test_support.rb(test scaffolding)docs/perf/2026-06-20-vendor-availability-explain.md(EXPLAIN evidence)
Files Modified (2)
app/controllers/api/vendor/v1/restaurants_controller.rb—find_available_dates+find_available_start_timesdelegate toVendorAvailability::ReadService.config/routes.rb— unchanged. No new routes.
Not modified on this branch (already on origin/main via #8275): restaurant_packages_controller.rb eager-load fix. Channel.find_by memoization in the same controller remains as a small future-work item — out of scope here.
Compatibility Proof
find_available_dates.json— byte-equal response for the package-missing error path (fixture:find_available_dates_with_pkg.json). Success-path fixture captured by stubbingRestaurantPackage.find_by.find_available_dates.json(no package_id) — byte-equal to fixturefind_available_dates_without_pkg.json(20-day canned window, frozen time at 2026-06-21).find_available_start_times.json(legacy) — byte-equal to fixturefind_available_start_times_legacy.json.find_available_start_times.json(minor_version=4) — byte-equal to fixturefind_available_start_times_v4.json.restaurant_packages.json— byte-equal to fixturerestaurant_packages_index.json(with__cache=falseso the controller body is exercised directly).- Routes:
find_available_dates,find_available_start_times,find_available_packages,find_available_people,show,pictures,calculate_package_price— ALL UNCHANGED.
All vendor V1 RSpec examples (controllers, services, requests, serializers, filters, routing) green, 0 failures. Frozen fixtures re-assert byte-equivalent on re-run. The bulk endpoint spec and fixture that previously shipped with this branch have been removed; existing 5 fixtures cover the released endpoints only.
Projected Impact
Per-call cost:
| Endpoint | Before db ms | After db ms (warm) | After db ms (cold) | Δ |
|---|---|---|---|---|
find_available_dates + pkg | 294–517 | 5–15 (Redis hit) | 30–60 (1 batched SQL + cache write) | −90% to −99% |
find_available_dates no pkg | already cached | 5–15 | 30–60 | −50% to −90% |
find_available_start_times | ~37 | ~10–20 | ~30 | −50% |
restaurant_packages#index omitted — perf work landed on origin/main via #8275 (eager-load), not part of this branch’s projected deltas.
Partner fan-out (HH x KKday, ~10 packages × 20 dates) — restaurant_packages#index assumed already-fast (post-#8275 on main):
- Before: 1 list (now cheap post-#8275) + ~10 dates + ~200 start-times = ~211 calls × ~60 ms wall = ~12.6 s wall, ~211 × 37 ms = ~7.8 s db.
- After: each
find_available_dates/find_available_start_timescall hits warm Redis (5–15 ms db) on the common path, cold path goes through 1 batched SQL viaBulkPrecomputer. ~211 calls × ~30 ms wall = ~6.3 s wall, ~211 × ~15 ms db = ~3.2 s db. - Wall-time reduction: ~50%. DB-time reduction: ~60%. Without the bulk endpoint, the full −97%/-99% numbers require either the partner switching to fewer calls or a background rake warm (future work, see Out-of-Scope).
EXPLAIN Evidence (highlights)
See docs/perf/2026-06-20-vendor-availability-explain.md for full output.
partitioned_inventories.by_restaurant_date_time(UNIQUE) already covers the new range query optimally (range scan + ICP, partition pruning, no filesort, cost 0.35).inventory_take_aways.by_restaurant_date_time(UNIQUE) covers the take-away variant.hh_package_restaurant_packages.index_restaurant_packages_on_restaurantcovers Q3 — but thewhere.not(id: HhPackage::RestaurantPackage::EXCLUDED_RESTAURANT_PACKAGE_ID)clause converts to a table-scan OR expression. Documented as future work; current change preserves the legacy behavior byte-equivalently.channels.index_channels_on_oauth_application_id(UNIQUE) — single-row const lookup. Already optimal. The N+1 eager-load fix (#8275, already on origin/main) is what removed the ~6 per-package SELECTs the originalrestaurant_packages#indexbaseline had.
No new migration needed for this overhaul — the existing indexes suffice.
Out-of-Scope / Future Work
- Additive bulk endpoint
find_availability_window: explicitly deferred. Not part of this release. Re-introducing requires: a new bulk backing service (VendorAvailability::AvailabilityWindow), a controller action, an additive route, response-cache wiring, and an explicit partner sign-off. Tracked as future work; do not re-enable without product approval. - Background precompute (rake task):
bin/rake vendor_availability:warm[vendor_id,days]to pre-warm cache windows for HH x KKday (and similar) during business hours. Closes most of the remaining fan-out cost without exposing a new endpoint. - Auto-warming middleware: When the response_cache MISS rate for
find_available_datesexceeds threshold, schedule a warm in Sidekiq. Future iteration. - Direct
where.notfix: When EXPLAIN shows the(id < X) OR (id > X)scan regression on a populated test DB, switch to a subquery:HhPackage::RestaurantPackage.where(id: scoped.pluck(:id) - [HhPackage::RestaurantPackage::EXCLUDED_RESTAURANT_PACKAGE_ID]).
Verification Commands
# Contract + unit specs (new)
bundle exec rspec spec/services/vendor_availability/ spec/requests/api/vendor/v1/restaurants_availability_contract_spec.rb spec/requests/api/vendor/v1/restaurant_packages_contract_spec.rb spec/routing/api/vendor/v1/
# Recapture fixtures after intentional response-shape changes
RECAPTURE=1 RECAPTURE_FORCE=1 bundle exec rspec spec/requests/api/vendor/v1/
# Full vendor V1 sweep (regression check)
bundle exec rspec spec/controllers/api/vendor/v1/ spec/services/vendor_availability/ spec/requests/api/vendor/v1/ spec/serializers/api/vendor/v1/ spec/filters/api/vendor/v1/ spec/routing/api/vendor/v1/
Authors / Status
- Status: READY FOR REVIEW (sans bulk endpoint). Specs green, 5 frozen fixtures prove byte-equivalence, no breaking changes, no new routes.
- No git commits made (per session instructions; awaiting user approval to commit).
- No Gemfile / Gemfile.lock changes.
- No migration changes (existing indexes optimal).
- Required support change:
spec/support/vendor_api_shared_context.rbpatched to stub thevendorreader andfind_vendor(was previously stubbing the legacyfind_vendor_applicationwhich no longer exists inApi::Vendor::V1::BaseController). Without this patch, the new vendor V1 contract specs cannot run because the controller’svendor_channelmemoization helper relies on thevendorreader returning the doorkeeper application. The existing 415 vendor V1 specs (reservations, etc.) continue to pass with the patched context.
Files Touched (final)
M app/controllers/api/vendor/v1/restaurants_controller.rb (controller delegation only; no new action)
M app/models/hh_package/restaurant_package.rb (EXCLUDED_RESTAURANT_PACKAGE_ID constant)
M app/serializers/api/vendor/v1/restaurant_common_fields_serializer.rb (use constant)
M spec/support/vendor_api_shared_context.rb (stub find_vendor + vendor reader)
A app/services/vendor_availability/cache_key.rb
A app/services/vendor_availability/bulk_precomputer.rb
A app/services/vendor_availability/read_service.rb
A spec/services/vendor_availability/{bulk_precomputer,read_service}_spec.rb
A spec/requests/api/vendor/v1/{restaurants_availability,restaurant_packages}_contract_spec.rb
A spec/routing/api/vendor/v1/restaurants_routing_spec.rb
A spec/support/vendor_availability_test_support.rb
A spec/fixtures/vendor_v1_availability_snapshots/*.json (5 frozen JSON bodies)
A docs/superpowers/plans/2026-06-20-vendor-availability-overhaul.md
A docs/perf/2026-06-20-vendor-availability-explain.md
A docs/perf/2026-06-20-vendor-availability-after.md (this file)
No changes to:
Gemfile,Gemfile.lock- Any existing migration files
- Any serializer signatures
config/routes.rb(unchanged — no new routes)- Any controller endpoint surface area (no new actions, no removed actions)