Keep how much of each scope is consumed on its lock anchor, so that checking whether a hold fits reads a row instead of summing the bookings.
Why
Deciding whether a line fits asks readConsumption(), a grouped SUM(quantity) over yoyaku_booking for the session, taken FOR UPDATE inside the lock. On a house mid-sale that aggregates over thousands of rows while holding the lock every other booker is queueing behind. The anchor rows introduced by #3616896: Seat a party under the hold's own locks, so a rush is served rather than refused are already the rows a hold locks, and they are locked for exactly the scopes whose numbers it can reach, so a counter on them would be read by the same statement that takes the lock.
The invariant that makes it safe is that you lock exactly the rows whose numbers you need, which is the same set as the rows you are about to change: a hold takes the session anchor only where a capacity exists, and the total is needed only where a capacity exists; it takes a tariff anchor only where a quota exists, and so on.
What is measured, and why this is not worth building as it stands
A working implementation is on the merge request. It is correct and it is a net loss, which is the reason this is filed rather than merged.
The SUM is already amortized: consumedOnSlot() memoizes per session and is cleared at the lock boundary, so a hold pays it twice per session however many lines it has. The counters as written cost two writes per line, one for the session anchor and one for the tariff anchor. Measured on SQLite with PlaceHoldCostTest, against the branch of #3616896: Seat a party under the hold's own locks, so a rush is served rather than refused without them:
- a party of 1: counters +2, removing the SUM saves 2, so nothing gained
- a party of 2: +4 against 2 saved
- a party of 6: +12 against 2 saved
The design question to settle first
Six seats on one session at one tariff move two counters, not twelve. Batching the deltas per anchor rather than per line would make a party of six cost two writes against the two saved, and the win becomes real.
What makes that awkward is the thing that makes the current version trustworthy. The maintenance sits on hook_yoyaku_booking_presave rather than in BookingManager, so that every writer is covered: an operator editing a line, a migration, a module nobody has written yet. A counter only some writers maintain is worse than none, because it gets believed. An entity save is per line by nature, so batching means accumulating deltas in memory and flushing them once inside the same transaction, which is more machinery in the one place where being wrong is silent.
What the merge request already does
- a taken column on yoyaku_lock_anchor, with add() and takenOf()
- maintenance on the booking entity, keyed on the state transition rather than the operation, so anything leaving a consuming state gives its quantity back without needing its own code
- BookingConsumption, which says which scopes a line counts against and how much it takes
- AnchorCounterTest: eleven scenarios covering hold, confirm, release, cancel, expire, release for settlement, cancel for settlement, complete, mark unused, delete, and several lines adding up. It asserts the counters against a fresh sum of the bookings rather than against figures, so a transition added later that forgets the counter fails without anybody having to think of it. Checked against the give-back removed, and it does fail.
Nothing in the module writes a booking's state by SQL, so the entity hooks see every writer today.
A second reader has since arrived. Choosing a block for a party that named no seat reads a free count per block, which is index-only but scans one entry per seat in the session: 8000 rows and 1.2 to 1.7 ms on a hall of 8000 places, once per attempt, and worst on an empty house. See the comments below.
What reads the number now
Two readers have arrived since this was postponed, and both work the number out rather than reading it. An area a booker takes by the number is bounded on how much of it has gone, and every area card on the booking page carries how many seats are left in that part of the house. On #3616896: Seat a party under the hold's own locks, so a rush is served rather than refused the first is derived as what the hall physically holds in the area less what the session still offers of it, and the second is a count of the session's free seats kept for as long as an availability figure may be believed. Neither is wrong, and both are arithmetic over two readings that one counter would answer outright.
Pooled areas are the case that needs this most
An area offered as a pool holds no seats. There is no row per place to count, so how much of it has gone can only be had by summing the bookings that name it, and that sum runs inside the lock. It is also the mode a festival or a standing hall is sold in, where the attendees are many and a line holds as many units as it says rather than one seat. On #3616896: Seat a party under the hold's own locks, so a rush is served rather than refused every area that names its places stopped reading the bookings, so this is the one consumption sum left on the hold path, and a counter on the anchor is the only thing that turns it into a read.
Three requirements follow for whoever builds it. The counter must count units rather than rows, because a pooled line holds a quantity. It must be kept at the grain the area is offered at, a section or one slice of one, so that a hall pooling a slice and selling the rest by the seat is counted at both grains without either absorbing the other. And where an area does name its places, the counter has to agree with the session's own seat rows, which are what the seating moves when it takes a seat: a test asserting the two against each other is what keeps them from drifting apart.
What it buys, measured on the load rig at the end of #3616896: Seat a party under the hold's own locks, so a rush is served rather than refused: one claim costs 57 ms on a hall of eight thousand places and 79 ms on one of eighty thousand, for the same sixty-odd statements. The engine does identical work on both, and the difference is the reads that touch a row per seat of the session, of which the free count per area is the largest at 13.4 ms against a fraction of that on the small hall. Those milliseconds are spent inside the lock, so under a hundred and sixty bookers they multiply into everyone's wait: the small hall sells at 220 seats a second and the large one at 95. A counter read off the anchor is what takes the last hall-sized read out of the critical section.
Postponed
On #3616896: Seat a party under the hold's own locks, so a rush is served rather than refused, which introduces the anchor table this builds on, and on the batching question above.
AI-Generated: Yes (Claude Code wrote the implementation and its tests, took the measurements, and wrote this summary. The measurements are from a local SQLite run of the cost tests.)
Issue fork yoyaku-3617101
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 #3
mably commented#3616896: Seat a party under the hold's own locks, so a rush is served rather than refused gives this a second reader, and a more urgent one than the consumption
SUM.Choosing which block to seat a party in now reads how many places each candidate block still has, so that a party is never confined to a block with no room for it. The read is
SELECT section, COUNT(*) FROM yoyaku_slot_place WHERE slot = ? AND taken = 0 GROUP BY section, once per hold that names no seat.Measured on a hall of 8000 places in 16 blocks: it is index-only,
EXPLAINreports 8000 rows, and it takes 1.2 to 1.7 ms. It counts what is FREE, so it is at its worst on an empty house and cheapest at sell-out, which is the mirror image of the read #3616896: Seat a party under the hold's own locks, so a rush is served rather than refused removed rather than a repeat of it. On a hall ten times the size it is ten times that, on every party hold.A free count on each section anchor answers it with a row read, and the section anchors already exist one per session and block. Two things make this reader a better fit than the consumption case that put the issue on hold:
The design question this issue is postponed on is unchanged: the counters are only worth building if the deltas are batched per anchor rather than written per line. What changes is the return. It is now two reads removed rather than one, and this one grows with the hall.
AI-Generated: Yes (Claude Code was used to draft this comment and to take the measurements in it. I reviewed them before posting; this comment adds no code to the issue.)
Comment #4
mably commentedOne correction to the comment above, from #3616896: Seat a party under the hold's own locks, so a rush is served rather than refused as it now stands: the count is read once per ATTEMPT rather than once per hold.
A party given an area it turns out not to fit in is no longer refused there. The area is handed back and the hall asked again with nothing chosen, which reads the count a second time. So a hall where areas fill under the party asks for it more often than once a booking, which strengthens rather than weakens the case here.
AI-Generated: Yes (Claude Code was used to draft this comment. I reviewed it before posting; this comment adds no code to the issue.)
Comment #5
mably commentedComment #6
mably commentedComment #8
mably commented