Issue #3614625: Measure whether the free-place read repeats, and whether its exclusion plans badly
Measured both halves of the issue. Neither needs a code change, so this adds the measurement as a test and records what it found.
1. The read does not repeat
One read of the free places per session per hold. RequestBooker groups a request by session and composes each session once, and the seating strategies rank the candidate list they were handed instead of reading again. The map path never makes the read at all: the visitor names the seat, so nothing has to be proposed.
FreePlaceReadCountTest counts it, by putting a query log around each surface and classifying the statements that read the place table:
| surface | free-place reads |
|---|---|
| a party of three on one rate | 1 |
| a party of three spanning two rates | 1 |
| a basket taking seats on two sessions | 2, one per session |
| draw the map, then take a named seat on it | 0 |
The single read is not enforced by anything, only by how the request is composed, and a regression to one read per rate or per unit would leave every functional test green. Hence the guard.
hasFreePlace() excludes taken places with the same NOT IN shape but stops at the first row, so it is counted separately: lumping the two together would report a picker page as making a hall-sized read.
2. The exclusion plans well as written
The premise was that MySQL plans NOT IN as a dependent subquery. On MySQL 8 it does not: it materializes the subquery once with deduplication, then applies it to the place scan before the sections are joined. The two candidate rewrites plan as an anti-join over every place, join the sections for all of them, and measure slower.
Measured on a schema seeded with 1422 places over six sections and the indexes the module installs, all three forms returning identical result sets, twenty runs each, median milliseconds:
| taken of 1422 | NOT IN |
NOT EXISTS |
LEFT JOIN + IS NULL |
|---|---|---|---|
| 0 | 2.29 | 1.92 | 2.78 |
| 50 | 2.07 | 1.92 | 2.58 |
| 350 | 1.86 | 1.91 | 2.55 |
| 700 | 1.70 | 1.91 | 2.59 |
| 1100 | 1.44 | 1.88 | 2.51 |
| 1400 | 1.24 | 1.87 | 2.49 |
The shipping form is the only one that gets faster as the hall fills, because the exclusion is what narrows the scan. Rewriting it as the join would have cost about a third of the read, so nothing is rewritten.
One thing the measurement did establish: the IS NOT NULL guard inside that subquery is load-bearing, not defensive. Without it the read returns 0 free places instead of 722, because an area sold as a pool holds bookings that name no place and a single NULL makes NOT IN answer with the empty set. A hall with one pool in it would offer no seats. That was a code comment; it is now measured and written down.
What is in the branch
FreePlaceReadCountTest, five methods, verified red against a deliberately doubledfreeCandidates()call and green on both SQLite and MySQL.docs/seating.md, the Cost section, recording the counted read and the measured exclusion.docs/placement.md, pointing at it, and a stale column list corrected: the candidate read returns the subsection too.
No user-facing strings, so no translation change.