Issue #3617880: A booker who asks to move to another part of the house is refused for a seat chosen before the wait
A booker choosing another part of the house has the places they already hold moved into it. That move worked out the new seats first and took them second, and the working out happened above the hold's locks. Between the two the party waits, and under a rush that wait runs into seconds, so the seats it settled on could be gone by the time it held the locks. The exchange is all or nothing, so nothing was lost, but the booker asked for the balcony and was refused for a seat in it they never named.
The split. What has to happen is now answered separately from what the seats will be. PlacementProviderInterface::movingOut() replaces moveInto() and returns a PlacementMove: how many units move, at what tariffs, and which held lines are given up. All of that is read from the party's own seats, which no other booker can reach, so it is as true under the locks as above them, and it settles what the hold waits on. The units then travel as a quantity and an area into the ordinary hold, and placeUnderLocks() chooses the seats inside the locks, exactly as it does for a party arriving by quantity.
BookingManager needed no change for this: the lines are flagged placement_pending, the party's places outside the area go in as what the hold gives up, and the anchors already turn an area on a line into the row to wait on. The sentence about having moved is said by the seating rather than by the caller, since that is where it is known, and only on the way out, so a move refused under the locks announces nothing.
A bug this exposed, and fixed. Giving a line up saves a freshly loaded booking, so a refused exchange left a RELEASED copy in the entity cache that the rollback could not reach: the order then answered the rest of the request with lines it no longer held, and a booker whose move was refused would have been shown a party that had lost its places to a move that never happened. hold() now drops those copies when it rolls back. Latent before this, since a composition that failed meant nothing had been released yet; ordinary now, because the composition happens after the release. AreaChoiceTest's refused-move scenario is what caught it.
Tests. A new scenario asserts the order of the statements: the lock on the anchor row comes before the read of what the house has left. It is asserted that way because the outcome only differs when two bookers collide, and two bookers cannot be staged inside one process. Confirmed to fail against 1.x (the read is statement 3 and the lock statement 5) and to pass here. The existing move scenarios, including the query budget and the refused-move case, pass unchanged.
Not changed. The pass that closes a hole keeps composing above the locks. Measured on the load rig on one house filling from a third to three quarters full, at and past the knee where a hold waits seconds: 3272 claims, 1510 rearrangements that moved a party, and no refusal at all. Its exposure is small for a structural reason, since it ranks the runs it could take by how much of the party already sits in them and those seats cannot be taken by anybody else.
Cost. Measured on the same fixture, a move costs 35 statements against 36 on 1.x, where seating the same party from scratch costs 39. The locked pass is handed the party it is joining rather than reading it again: an order's own lines are reachable by nobody else, so what the party still holds is what was read a moment ago less what this same request is giving up, which is arithmetic and not a query. The one thing that cannot see is a booker working the same order in two windows at once, and that fails safely, since a stale set can only produce a poorer arrangement and never a double booking: every seat is still checked under the locks before it is taken.
Measured on the load rig, which needed a driver that changes a booker's mind: with --blocks every claim named the block the booker was already in, so the engine answered that nothing had to move and this path was never reached. --change-mind=N makes them ask for a different block once they hold N places, which is the journey a move actually is. One hall of 8,000 places in 16 blocks, 40 bookers, 30 s, party of 8, a fresh session per arm, arms interleaved:
| arm | refused | holds/s |
|---|---|---|
1.x |
199, 276, 193 | 113.5, 142.0, 114.9 |
| this branch | 0, 0, 0 | 150.4, 137.7, 124.1 |
Refusals are categorical and reproduced three times each way. The rate is not distinguishable on this rig, whose noise floor is wide enough to swallow the difference; what matters is that the branch is not slower. Latency sits on top of the other: p50 226 ms against 231, p95 495 against 463.
Two things the 1.x arm showed that are worth naming. Its refusals read "There is no longer room for what you asked for.", which is the seat the engine composed before the wait having gone by the end of it, the exact failure this issue is about. And some claims came back with the party reporting zero places while carrying the notice that they had moved: the copies left behind by the exchange, which is what the rollback fix here removes.
What is not measured is the lock footprint. The read of what the house has left now happens inside the critical section rather than before it, which is the same trade [#3616896] made for ordinary holds. The load rig's driver posts no choice of area, so it never exercises a move, and putting a number on that would mean teaching it to.
Follows [#3616896], which put the seating for a fresh hold under the same locks.
AI-Generated: Yes (Claude Code was used to draft the issue summary and to write the code, the documentation and the tests on this merge request, and to take the measurements referred to above. I review and run the work before it is merged.)