A booker who names no seat asks the house to choose one, and choosing reads what is free. That read happened above the hold's locks, so every booker reaching at once read the same free house and was offered the same seats: the first was served and the rest refused for a seat that was free when they asked. On a house of 8000 sold at one price, 160 bookers at once sold 1460 seats and were refused 84 per cent of what they asked for.
The same read was also too big. A hold read the whole session to answer questions about a handful of seats: the pass primer read every free seat of the session twice per hold, the seating read the hall to find a run, and the rule against leaving a place on its own read the hall to judge one row. So the last click of a sale was the dearest one.
What changes
A request reaches the engine as a request rather than as lines, and where its units go is decided inside the locks, against what is committed, so being offered a seat and taking it are one moment. A hold reads the seats it names, by key, and the area it is being seated into: a session's seats are rows of their own carrying whether each has gone, so what is left in an area is a seek rather than a subtraction over a house, and it shrinks as the sale goes on instead of growing.
The seating asks for areas a page at a time in house order and turns another page whenever the party would not be sitting together, keeping what it has read, so stopping early can only skip reads it did not need. A party given a part of the house that turns out not to seat it is not refused there: the guess is dropped and the hall asked again with the house open to it. How many of the best areas a party may be given is a field rule on the resource, party_area: one keeps every party in the best area and queues them all for it, a number spreads them over that many, and zero spreads over every area with room for them. Four is shipped, and a hall of one area has nothing to decide.
What may be locked and what may be read are the same question asked once, so a party can only be seated inside what was locked for it. A party too big for any one area waits on every area it could be seated in; a booker adding to a basket waits on the area their party is already sitting in; and the session is waited on only where the hall offers no areas at all. The locks are taken before anything is written, so a hold no longer holds a site-wide row for the length of its own queue.
A seat cannot be sold twice, whoever writes the booking. Claiming moves only rows that are still free, and a hold that moves fewer rows than it claimed is taken back whole rather than committed on top of somebody else's seats. A booking saved outside the hold path is refused rather than written onto a seat another booking holds. Under both, the booking carries the seat it consumes with a unique key over it, so the rule holds even where those rows have drifted from what the bookings say: a session drawn before this module was installed carries none at all, and every guard above reads them. The column is empty whenever a line consumes nothing or names no seat, and repeated empties do not collide, so released lines, pooled units and halls without seats are outside the rule rather than fighting over it. A hold that meets the key is refused the way a full house refuses.
What a claim reads is the block it is about, and no longer the house. Every area card carries how many seats its part of the house has left, which was answered by listing every free seat of the session and counting them, and again by a second reader when the hold chose where to seat the party. It is one reading now, counted by the database, narrowed to the parts of the house a tariff is sold in, and kept for as long as an availability figure may be believed: the seats this request has taken since come off it before it is answered with, so a booker sees their own claim at once and everybody else sees it when it is counted again. How many seats a hall holds, and where its grades sit, are facts about the building and are kept between requests, ending when an operator edits it.
An area a booker takes by the number is bounded the same way, on what the hall holds there less what the session still offers of it, rather than by summing the bookings inside the lock. Only a pooled area still counts them, having no seats to count instead. That path is how a house of five thousand seats and upwards is most likely to be run, and it was twice the cost of selling seat by seat.
The shipped availability lifetime goes from thirty seconds to five. At a hundred and fifty seats a second a thirty second old picture is thousands of seats out of date, and a booker shown one picks seats that have gone.
Measured
A house of 8000 seats in 16 areas, 160 bookers at once, driven from empty:
- a party that names no seat: 8000 of 8000 sold, on 8000 distinct places, where the same run sold 1460 and refused 84 per cent
- refused while the house still had room: none, and asking again rather than refusing made the sale a fifth faster
- a booker clicking seats: unchanged, 160.2 and 166.6 seats a second before against 164.1 after, run as A, B, A on one machine
- what a hold costs: the same statements as 1.x, with one read fewer
On a hall ten times the size, 80000 places in 80 areas, forty bookers at once: 16.5 seats a second when the claim endpoint merged, and about 60 now, with a claim's median from 3731 ms to 90 ms. A single claim off an empty house costs 88 ms against 410 ms on 1.x, and reads 51411 rows where it read 213671. A hall whose areas are all assigned, which is how a house this size is likely to be sold, keeps pace with one sold seat by seat: 59.8 seats a second against 59.9, where 1.x managed 3.7.
Filed from this work
- #3617101: Keep what each scope has taken on its lock anchor, so a hold reads a row rather than a sum, keeping on each anchor how much of its scope is consumed: batched so a party moves each row once it still costs a statement per hold, and nothing reads the number yet
- #3617494: Let the database refuse a second booking on a seat, where the presave now refuses a second booking on a seat and the key on the bookings themselves is what is left
- #3617848: Claim a party's places through an endpoint of its own, so the booking page stays cacheable and its notices stay long enough to be read, claiming through an endpoint of its own so the booking page stays cacheable
- #3617880: A booker who asks to move to another part of the house is refused for a seat chosen before the wait, composing a rearrangement under the locks
- #3617891: Seat a party that cannot sit together in as few pieces as possible, across areas if need be, seating a party that cannot sit together in as few groups as possible
- #3617528: Repair a slot whose seats have gone missing, when it is saved, repairing a slot whose seats have gone missing
AI-Generated: Yes (Claude Code was used to draft this issue summary and to write the code, the tests and the measurements on the merge request. I reviewed them before posting.)
Issue fork yoyaku-3616896
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 #3
mably commentedComment #4
mably commentedComment #5
mably commentedComment #6
mably commentedThe open question on this issue was whether narrowing the anchor actually buys throughput, since that is the whole reason to build it. It can be sized without building it, and the answer is about a doubling rather than the order of magnitude the section count suggests.
How it can be measured before it exists
Spreading a load over N sessions gives N distinct lock rows that do not serialize against each other, while every hold still writes the one shared cachetags row for yoyaku_booking_list. That is exactly the shape N section anchors inside a single session would have. So the session-spread curve sizes the narrowing without any of it being written.
Read the ratios, not the throughput
Four ladder runs of the identical configuration produced one-session throughput of 60.6, 55.0, 50.0 and 36.0 holds a second. Nothing changed between them, and each run was valid on its own terms: memory headroom intact, workers saturated, the driver not starving. That is a spread of 68 percent, so no difference between two runs smaller than that can be claimed as a result.
Normalized to its own one-session rung, though, each ladder agrees closely with the others. Three independent ladders on an 8000 place hall, 160 bookers, 80 workers, holds only, against their own first rung:
Wait edges counted inside a single run say why, and in the plainest possible way: the same load that produced 162688 waits on one session row produced 2581 across four and 483 across sixteen.
The answer
Four effective lanes are worth about twice the holds a second, sixteen about two and three quarter times. The returns flatten because the waiting has moved off the slot row by then, so more lanes past that point buy very little.
The number of sections is not the number of lanes. What sets the effective count is how the demand spreads, and a house with many sections but a handful of price bands concentrates its rush into far fewer lanes than its section count suggests. A realistic reading for a large seated house is therefore the middle of that curve, so roughly a doubling.
That is still the largest lever available on the booking path, and worth building.
What it depends on, from the audit above
The narrow anchor is only reachable when none of the four conditions force the slot-wide one. With no tariff quota configured and no slot capacity, that reduces to two: an allotment in use, or a line taking units of a pooled area. A seated hall with neither takes the narrow path, which is the ordinary shape of a seated house rather than an edge case.
A design point the sizing exposes
If the anchor narrows to a section, the locked availability read has to narrow with it. Reading the whole house under each of forty anchors keeps exactly the cost the narrowing exists to remove, and would leave the measured gain on the table.
Not established
Moving the cachetags contention out of the database was tried, by swapping the tag checksum service for the memcache module's timestamp implementation. It removes that contention completely, zero wait edges in every arm, but whether it pays could not be resolved: the throughput comparison is between runs and the drift above is several times larger than any difference it showed. Recorded as unresolved rather than as a result.
Scope
The sizing findings land with this issue rather than separately. They are the justification for the narrowing, and they rewrite the concurrency section of the performance register that this change also has to record its own before and after in, so splitting them would only buy a conflict. The MR adds a page on sizing a site for a rush, covering what a worker costs once it has served a large hall, what happens when the crowd is many times the worker count, the order in which the queue forms, and the discipline of comparing arms inside one run rather than across runs.
AI-Generated: Yes (Claude Code ran the load ladders, produced this analysis, and wrote this comment. The narrowing itself is not implemented.)
Comment #8
mably commentedComment #9
mably commentedCorrecting the sizing I posted in comment #6, and reporting a larger finding that came out of measuring it properly.
What holds
Narrowing the anchor does what it claims. On an identical workload the number of transactions waiting at once goes from 741 to 0, reproduced across two runs, and the wait edges on the anchor fall from about 120000 to 28.
Relieving it exposed something the wide lock had been hiding: readConsumption() is a SELECT ... FOR UPDATE over every consuming booking of the session, so each hold took an exclusive lock on all of them. That was free while holds arrived one at a time and no two were ever inside it together. It is now skipped where nothing bounds the session, which is the ordinary shape of a seated house, with a test asserting both that it is skipped when safe and that capacities and quotas are still enforced when it is not.
What does not hold: the doubling in comment #6
That estimate was sized from a curve that spread bookers over sessions, used as a proxy for spreading them over parts of a house. Measured on the real mechanism the gain is about 14 percent, not 100.
The proxy was never measuring the narrowing. Spreading over sessions separates four things at once: the lock rows, the set of taken seats each hold reads, the consumption sums, and the inventory, since sixteen sessions of an eight thousand seat hall are a hundred and twenty eight thousand seats for sale rather than eight thousand. Sections separate only the first of those four. My apologies for the number; it was reasoned from the proxy rather than measured on the thing itself.
The finding that dwarfs it
Driving one session from empty to sold out, 7959 of 8000 seats, 160 bookers, holds only:
The peak of 176.8 is one session on the same machine that we had only ever seen reach 175 with sixteen sessions. The rig was never the ceiling, occupancy was, and every load figure published for this project so far was taken in the first fifth of a sale. A single number for a load run is an average over a curve that collapses, so figures here should be curves over occupancy or should state the occupancy they were taken at.
Read this part before acting on those numbers
The refusal rate, and therefore the holds a second, is substantially an artifact of the harness rather than of the engine. Each simulated visitor picks its seats once when it is born and never consults availability, so by the time the house is nearly full the seats it chose are almost certainly gone and it clicks them anyway. A real booker reads a map of what is free and clicks a free seat. So the shape of the curve is real and the 4.3 at the end is not a number to plan against. Fixing the driver to choose from the availability it already fetches is the next thing the harness needs.
What is not an artifact: the time to take a seat rises from 30ms to 182ms, sixfold, while the request rate stays flat at about 430 a second. That is cost per attempt growing as the house fills, and it is real.
Why it grows
PlaceAvailability::takenPlaceIds() reads every seat a session has taken, to answer whether one seat is free. Over the run: 634034 calls, 1515251074 rows examined, about 2390 rows per call. This is not a missing index, the (slot, place, state) index exists and is used. It is inherently a row per seat already sold, so the first click of a sale reads nothing and the last reads everything.
A table of AVAILABLE seats per session inverts it. The taken set grows with the sale; a free set shrinks, so the last clicks become the cheapest rather than the most expensive. It also composes with the section anchor, since free seats keyed by session and part of the house are both narrow and shrinking. The cost is the usual one for derived data: every path that takes or releases a seat has to maintain it, which means keeping the maintenance on the booking entity so that every writer is covered, plus a test that reconciles it against the bookings after each transition.
The site does not fall over
Worth saying plainly, since the curve reads alarmingly. Throughput of requests holds at about 430 a second the whole way and the slowest median is 182ms. The engine degrades into refusals rather than into errors, which is the designed behavior.
AI-Generated: Yes (Claude Code made the change, built the harness arm, took the measurements and wrote this comment, including the correction to its own earlier estimate.)
Comment #10
mably commentedCorrecting the caveat in comment #9. I claimed the refusal rate late in a sale was substantially an artifact of the harness, because each simulated visitor chose its seats once when it was born and never looked at what was still free. That was a reasonable suspicion and it is wrong.
The driver was fixed and the curve did not move
A visitor now reads the taken list the map already returns, exactly as a browser does, and chooses only from seats drawn as free. Same hall, same 160 bookers, same run to sell-out:
So the collapse is not the harness picking badly. It is real contention: at ninety percent sold there are around eight hundred seats left across sixteen parts of the house and a hundred and sixty bookers are racing for them, so most attempts lose the race even when the seat was free a moment earlier. That is what the last minutes of an on-sale are.
What this does establish
The reading is reproducible, which matters on a rig whose absolute throughput drifts by up to 68 percent between runs of an identical configuration. Two independent runs to sell-out gave 7959 and 7943 seats sold, and 4.3 against 4.4 holds a second in the final tenth. A curve over occupancy is stable where a single number is not, which is the argument for measuring this way rather than with a fixed window.
The time to take a seat rises from about 35ms to about 184ms in both runs, unchanged by the driver fix. That is the cost per attempt growing as the house fills, and it is the part that a table of available seats would invert.
Where that leaves the numbers
4.3 holds a second in the last tenth is a real figure and should be read as one. The site does not fall over while producing it: requests are served at about 430 a second throughout and the slowest median is 184ms, so the engine degrades into refusals rather than errors. But the final seats of a large house are, at this concurrency, very slow to sell.
AI-Generated: Yes (Claude Code wrote the caveat being corrected here, fixed the driver, re-ran the measurement and wrote this comment.)
Comment #11
mably commentedComment #12
mably commentedComment #13
mably commentedComment #14
mably commentedComment #16
mably commented