Skip to content

fix: report the duplicate-placement loser as 409, not 500 - #202

Merged
dmccoystephenson merged 1 commit into
mainfrom
fix/duplicate-placement-conflict
Aug 12, 2026
Merged

fix: report the duplicate-placement loser as 409, not 500#202
dmccoystephenson merged 1 commit into
mainfrom
fix/duplicate-placement-conflict

Conversation

@dmccoystephenson

Copy link
Copy Markdown
Member

Summary

  • viron.entity_location is now keyed on entity_id alone. 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.addEntityToLocation and both inserted, leaving the entity in two places
    at 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.updateReportingDuplicateKey was added, reporting a unique/primary-key
    violation (SQL state 23505, as used by both Postgres and H2) as a
    DuplicateKeyException instead of the false that made a lost race indistinguishable from a
    genuine write failure and therefore forced a 500. update delegates to it and still flattens
    the violation to false, so the contract every pre-existing caller was written against is
    unchanged; 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 conflict
    from a re-read rather than observe it).
  • The losing request is answered from the placement the winner committed: a 409 naming that
    location, and — where both requests named the same location — the same silent success the
    sequential path already returns, so the PUT stays idempotent whether the two arrive together
    or 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.
  • A migration for databases created before this change is provided at
    db-scripts/migrations/2026-08-12_entity_location_one_placement_per_entity.sql. It is run by
    hand (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.
  • The OpenAPI spec and the README were updated for the concurrent behaviour and the new
    migrations directory. The Python client SDK needed no change — add_entity_to_location
    already 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; ./mvnw is unavailable in this
    environment, so the system Maven 3.6.3 with Java 21 was used, matching what CI runs)
  • ConcurrentPlacementTest verified to fail without each half of the fix: with the old
    composite key, eight concurrent placements all survive; with the key in place but the
    violation still flattened to false, every loser is reported as a ServiceException
    (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.
  • ConcurrentPlacementTest run four times for flakiness; green each time
  • pytest — not run; no Python SDK file was touched

Closes #200
Closes #194

This PR description was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener).

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>
@dmccoystephenson

Copy link
Copy Markdown
Member Author

Self-review

The diff was read in full. No blocking problem was found; the notes below record what was
weighed, one accepted limitation, and one follow-up that is deliberately not part of this change.
(Posted as a comment rather than a formal review object, since the review API is not available to
this session.)

Findings

  • db-scripts/migrations/2026-08-12_entity_location_one_placement_per_entity.sql:44 — the drop
    names the constraint entity_location_pkey, which is the name Postgres derives for a table
    created by db-scripts/setup/create_tables.sql. A database whose key was created under a
    different name is not migrated by this script; the failure is loud and the whole script is
    wrapped in BEGIN/COMMIT, so nothing is half-applied. Left as is rather than made
    name-agnostic, because the only supported way this table gets created is the setup script.
  • src/main/java/preponderous/viron/database/DbInteractions.java:138 — a duplicate key reaching
    update is now logged as Error executing update: Update violated a unique constraint: ...,
    a slightly doubled prefix. Accepted: the message is still accurate and the alternative is
    duplicating the log call in the delegate.
  • src/main/java/preponderous/viron/database/DbInteractions.java:186 — detection is by SQL state
    rather than by exception type, because the Postgres driver reports 23505 on a PSQLException
    and not on the JDBC SQLIntegrityConstraintViolationException subclass. H2, which the tests
    run against, reports the same state, so the test coverage is meaningful for the production
    driver. The chain is walked with a self-reference guard so a malformed chain cannot hang the
    request thread.
  • src/main/java/preponderous/viron/controllers/LocationController.java:102 — the lost race is
    logged at INFO, not WARN or ERROR: it is an ordinary outcome of two clients competing, and the
    request is answered correctly.
  • src/main/java/preponderous/viron/controllers/LocationController.java:201 — out of scope and
    untouched here, but noted while reviewing: moveEntityToLocation has the same read-then-write
    shape, reading occupancy and then updating. Two entities moving into the same empty location
    concurrently can both pass that check, and unlike the placement invariant this one cannot be
    delegated to the database — a location is allowed to hold several entities (addEntityToLocation
    does not check occupancy at all), so no constraint expresses it. A separate issue is being filed
    rather than widening this change.

Checked and found clean

  • No unparameterized SQL and no unclosed JDBC resource was introduced; the new throwing path
    releases its connection in the same finally as the others, which is asserted by a
    single-connection-pool test.
  • No new exception type was added, so no GlobalExceptionHandler case is missing;
    DuplicateKeyException is caught where it is raised and never reaches the handler.
  • No DTO or endpoint shape changed, so no validation or @Schema annotation is owed, and the
    Python client SDK contract is unaffected.
  • Every new public method (DbInteractions.updateReportingDuplicateKey) is covered by tests, as
    is the changed behaviour of update on a duplicate key.
  • The OpenAPI spec was updated in the same change, and OpenApiSpecDriftTest passes.

This comment was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener).

@dmccoystephenson
dmccoystephenson merged commit 2cebbd1 into main Aug 12, 2026
1 check passed
@dmccoystephenson
dmccoystephenson deleted the fix/duplicate-placement-conflict branch August 12, 2026 02:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant