Repository navigation
Conversation
damaz91
left a comment
There was a problem hiding this comment.
Thank you @vishkaty for the exceptionally thorough write-up, wire reproduction notes, and clean fix!
Claiming the Idempotency-Key with INSERT ... ON CONFLICT(key) DO NOTHING and persisting the checkout inside a single synchronous better-sqlite3 transaction (claimIdempotencyAndSaveCheckout) cleanly closes the UCP-Agent profile-fetch race window in createCheckout and updateCheckout without holding locks across network I/O or risking orphaned idempotency records when storage writes fail. Replaying via replayIdempotent ensures concurrent identical retries receive the exact persisted checkout representation while concurrent differing payloads receive 409 (IDEMPOTENCY_CONFLICT), bringing the Node.js sample in line with #245. The barrier-gated tests in test/idempotency_race.test.ts are deterministic and well-crafted.
A few small, non-blocking suggestions for optional polish or follow-up:
replayIdempotentreuse:completeCheckoutandcancelCheckoutstill inline the duplicaterequest_hashcheck at the top of each handler — they could sharethis.replayIdempotent(...)as well (andreplayIdempotentcould optionally readrecord.response_statusdirectly rather than takingstatusseparately).cancelCheckout/completeCheckoutwrite parity:cancelCheckouthas noawaitbetween its read and write, though callingclaimIdempotencyAndSaveCheckoutthere too would give it the same single-transaction guarantee on storage failure. (Agreed with leavingcompleteCheckout's pre-webhook transaction alignment separate so it composes cleanly with #258.)- Test assertions: In
test/idempotency_race.test.ts, calling.unref()on thebackstoptimer ingateProfileFetchavoids keeping the event loop open if a future change rejects a request beforefetch, and assertingmessages[0].code === "IDEMPOTENCY_CONFLICT"on the409responses would guard against another409error code masking an idempotency conflict.
LGTM!
… race createCheckout and updateCheckout read the idempotency record, await the agent profile fetch, then write, with saveCheckout before saveIdempotencyRecord and the record statement being INSERT OR REPLACE. Requests sharing one Idempotency-Key that arrive while that fetch is in flight all find no record, so each persists its own checkout and each answers 201, and a later retry of the key replays whichever wrote last. Claim the key and store the checkout in one transaction, through the runInTransaction helper, with the key statement changed to INSERT ... ON CONFLICT(key) DO NOTHING. The first request to claim the key stores its checkout and answers as before. A request that finds the key already claimed stores nothing and answers with the recorded response when its request hash matches, or 409 when it does not. The transaction keeps a record from being left behind for a checkout that was never stored. completeCheckout and cancelCheckout share the changed statement. Neither has an await between its idempotency read and its record write, so neither reaches the statement with the key already claimed, and their answers do not change. cancelCheckout now stores the canceled checkout and records the key in one transaction as well, as completeCheckout already does, so a storage failure between the two writes cannot leave a canceled checkout whose key answers every retry 409. Tests: test/idempotency_race.test.ts, three cases that hold the mocked profile fetch open until every racer has reached it, each 409 checked for the IDEMPOTENCY_CONFLICT code, and two that plant a storage failure to show a key is never recorded for a checkout that was not stored and a checkout is never canceled without its key.
341c2a3 to
558f8c5
Compare
|
Thanks for the review. Two of the three suggestions are in the pushed head and one is left for a follow up, so the diff stays the one you approved plus the two behavioural points. Test assertions: both taken. Asserting Write parity: taken for
The branch is rebased from |
Concurrent requests that share one
Idempotency-Keyeach persist their own checkout and eachanswer 201, so the key names one of several stored checkouts rather than a single result, and a
later retry of the key replays whichever request wrote last. Expected: one stored checkout per
key, every identical retry answered with that one result, and a retry carrying different
parameters answered 409.
The text.
checkout-rest.md, Specific Header Requirements, lines 1241 to 1245: when a keyis provided the server MUST store it with the operation result, MUST return the cached
result for duplicate keys, and MUST return
409 Conflictif the key is reused with differentparameters. That is the wording at
a25a4a24, the commit thev2026-04-08tag named before itwas moved; at
9c9d6fce, the commit it names today and the head ofrelease/2026-04-08since2026-10-22, items 2 and 3 say the same of a duplicate key whose request body matches the
original and of a mismatched body, and link to the Idempotency Key Requirements table in
signatures.md(lines 826 to 835 there): on a duplicate with a matching payload return thecached response and do not re-execute, on a duplicate with a mismatched payload reject with
409 Conflict. Neither file uses the word concurrent at either commit. Measured by the statethe server is left in, both items fail: eight requests carrying different parameters under one
key all answered 201 and none answered 409, and eight requests carrying one body under one key
each executed and each stored a checkout, which the
signatures.mdrow forbids in as manywords at both commits (
a25a4a24line 485,9c9d6fceline 833: return the cached response,do not re-execute). Measured request by request, both items need a reading, because neither
text says what the cached result or the first use is when the requests are simultaneous; if
the intent is that only sequential reuse is covered, this is a gap in the reference rather than
a conformance defect, and the right outcome is to say so. The server declares
2026-04-08(
src/utils/config.ts:15).The window, on main
fe191a3f.createCheckoutreads the idempotency record(
src/api/checkout.ts:637), then awaits the agent profile fetch (:721, thefetchat:138inside
parseAgentProfile), then writes:saveCheckoutat:764,saveIdempotencyRecordat:767.updateCheckouthas the same shape: read at:811, await at:852,saveCheckoutat:916,saveIdempotencyRecordat:919. The fetch is the onlyawaitbetween the read and thewrite in either handler, so the window is reachable over the wire whenever the agent sends a
profile over http, which is the required and documented form of the header:
checkout-rest.mdlines 1237 to 1240,
UCP-AgentMUST be present carrying the platform profile URI asprofile="https://platform.example/profile", andoverview.md, Business Requirements, item 1,businesses MUST fetch and validate the platform profile unless already cached. The record
statement is
INSERT OR REPLACE(src/data/transactions.ts:162) onkey TEXT PRIMARY KEY(
src/data/db.ts:77), so every racer claims the key in turn and none is refused, while each hasalready stored a checkout of its own. Neither handler changed between
e7c9c124, the base of thefirst head of this branch, and
fe191a3f: #258 changedcompleteCheckoutand the importblock only.
Observed over a socket against the reference booted from a fresh clone of main
fe191a3f(node v22.14.0, with
src/index.ts:177reading the listen port from the environment and nothingelse changed), eight concurrent creates under one key with identical bodies and a profile served
by a local stub that answers once all eight have asked: eight checkouts persist, eight 201
responses return naming eight different ids, and a GET on each id answers 200, in three trials
of three. With differing bodies: eight 201 responses, eight checkouts, no 409. Two controls on
the same server do not race: with no
UCP-Agentheader, and with adata:profile, whichparseAgentProfiledecodes without a fetch (:131to:136), identical bodies give onecheckout and differing bodies give one 201 and seven 409.
updateCheckoutbehaves the same way:eight handler executions (rows in
request_logs) and eight 200 responses with the http profile,one execution and 1x200 plus 7x409 in both controls. The same measurement on
e7c9c124gave thesame figures.
Why the suite did not catch it. No existing test issues two requests under one key
concurrently; the idempotency tests are sequential, and sequentially this path is correct.
The change. A new
claimIdempotencyAndSaveCheckoutinsrc/data/transactions.tsclaims thekey with
INSERT ... ON CONFLICT(key) DO NOTHINGand stores the checkout inside onetransaction, through the
runInTransactionhelper that #258 added, so either both happen orneither does.
saveIdempotencyRecordnow reports whether it inserted; a caller that did notinsert lost the race and answers with the recorded response, or 409 when the request hashes
differ, through one
replayIdempotenthelper that the two handlers also use for the read at thetop. Both writes are synchronous, so unlike the read at the top of the handler they can share a
transaction.
The transaction matters for a second reason. An earlier version of this change claimed the key
before storing the checkout without a transaction, and that left a record behind for a checkout
that was never stored: a retry answered 201 from the record while a direct read of the checkout
answered 404. One of the tests below covers precisely that; it passes on the unfixed tree, so it
guards the fix rather than the original fault.
completeCheckoutandcancelCheckoutcall the samesaveIdempotencyRecord. Neither has anawait between its idempotency read and its record write, so neither reaches the changed
statement with the key already claimed, and their answers to a race are the same with and
without this change. In
completeCheckoutthe read is at:947and, since #258, the record iswritten inside the completion transaction (
:1171to:1205, the record at:1198) before thewebhook delivery at
:1209, with the status check at:982to:991between the two. IncancelCheckoutthe read is at:1233,saveCheckoutat:1270and the record at:1273,with no transaction around the two writes; this change wraps them in
runInTransactionthe waycompleteCheckoutdoes, so a storage failure between the two cannot leave a canceled checkoutwhose key was never recorded, which on main answers every retry of that key 409
CHECKOUT_NOT_MODIFIABLE(Cannot cancel a canceled checkout session) instead of replaying thecancel. Measured over the wire on the same two servers, eight concurrent identical completes of
one ready checkout under one key, with a dead webhook, give 8x200, one handler execution and one
order, in two trials each on main and on this branch. Two different checkouts completed
concurrently under one key, measured in process with the fetch mocked to hold the delivery open,
give 1x200 and 1x409
IDEMPOTENCY_CONFLICTwith the record naming the first, on main and onthis branch alike. Both figures come from #258, which records the key before delivering the
webhook as the Python reference does (
rest/python/server/services/checkout_service.py:938,then
:949). One7c9c124the same eight completes gave 1x200 and 7x409CHECKOUT_NOT_MODIFIABLEwith eight executions, and two checkouts under one key both completedwith the record naming the last writer.
One adjacent window is deliberately not touched: the not modifiable check in
updateCheckout(
:836to:846) also sits before the same profile fetch, so an update held at that fetch whilea complete lands on the same checkout stores the update over the completed checkout (measured in
process on main
fe191a3fand on this branch: the complete answers 200 with statuscompleted,the released update answers 200, and the stored checkout afterwards has status
ready_for_completeand noorder). That is not an idempotency question, it is the same beforeand after this change, and it is noted so the scope is explicit.
Tests.
test/idempotency_race.test.ts, five cases in the existingnode:testidiom with inmemory databases. Three hold the mocked profile fetch open with a counter gate until every racer
has reached it: identical concurrent creates must name one checkout; differing concurrent creates
must give one 201 and the rest 409; the same for updates. Every 409 in those two cases must carry
messages[0].codeIDEMPOTENCY_CONFLICT, since the server answersCHECKOUT_NOT_MODIFIABLEand
OUT_OF_STOCKwith 409 too. The fourth plants a storage failure with a trigger oncheckoutsand checks that a key is never recorded for a checkout that was not stored; the fifthplants the same failure on
idempotency_keysand checks that a cancel whose key could not berecorded leaves the checkout open, so the retry cancels it and answers 200. Against main
fe191a3fwith the test file added the first three fail, reporting eight checkouts where one isrequired,
8x201where1x201, 7x409is expected, and8x200where1x200, 7x409isexpected; the fourth passes; the fifth fails with the retry answered 409
CHECKOUT_NOT_MODIFIABLE. With the change all five pass. Eleven variants of the change werethen run with the whole suite kept, and each fails at least one of the five:
INSERT OR REPLACErestored, the insert count ignored, and the checkout stored before the key is claimedeach fail the three race cases; the hash check removed fails the two differing cases; the hash
check inverted fails all three race cases; the winner ignored in create fails the two create
cases; the winner ignored in update fails the update case; no transaction around the claim fails
only the fourth case; no transaction around the cancel writes fails only the fifth; a lost race
answered with a 409 of another code, and the conflict code swapped in
replayIdempotent, eachfail the two differing cases. The two hash check variants also fail three and five of the
existing sequential idempotency tests, and the swapped code fails the sequential envelope test.
Before the code assertion was added, the lost race variant passed the whole suite as it stood,
175 from main plus the four race cases, 179 of 179.
Checks run locally on this branch.
npm run buildpasses.npm test: 180 pass, noneskipped, on node 20.18.0, 22.14.0 and 24.22.1, against 175 on main
fe191a3funder each. Thewire figures for this branch above were taken before the review changes were applied, on a
tree whose only difference under
src/from this head is thecancelCheckouttransaction,so the create, update and complete handlers measured are byte identical to the ones here. The
pinned
pre-commit run --all-filespasses all twelve hooks. The wire measurement above againstthe patched server gives one checkout and 8x201 for identical bodies and 1x201 plus 7x409 for
differing bodies with the http profile, one checkout and 1x200 plus 7x409 for the update, and
the controls unchanged. One note on the node 20 leg that concerns the machine and not the
change:
better-sqlite312.11.1 publishes prebuilt bindings for node ABIs 127, 137, 141 and 147and none for ABI 115, so node 20 compiles the binding from source everywhere. The workflow job
did that on ubuntu for
341c2a3d, the first head of this branch, with plainnpm ciandpassed. On macOS with the Command Line Tools alone it needed
npm ci --ignore-scripts, thennode-gyp rebuild --releaseinnode_modules/better-sqlite3using node-gyp 11.0.0 with itsXcode version lookup short circuited (one line in
gyp/pylib/gyp/xcode_emulation.py) andCXXFLAGS="-isystem <SDK>/usr/include/c++/v1", thennpm rebuild esbuild fsevents; thenode-gyp 10.1.0 bundled with that npm stops at the Xcode version check. The change relies on
runInTransaction, which wrapsdb.transaction, and onrun().changesfrombetter-sqlite3,on
ON CONFLICT DO NOTHINGfrom the SQLite the package bundles (3.53.2), and the test onmock.method,t.afterand the globalfetchandResponse.Review. Of the three suggestions on the first head,
341c2a3d, two are taken here and oneis left for a follow up. The 409 code assertion closes a real hole: a variant that answers a
lost race with a 409 of another code passed the whole suite before it, 179 of 179, and fails
the two differing cases with it. The backstop timer in
gateProfileFetchnow hasunref()called on it:measured on this file with a planted failure that leaves the timer uncleared, the file finishes
in 361 ms instead of 2562 ms; in the case the timer exists for, a racer that never reaches the
fetch, the runner now reports the pending test as soon as the loop drains instead of waiting
for the timer, still red.
cancelCheckoutnow pairs its two writes in a transaction, since #258has merged and the reason for leaving the completion side separate is gone; the handler keeps
saveCheckoutthensaveIdempotencyRecordinsiderunInTransactionascompleteCheckoutdoes, rather than the claim helper, because it has no await in which a claim could lose. The
inline
request_hashchecks at the top ofcompleteCheckoutandcancelCheckoutare left asthey are: routing them through
replayIdempotent, and readingresponse_statusfrom therecord, changes no behaviour, and this diff stays the one that was approved plus the two
behavioural points above; a follow up can do that refactor on its own.
Relationship to other work. This is the nodejs counterpart of the python race fixed in #245,
merged 2026-10-19, which takes the same first writer wins contract; the two share no files. This
branch is based on main at
fe191a3f, after #258 merged on 2026-10-29 as93a6e71e, and addsreplayIdempotentbeside the two helpers #253 added. #258 put arunInTransactionhelper intransactions.ts, andclaimIdempotencyAndSaveCheckoutnow calls it. The helper isgetTransactionsDb().transaction(fn)(), the statement the first head341c2a3dwrote inplace, so the swap changes no behaviour. #258 also moved the completion writes and the completion
idempotency record into one transaction, which is the alignment with the Python reference noted
above. #264, now also rebased onto
fe191a3f, changesparseAgentProfilein the samecheckout.ts(it drops a profilewebhook_urlthat is not an http or https URL withoutcredentials) and adds
test/agent_profile.test.ts; it touches neither handler changed here nortransactions.ts. A three way merge in either order gives the same tree, and the combined suitepasses, 191 of 191.