Add the update place endpoint, with optimistic concurrency #50
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/update-place-endpoint"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes task 69.
PUT /api/places/{id}edits a place's name, description, latitude and longitude, gated byGroupMayEditPlacesvia the existingRequireGroupCapabilityForPlacefilter (ADR-0045) — Editor/Owner may update, a Viewer (a member with insufficient role) gets 403, a non-member gets 404 indistinguishable from a nonexistent id (ADR-0041).group_idcannot be changed here, including by over-posting:UpdatePlaceRequesthas no such property andPlaceRepository.UpdateAsync'sSETclause has no way to write it.Concurrency decision (ADR-0048): unlike task 76's group rename, this endpoint checks optimistic concurrency. A shared group can have several Editors, and a coordinate collision (two people dragging the same pin) leaves no visible diff for the loser to notice, unlike an overwritten group name. The token is Postgres's own
xminsystem column, read back as an opaquelong— no schema change, no migration.PlaceResponsegainsVersion;UpdatePlaceRequestrequires it back. A stale version, or a place deleted out from under the write, is refused with 409 rather than silently overwritten; the ADR argues the trade-offs and alternatives (dedicated version column,If-Matchheader) in full.Took ADR number 0048, not 0047 —
docs/adr/README.md's own "next free" rule: the in-flightfeat/delete-group-endpointbranch already claims 0047 locally, ahead of being pushed as a PR.Tests: Owner/Editor 200 (
updated_atandVersionadvance), Viewer 403, non-member 404, nonexistent id 404, the two 404s byte-identical,group_idunchanged after an over-post attempt, coordinate/name validation 400, unauthenticated 401, stale-version 409 with the row left as the winning edit left it — all against a real Postgres viaWebApplicationFactory+ Testcontainers, plus repository-level concurrency tests.Verdict: mergeable
Checked the headline decision hardest, since that's where the risk actually is.
Race safety is real, not decorative. I added a
Task.WhenAlltest firing twoUpdateAsynccalls at the same row with the same version and ran it against a real container — exactly one succeeds, the other getsnull. The concurrency control lives entirely in the atomicUPDATE … WHERE id = @Id AND xmin::text::bigint = @ExpectedVersion; the secondGetByIdAsyncinPutUpdatePlaceonly runs after that write has already lost, purely to pick 404 vs 409 for the response — it plays no part in deciding who wins. Not a check-then-act race.Worth adding to the suite:
PlaceRepositoryTestsonly proves the stale-version case sequentially (write, then write-again-with-old-token). Nothing in the PR itself exercises genuine DB-level contention, which is the one claim this whole ADR rests on. Suggest lifting something like theTask.WhenAlltest I used intoPlaceRepositoryTestsbefore this ships, so the proof lives in the repo rather than in a reviewer's scratch worktree.xminround-trips correctly —xmin::text::bigintconsistently on read, write and compare; no narrowing, no type mismatch.Freeze/wraparound: traced through it, and the ADR's one-line treatment undersells its own case.
VACUUM FREEZErewrites a row'sxminto the reserved marker2, which no real transaction id ever takes. Because the WHERE clause compares against the row's current value, a stale token can only ever fail to match after a real intervening change (safe 409) — it can't retroactively become2, since a caller only ever reads2from a row that is already frozen, not from one that later gets frozen out from under them. So freezing can produce spurious 409s on a row that's gone untouched for tens of millions of write transactions (real but distant at this app's scale, and self-recovering — reload and retry), never a silent "unchanged" false positive. True 32-bit wraparound collision is additionally guarded by Postgres's own anti-wraparound autovacuum forcing freezes well before that point. Net: no reachable data-loss path. ADR-0048's "the value is guaranteed unique enough to detect a change… never a global ordering or a count of edits" gestures at this without actually walking through freeze, which is exactly the sharp edge a reader would come to this ADR wanting reassurance on. Worth a paragraph making the freeze-can't-cause-false-negatives argument explicit, rather than leaving it implied. Not blocking — the mechanism is sound — but the record should show the reasoning, not just the conclusion.Everything else checked out: Viewer 403 / non-member 404 byte-identical (own test, passing),
group_idimmutable including the over-post route (no property to bind,SETclause has no column for it), coordinate/name validation matches create,updated_atadvances, entities stay off the wire (PlaceResponse/UpdatePlaceRequestonly, explicit mapping in the handler). The list-places rebase only added thexmincolumn to the existingSELECT/ORDER BY— didn't touch the ordering itself, and the full suite (ListPlacesEndpointTestsincluded) passes clean against the pinned SDK.Verdict: mergeable
Re-reviewed the delta only (
b9df42e→d11b644):PlaceRepositoryTests.csand ADR-0048.The race test is genuinely concurrent, not accidentally serialised. The shared
NpgsqlDataSourceused here (AddPlaceMarkDatabase) takes Npgsql's defaultMaximum Pool Sizeof 100 — only the health-probe pool is capped, at 5, and that's a separate keyed registration the repository never touches. 20 writers comfortably fit without queueing for a connection.Confirmed directly rather than assumed: reverted
UpdateAsynclocally to a check-then-act shape (a separateSELECTofxmin, anifon the caller's expected version, an artificial 20ms delay, then anUPDATE … WHERE id = @Idwith no version guard) and reran the new test — it reddened correctly, all 20 writers succeeding. Restored the real implementation and reran; green. Ran the real test 8 more times standalone plus once more in the full suite (9 runs total) with no flake.ADR-0048's expanded
xminsection is accurate and does not overclaim. Walked the mechanics independently before reading the write-up: MVCC leaves a dead tuple'sxminalone and always mints a fresh, live one on write;VACUUM FREEZEis the only thing that rewrites a live tuple'sxminin place, to the reserved constant2, which no ordinary transaction is ever assigned; any subsequent real edit immediately un-freezes the row by writing a new tuple with a genuine transaction id. That combination is what makes a false match (silent data loss) require actual 32-bit wraparound, which PostgreSQL's own anti-wraparound autovacuum and last-resort write refusal exist to prevent — a false conflict (spurious 409) is the only failure shape actually reachable, and it's the safe direction. The write-up reaches the same conclusion by the same route, correctly separates the two failure shapes, and no longer asserts the vaguer "guaranteed unique enough" line the previous revision leaned on. The named revisit condition — autovacuum disabled/broken past its own wraparound safeguard, or transaction volume growing enough that tens of millions of transactions could elapse inside one edit-collision window rather than months or years — is the right one; it's also, correctly, framed as "this would already be an incident on its own terms" rather than a plausible silent failure at this application's scale.Nothing else in the diff changed outside these two files.