fix: report the duplicate-placement loser as 409, not 500 - #202
Merged
Conversation
Two concurrent placements of the same unplaced entity both pass the read-then-write guard in LocationController.addEntityToLocation, so the outcome is settled by the database rather than by the guard. viron.entity_location was keyed on (entity_id, location_id), which does not express "an entity occupies at most one location": two such requests naming different locations both inserted, leaving the entity in two places at once. The key is now entity_id alone, so the second insert is rejected, and db-scripts/migrations carries the equivalent change for databases that already exist. DbInteractions.updateReportingDuplicateKey surfaces that rejection as a DuplicateKeyException instead of the indistinguishable false that forced a 500; update() still flattens it, so no existing caller changes. The controller answers the loser from the placement the winner committed: a conflict naming that location, or silence when it is the location the loser asked for, which is what the sequential path already returns. Closes #200 Closes #194 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Author
Self-reviewThe diff was read in full. No blocking problem was found; the notes below record what was Findings
Checked and found clean
This comment was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
viron.entity_locationis now keyed onentity_idalone. The previous key,(entity_id, location_id), does not express the invariant the rest of the service assumes —that an entity occupies at most one location — so two concurrent placements of the same
unplaced entity at different locations both passed the read-then-write guard in
LocationController.addEntityToLocationand both inserted, leaving the entity in two placesat once. This is a correction to the premise recorded in Report the duplicate-placement loser in addEntityToLocation as 409, not 500 #200 and Multi-statement writes are not atomic; a shared JDBC connection blocks adding transactions #194, where the old key was
described as holding in every case: it holds only when both requests name the same location.
A regression test that reproduces the race fails against the old key with eight surviving
placements.
DbInteractions.updateReportingDuplicateKeywas added, reporting a unique/primary-keyviolation (SQL state
23505, as used by both Postgres and H2) as aDuplicateKeyExceptioninstead of thefalsethat made a lost race indistinguishable from agenuine write failure and therefore forced a 500.
updatedelegates to it and still flattensthe violation to
false, so the contract every pre-existing caller was written against isunchanged; only the placement insert opts in. This is option 3 of the three weighed in Report the duplicate-placement loser in addEntityToLocation as 409, not 500 #200 —
translation at the boundary that owns the insert — chosen over surfacing every
SQLException(which would change the error contract service-wide) and over
INSERT ... ON CONFLICT DO NOTHING(which would move the invariant into Postgres-only SQL and still infer the conflictfrom a re-read rather than observe it).
location, and — where both requests named the same location — the same silent success the
sequential path already returns, so the
PUTstays idempotent whether the two arrive togetheror in sequence. This last point departs slightly from the acceptance criteria in Report the duplicate-placement loser in addEntityToLocation as 409, not 500 #200, which
ask for a 409 in every lost race; a 409 for a request whose desired outcome was reached would
contradict the endpoint's documented no-op, so the guard's existing answer was preserved
instead and is covered by its own test.
db-scripts/migrations/2026-08-12_entity_location_one_placement_per_entity.sql. It is run byhand (the setup scripts execute only when Postgres initialises an empty volume) and deletes
nothing: it aborts and reports the count if any entity is already placed twice, leaving the
choice of which placement is real to an operator.
migrations directory. The Python client SDK needed no change —
add_entity_to_locationalready maps 409 to a conflict, and no endpoint's URL, request shape or response shape moved.
Test plan
mvn test -B— 424 tests, 0 failures (up from 413;./mvnwis unavailable in thisenvironment, so the system Maven 3.6.3 with Java 21 was used, matching what CI runs)
ConcurrentPlacementTestverified to fail without each half of the fix: with the oldcomposite key, eight concurrent placements all survive; with the key in place but the
violation still flattened to
false, every loser is reported as aServiceException(500). The interleaving is forced with a barrier held immediately after each request's
placement read, since releasing threads together does not reproduce it — the winner
commits before the others read.
ConcurrentPlacementTestrun four times for flakiness; green each timeCloses #200
Closes #194
This PR description was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener).