The aim is fewer requests reaching Drupal at all under load, not fewer queries inside one request, so that proxies and a CDN can absorb a rush on a seat map.
One field stood in the way. The picker page inlined the visitor's own held places into drupalSettings, so it carried a session cache context and max-age = 0. Every booker who opened the map was a page Drupal had to build.
The shared half is a document of its own
/availability/slot/{slot} serves what everyone gets the same answer to: taken places, held places, pool counts and tariff prices, and no per-visitor field anywhere in it. It is a plain JsonResponse, public when the anonymous role may book, so a proxy may keep it. AvailabilityDocument is the one place that decides plain response, public or private, lifetime and age, and both the seat map and the calendar's month feed go through it.
It reports Age rather than taking a shorter lifetime. A server holding an answer for N seconds and a browser holding it N more makes staleness 2N; reporting the age it already carries means the two compose to N, and a proxy's own wait counts toward that same N. The cached entry records when it was built for exactly this.
The visitor's own half never touches the page
A digest of the held lines rides in the existing yoyaku_held cookie as field p, and the payload lives in the browser's own storage keyed by that digest. Digests agree and nothing is fetched at all; they differ and /placement/{slot}/mine is fetched once. Operation answers carry the fresh payload and the new digest together, so steady state never fetches it.
A digest rather than the payload itself, because a cookie is sent with every request under its path, images and stylesheets included, and proxies and CDNs bypass caching on a cookie's presence rather than its size. The digest is a few characters. It also stays a projection of the live basket rather than a parallel store the client authors, which is what keeps it from drifting when a hold lapses.
One contributor owns one field of that cookie and declares which, and a clash throws, so every contributor is served by a single walk of the basket.
The page itself
The picker is a controller rather than a form: a form contributes a per-session token, and a single per-session field is enough to keep a page out of every cache. With nothing per-visitor left on it the page is cacheable, and carries the slot's, the venue's and the artwork's cache tags with the same lifetime as the availability answer. Checkout became an operation on the place endpoint instead of a form submit, and the checkout script is its own library, so it no longer loads on the two admin screens that reuse the map.
The calendar needed its own cache entry first
The month feed's setCacheMaxAge() was the only thing stopping the month being recomputed on every request, through the dynamic page cache. Publishing it as a plain response without replacing that would have been a downgrade, so the feed now has a server-side entry of its own, keyed by resource, year and month, tagged, and recording its build time so it can report its age too.
API changes
The venue map's state endpoint is gone, with VenueMapBuilder::build(), withState() and state(). The shared and own halves are folded in the browser, and that fold used to live in two places, which is how a stale list of taken places could outrank the visitor's own basket. PlaceBookingForm is replaced by a controller. The cached state shape is version 2.
Remaining work
Coalescing a run of clicks into one request, and a shared stepper widget, moved to #3615940: Share one stepper widget, and coalesce a booker's clicks into one request. Renaming the drawing endpoint to say what it serves is #3615922: Rename the venue drawing to the venue map, since it is geometry and not a picture.
Issue fork yoyaku-3615840
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mably commentedComment #7
mably commentedComment #9
mably commented