Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

EndpointBEFORE (prod)AFTER (projected)Mechanism
find_available_dates w/ package_id1079–1804 ms total / 294–517 ms db50–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.
  • 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-slug seat_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 constants
  • app/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_times
  • spec/services/vendor_availability/{bulk_precomputer,read_service}_spec.rb
  • spec/requests/api/vendor/v1/{restaurants_availability,restaurant_packages}_contract_spec.rb
  • spec/routing/api/vendor/v1/restaurants_routing_spec.rb
  • spec/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.rbfind_available_dates + find_available_start_times delegate to VendorAvailability::ReadService.
  • config/routes.rbunchanged. 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 stubbing RestaurantPackage.find_by.
  • find_available_dates.json (no package_id) — byte-equal to fixture find_available_dates_without_pkg.json (20-day canned window, frozen time at 2026-06-21).
  • find_available_start_times.json (legacy) — byte-equal to fixture find_available_start_times_legacy.json.
  • find_available_start_times.json (minor_version=4) — byte-equal to fixture find_available_start_times_v4.json.
  • restaurant_packages.json — byte-equal to fixture restaurant_packages_index.json (with __cache=false so 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:

EndpointBefore db msAfter db ms (warm)After db ms (cold)Δ
find_available_dates + pkg294–5175–15 (Redis hit)30–60 (1 batched SQL + cache write)−90% to −99%
find_available_dates no pkgalready cached5–1530–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_times call hits warm Redis (5–15 ms db) on the common path, cold path goes through 1 batched SQL via BulkPrecomputer. ~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_restaurant covers Q3 — but the where.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 original restaurant_packages#index baseline 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_dates exceeds threshold, schedule a warm in Sidekiq. Future iteration.
  • Direct where.not fix: 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.rb patched to stub the vendor reader and find_vendor (was previously stubbing the legacy find_vendor_application which no longer exists in Api::Vendor::V1::BaseController). Without this patch, the new vendor V1 contract specs cannot run because the controller’s vendor_channel memoization helper relies on the vendor reader 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)