fix: settle the move collision check against concurrent moves - #211
dmccoystephenson merged 4 commits into
Conversation
moveEntityToLocation refused a move into an occupied location by reading
its occupancy and then writing on the strength of that read. Two moves
into the same empty location both read it empty and both committed, so
the collision the 409 exists to prevent happened anyway.
Nothing in the schema settles this the way the primary key on
entity_location.entity_id settles the placement race: a location may hold
several entities, and addEntityToLocation places one without consulting
occupancy at all. The target's own row is therefore locked before its
occupancy is read, inside the transaction the move now runs in, so a
second move into the same location waits and then reads the placement the
first one committed.
Removing an entity from a location it is not at also stops being reported
as a server fault: the endpoint now answers 404, as its sibling
DELETE /locations/entity/{entityId} already did for the equivalent case.
Closes #203
Closes #210
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Locking the target location without first locking the entity's placement put the move's two locks in the opposite order to deleteEnvironment, which clears entity_location before deleting the locations. A move and a cascade delete touching the same rows could then wait on each other in a cycle, which the database would break by aborting one of them. The placement is now locked first, before anything is read. That matches the delete's order, and it also keeps the position the grid and adjacency checks are made against from moving underneath them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both lock helpers derived their answer from DbInteractions.queryOne, which logs a SQLException and returns an empty Optional. A lock timeout or a deadlock the database broke was therefore indistinguishable from a row that does not exist, and the move answered 404 for a location that plainly exists. DbInteractions.lock reports the failure instead, in the manner updateReportingDuplicateKey already reports a duplicate key. removeEntityFromLocation's new placement check was a check-then-act with nothing holding the row between the two: two removals of the same placement at once both passed the check, and the second wrote nothing and was answered with the very server fault #210 set out to remove. It now locks the placement first, inside a transaction, as the move does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-reviewThe diff was reviewed against this repository's conventions, and the branch was re-run in full afterwards: The new race test was checked for teeth rather than assumed to have them. Two mutations were tried: removing Fixed in c846d38
Fixed in dfc9c0c, before this review
Filed rather than fixed
Checked and found sound
This comment was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener). |
DELETE /api/v1/locations/entity/{entityId} kept the unguarded
check-then-act that its sibling has just shed: two removals of the same
placement both read it present, and the one that wrote second matched no
rows and was answered 500 for having lost a race. It now takes the same
placement lock inside the same transaction, which also makes the
preceding read redundant, since a statement that locks no row is the
unplaced entity the 404 already reports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the whole branch (second pass)The full diff was re-read against this repository's conventions in a later session, after the previous one was interrupted mid-cycle. The rubric is scored below against the diff and against command output rather than against judgement, and two findings were raised that the first pass did not: one has been fixed on this branch and one has been filed. The external anchor is genuinely green this time. The run on FindingsFixed in 8e1f4c6
The fix was checked for teeth rather than assumed to have them: with the controller change stashed, three of the four Filed rather than fixed
Rubric
Observations outside the diff
This comment was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener). drafted by Claude on behalf of Daniel Stephenson |
Merge readiness — held for the codeownerEverything this PR needs has been done, and it is being left open on one gate rather than on any remaining work. Green. CI passes on The gate. One modified path is on this repository's do-not-auto-merge list, so autonomous merge is withheld:
What the spec change amounts to is worth stating plainly, since the hold is on the path rather than on the size of the edit. Four lines move: two No other protected path is touched — no workflow, no What is being asked. A codeowner's approval of the contract wording and of the This comment was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener). drafted by Claude on behalf of Daniel Stephenson |
Summary
PUT /api/v1/locations/{locationId}/entity/{entityId}/moverefused a move into an occupied location by reading occupancy and then writing on the strength of that read, with no boundary between the two. Two moves into the same empty location both read it empty and both committed, so the collision the endpoint's 409 exists to prevent happened anyway (moveEntityToLocation's collision check is a read-then-write race the database cannot settle #203).entity_location.entity_idsettles the placement race in Report the duplicate-placement loser in addEntityToLocation as 409, not 500 #200: a location is permitted to hold several entities, andaddEntityToLocationplaces one without consulting occupancy at all. The question of whether one-entity-per-location should become a real invariant — option 1 of moveEntityToLocation's collision check is a read-then-write race the database cannot settle #203 — is left untouched by this change, which only makes the rule this one endpoint already applies hold under concurrency; it is carried forward as One entity per location is enforced only by the move endpoint, so a concurrent placement still walks around it #213 so that closing moveEntityToLocation's collision check is a read-then-write race the database cannot settle #203 does not lose it.EnvironmentController.deleteEnvironmentacquires the same two locks in — it clearsentity_locationbefore deleting the locations — and the reverse order would let a move and a cascade delete wait on each other in a cycle. Locking the placement first also keeps the position the grid and adjacency checks are made against from moving underneath them.DELETE /api/v1/locations/{locationId}/entity/{entityId}answered 500 when the entity named was not placed at the location named, because no row was affected and the failure was reported as aServiceException. It now answers 404, matching the siblingDELETE /api/v1/locations/entity/{entityId}, which already handled the equivalent case that way (Removing an entity that is not at the given location answers 500, not 404 #210). Its check runs under the same placement lock and transaction, so two removals of the same placement at once do not reintroduce the 500 through the back door.DELETE /api/v1/locations/entity/{entityId}was found during review to have kept exactly the unguarded check-then-act that Removing an entity that is not at the given location answers 500, not 404 #210 removes from the other one: two removals of the same placement both read it present, and the one that wrote second matched no rows and was answered 500 for having lost a race. It now takes the same placement lock inside the same transaction. The lock also makes its preceding read redundant, since a statement that locks no row is precisely the unplaced entity its 404 already reports, so no response of that endpoint changes and its contract is untouched.DbInteractions.lockwas added for the locking statements. The existingqueryOnecatches aSQLException, logs it, and returns an emptyOptional, which would have made a lock timeout or a broken deadlock indistinguishable from a row that does not exist — and so would have answered 404 for a location that plainly exists. The new method reports the failure asCannotAcquireLockExceptioninstead, in the mannerupdateReportingDuplicateKeyalready reports a duplicate key.docs/MVP.mdare updated for both endpoints whose behaviour changed, and the Python client's message for the delete's 404 no longer names only the location, since that answer now covers the placement too.Left for a follow-up
lock_timeoutis set. That is a deployment-configuration decision that interacts with the cascade delete's statement-per-row loop, so it is filed as Nothing bounds how long a request waits for a row lock, and the pool is not sized against it #212 rather than settled here.Deferred this cycle
The remaining open issues were not picked up alongside this work, and the reasons are recorded here rather than as comments on each:
requirements.txtand the client's import paths, unrelated to the concurrency of these two endpoints and better kept out of a fix that has to stay reviewable.Test plan
mvn test -B— 443 tests, 0 failures (./mvnwis not usable in this environment; the system Maven was used, and CI runs./mvnw test -Bagainst the same suite).pytestandpython3 -m pytest— 108 passed on both interpreters available here.build,python-client (3.8), andpython-client (3.12)all pass.ConcurrentMoveTestwas confirmed to catch the defect rather than merely to pass: it fails both withFOR UPDATEremoved from the locking statement and with the lock moved to after the occupancy read, and passes only with the lock in place.removeEntityFromCurrentLocationcases fail, and all pass with it restored.TransactionBoundaryWiringTestcovers that all three endpoints'@Transactionalis genuinely applied at runtime, since the locks are worthless without it.DbInteractionsTestcovers that a locking statement reports whether it matched a row and reports a failed statement rather than returning false.LocationControllerTestcases cover the lock ordering on both the move and the removals, a target deleted between the two reads, a placement that cannot be read back after being locked, and both shapes of the delete's new 404.Closes #203
Closes #210
This PR description was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener).
drafted by Claude on behalf of Daniel Stephenson