Cache the venue place map: VenueMapBuilder loads every place of a venue on every request, inside an uncacheable form

What this does

VenueMapBuilder::build() rebuilt the whole picker payload on every request to /placement/{yoyaku_slot}, hydrating every place of the venue twice (once in PlaceAvailability::availablePlaces(), once in the builder's own loop), plus a third time from PlaceBookingForm, plus every consuming booking entity just to read one place target id. The map renders inside a FormBase, which is rebuilt every request, so render caching cannot help: the fix has to live in the service.

The payload splits along a natural invalidation boundary. The venue's inventory changes only when an administrator edits the venue; the slot's state changes with every booking. So the inventory is cached and the state is read per request.

The cache

Two entries in cache.default, both permanent (geometry is exactly invalidatable, so a lifetime would be cargo cult):

cid Holds Tags
yoyaku_placement:venue_map:geometry:v1:{venue} packed places grouped by area, plus the two id-to-key maps, all language invariant yoyaku_venue:{id}, yoyaku_place_list, yoyaku_section_list, yoyaku_grade_list
yoyaku_placement:venue_map:bands:v1:{venue}:{langcode} the grade and section rows with their translated labels, colors and map elements same

Design points worth reviewing:

  • Keyed by venue, not by venue and configuration. The configuration is applied when the entry is read, so every way of opening one hall shares one cached inventory. Places are grouped by section then subsection, so a closed or pooled area is skipped as a group and the rule is asked once per area instead of once per seat.
  • Packed positional rows, not keyed ones. The reason is not the byte count, it is memcache's 1 MB default item ceiling: keyed rows reach it around 5,700 seats and packing pushes that to roughly 9,500. Past the ceiling set() fails silently, so every request would pay the full build plus a serialize plus a failed write. v1 in the cid retires old entries if the column order ever changes, which no validation branch could do as cheaply.
  • hasGeometry is computed per request, never cached. It is decided after the configuration has had its say, so a hall whose only drawn area is pooled must still draw nothing. testPooledOnlyGeometricVenueDrawsNoPlaces pins this.
  • Deliberately outside the cached half: the background SVG (a drawn hall is often hundreds of kilobytes, so caching it would undo the packing and push the entry past the ceiling; reading it is one local file read against thousands of avoided hydrations), and hook_yoyaku_placement_map_alter(), which still runs last on the assembled payload. That seam carries prices, so a cached alter would show one slot's price on another slot's map.
  • The list tags are blunt on purpose. Nothing writes a place, a section or a grade while a slot is being booked, so they fire only on inventory edits. A custom tag would need hooks for four entity types plus $place->original handling so a place moved between venues invalidates both, which is the kind of omission that leaves a hall drawing a seat it no longer owns.

Also fixed here

Two defects in the very thing being cached, both folded in rather than deferred:

  1. Area names ignored the visitor's language. Section and Grade are translatable, and Section's own docblock promises "translatable so a multilingual site shows each visitor the name in their language", but the builder called label() without getTranslationFromContext(). Those names are read out as part of every seat ("Orchestre, row A, place 12"), so a French visitor was told the name the hall was drawn under. Caching a source-language label would have frozen that in a shared entry, and retrofitting a language dimension onto a cache key later is a redesign rather than a patch. Fixed for the legend rows and for the pooled-area names.
  2. PlaceBookingForm declared no cacheability at all, while its drupalSettings payload carries per-slot seat states and the visitor's own held seats. A session-less anonymous response was therefore storable by the Internal Page Cache, with no booking tag and no max-age to end it. Now session context and max-age: 0.

Plus the lean reads the map needs: takenPlaceIds() (one indexed query, served by the existing yoyaku_booking__slot_place_state index, no schema change), scopeOpen() (the placeOpen() rule asked about an area, so placeOpen() is now a one-line delegation and the rule has one home), and hasFreePlace() (an anti-join limited to one row, replacing a full load in the form). The body of availablePlaces() now goes through takenPlaceIds() too, which drops its booking hydration and so also speeds up PlaceAssignment, which runs inside the slot lock on every hold.

Observable changes

  • places[*].label is removed from the payload. It was row . place concatenated, both of which are still in the payload, and the picker never read it: seat names are composed client-side by where() from the section, row and place, wrapped in Drupal.t(), so they are richer and correctly translated. Dropping it also removes the last language-dependent value from the geometry entry. Place::label() itself is untouched, so the admin listing and the printed ticket are unaffected.
  • Section and grade labels now follow the content language.
  • Place order is now deterministic (row, position, id) instead of whatever an unsorted query returned.

Measurement

On a 1,422-seat hall with memcache, building the same payload:

time queries place reads
cold 175.8 ms 32 2
warm 2.2 ms 4 0

Identical payload both times. The geometry entry is 145 KB.

Tests

Nine new kernel tests, and no existing test needed editing:

  • testTheSecondBuildReadsNoPlace, testNewPlaceRebuildsTheGeometry, testBookingChangesStateWithoutRebuildingGeometry, testPoolingAnAreaDropsItsPlacesWithoutRebuildingTheGeometry (the assertion a venue-and-configuration key could not make), testPooledOnlyGeometricVenueDrawsNoPlaces, and testHeldPlaceInTheBasketIsMine, which covers the mine branch and tier for the first time (the existing test holds without a cart, so it only ever reached taken).
  • VenueMapLanguageTest: each language reads its own names and neither is served the other's, and the geometry is shared across languages.
  • hasFreePlace() in ConfigurationTest: true with a free place, false when the slot is sold out though the hall still holds every place, and false when the only area is off the place market. That last case is what distinguishes it from openPlaceCount().

The place-read counts ignore aggregates, because a pooled area's capacity is counted in the database on every request by design.

Follow-ups filed

Kept out of this MR on purpose, each with its reason in the issue: moving the geometry out of drupalSettings into a cacheable JSON endpoint (the largest remaining cost, and this MR is its prerequisite), PlaceAssignment's remaining hydration inside the slot lock, and the background SVG read on remote file systems.

No new translatable string, so fr.po needs no change.

Merge request reports

Loading
Loading