Keep what each scope has taken on its lock anchor, so a hold reads a row rather than a sum

A hold used to work out what a slot had taken by summing its booking rows, as a locking read. That read is what this replaces: the number lives on the anchor row the hold already locks, so a hold reads a row instead of a table.

The rules it follows

A number is kept only where a rule reads it. A slot with no capacity keeps no slot-wide number, because nothing on that slot reads one. This is not an economy: keeping a counter means writing a row, writing a row means holding it until the transaction ends, and a counter on a slot's own row would be written by every hold in every part of the house at once. Measured while that was wrong: 62.7 holds a second down to 22.0, with a third of the bookers refused.

An unkept number reads as unknown, not as nought. So a bound added later to a slot that has bookings is not a lie: the first rule that wants the number counts the bookings itself, once, inside the locks, and keeps it from then on. NULL is unknown, 0 is empty, and the difference is what makes a site with existing bookings safe to upgrade into.

The rows a hold locks contain the rows it writes. The scopes counted are the scopes locked, decided by the same question in both places, because a counter written outside the lock set is a counter two holds can each move.

What is here

taken and sub columns on yoyaku_lock_anchor; BookingConsumption, which reads off a line which numbers it counts against; per-party batching so a party of six moves each counter once rather than once per line; LockAnchorAudit with drush yoyaku:anchor-counters [--fix], comparing every counter against the bookings it stands for in both directions; and area counters in yoyaku_placement for a part of the house offered by quantity rather than seat by seat.

Measured on the load rig

Forty concurrent bookers, ninety seconds, a quota'd house of eight thousand places in sixteen parts, arms alternated.

Against 1.x before [#3618513]: 45.9 and 44.5 holds a second with 1891 and 2047 refused claims, against 56.2 and 56.4 with none.

Against 1.x with [#3618513] merged, which is what this now rebases onto: 40.3 and 46.1 holds a second with 45 and 68 failed claims, against 53.9 and 58.0 with zero in both rounds, and p50 from 550-620ms to about 440ms.

The server says why, which is better evidence than the query counts this issue opened with. Its deadlock report puts the consumption read, holding X locks on some three thousand five hundred rows of yoyaku_booking__slot_tariff_state, against a release updating one booking row by its primary key and waiting on that same index: reader goes secondary then clustered, writer clustered then secondary. Deleting the read removes one side of that pair, which is why the residual goes to zero. Dropping only the FOR UPDATE would not do it: under REPEATABLE READ a plain read answers from the transaction's snapshot and would miss a commit, which is the overbooking the clause is there to prevent.

Rebased onto [#3618513]

That issue made what an anchor is about its primary key. This branch carried its own answer to the same problem, a lookup turning scopes into ids and a lock taking them by id, and all of it is now gone: lockByIds(), idsOfRef(), the id map, and the two ways addTo() had of reaching a row. The key is (slot, scope, ref, sub) with the slice last, so a lock on a reference is a range over the key's prefix: one statement, every slice under the reference, still in the order the plan reaches the rows.

Query budgets did not change, and that is the honest result: the two-step already skipped its lookup on the hold path, so the fold removes code rather than queries.

Found by auditing this branch before merge

start(), the write that turns an unknown number into a kept one, wrote an absolute value unconditionally, and assertFits() triggers it on both of a hold's passes: the one under the locks and the one above them, before the transaction opens. So a count taken above the locks could land after somebody else had counted the same bookings, kept the number and moved it on: two requests count 5, one takes a seat and makes it 6, and the other's write puts it back to 5. A counter low by one oversells by one, quietly, until the audit runs. Every bounded scope starts life unknown, so the window is a slot's first rush rather than only a migration.

Starting a number now only ever writes over a number nobody keeps yet, which makes it idempotent: whoever writes first wins, and later attempts reach nothing and leave a counter that is being kept up properly by then. set() stays unconditional for the audit's repair(), which is meant to overwrite a wrong kept number. testCountingFromTheBookingsLeavesTheKeptNumberAlone runs the race in the order that used to lose it, and was confirmed to fail against the unguarded write with exactly 6 becoming 5.

Four other questions the audit asked came back clean: nothing writes a booking's state by raw SQL, so the hooks see every writer; the GREATEST(0, ...) floor does not hide a missed give-back, since move() tells no row, a number nobody keeps and a number already at nought apart; the area counters only read; and there is one healing site rather than several.

Measured on a seated house, for completeness

Eighty thousand seats in eighty blocks, unbounded, every booker naming its own block, arms interleaved: 114.3 against 107.6 holds a second at forty bookers, 117.4 against 100.8 at eighty, and 96.0 against 97.0 at a hundred and sixty, with no refusals and no failures anywhere. Equal within a rig whose rate noise floor is 3.5x, which is the expected answer: an unbounded seated slot keeps no numbers, because its blocks are rows to wait on rather than numbers to spend. The tail is better at a hundred and sixty, 2.7 seconds against 11.2 at the ninety-ninth percentile.

Testing

AnchorCounterTest: fifteen transitions asserted against a fresh sum of the bookings rather than against figures, so a transition that forgets its counter fails without anybody having to think of it, plus per-party batching, refused holds counting nothing, unkept numbers staying unkept, a bound added later being counted from the bookings, and the audit mending a counter nothing wrote. AreaCounterTest for a pooled part of the house. LockAnchorsTest, LockAnchorScopeTest, ConsumptionReadTest, HoldCostTest and SweepCostTest all pass against MySQL, and the query budgets are exact rather than bounded.

phpcs with the CI standard, phpstan and cspell are clean, and the translation check reports every shipped string translated.

Upgrading

Pre-1.0, so this is reinstall-only: the table gains columns and its primary key changes. A site with bookings and no counters needs nothing done, since an unkept number reads as unknown and the first reader works it out.

Edited by Frank Mably

Merge request reports

Loading
Loading