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.v1in the cid retires old entries if the column order ever changes, which no validation branch could do as cheaply. hasGeometryis 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.testPooledOnlyGeometricVenueDrawsNoPlacespins 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->originalhandling 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:
- Area names ignored the visitor's language.
SectionandGradeare translatable, andSection's own docblock promises "translatable so a multilingual site shows each visitor the name in their language", but the builder calledlabel()withoutgetTranslationFromContext(). 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. PlaceBookingFormdeclared no cacheability at all, while itsdrupalSettingspayload 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. Nowsessioncontext andmax-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[*].labelis removed from the payload. It wasrow . placeconcatenated, both of which are still in the payload, and the picker never read it: seat names are composed client-side bywhere()from the section, row and place, wrapped inDrupal.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, andtestHeldPlaceInTheBasketIsMine, which covers theminebranch andtierfor the first time (the existing test holds without a cart, so it only ever reachedtaken).VenueMapLanguageTest: each language reads its own names and neither is served the other's, and the geometry is shared across languages.hasFreePlace()inConfigurationTest: 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 fromopenPlaceCount().
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.