Repository navigation
Conversation
Complete Checkout reserved stock, saved the order and marked the checkout completed as three independent writes. A failure after the reservation answered 500 while leaving stock decremented and, for a checkout-write failure, an order the checkout could not reference. Run all three writes inside a single better-sqlite3 transaction so any failure rolls the reservation, order and status back together and the client can retry safely. The webhook stays outside the transaction.
|
Friendly bump — this one is green on all checks and has been open for a few days. Happy to adjust scope, add more context, or fill in any missing test coverage if that would make review easier. |
damaz91
left a comment
There was a problem hiding this comment.
Thanks for putting this together, @FanouZeng-TT! Wrapping completion in a single better-sqlite3 transaction and testing partial-write faults with SQLite BEFORE INSERT / BEFORE UPDATE triggers is a very clean approach.
There is one rollback issue in the out-of-stock path for multi-item checkouts (plus a small suggestion around saveIdempotencyRecord placement) to address before we merge:
- Multi-item out-of-stock early return commits prior reservations (
rest/nodejs/src/api/checkout.ts):better-sqlite3'sdb.transaction(fn)()commits wheneverfnreturns normally and only rolls back whenfnthrows. In a multi-item checkout where line 1 succeeds inreserveStock(...)and line 2 fails, returning{ outOfStockItem: line.item.id }exits normally—committing line 1's stock decrement while answering409 OUT_OF_STOCK(whereas previously thereservedItems/releaseStockloop compensated earlier lines). Please throwOutOfStockErrorinsiderunInTransaction, forwardUcpErrorinstances incompleteCheckout'scatchblock (if (e instanceof UcpError) return ucpErrorResponse(c, e);), and add a test inrest/nodejs/test/lifecycle.test.tscovering a 2-item checkout where the second item is out of stock at completion time. - Include
saveIdempotencyRecordin the transaction (recommended): SincenotifyWebhookdoes not mutatecheckout, movingif (idempotencyKey) { saveIdempotencyRecord(...); }insiderunInTransaction(right aftersaveCheckout, beforeawait this.notifyWebhook(...)) commitsidempotency_keysatomically withinventory,orders, andcheckoutsand avoids an async window where a retry arriving while webhook delivery is in flight seescompletedstatus and gets409 CHECKOUT_NOT_MODIFIABLE.
| const outcome = runInTransaction(() => { | ||
| for (const line of checkout.line_items) { | ||
| const product = getProduct(line.item.id); | ||
| if (product && !reserveStock(line.item.id, line.quantity)) { | ||
| return { outOfStockItem: line.item.id }; | ||
| } | ||
| } | ||
|
|
||
| checkout.status = CheckoutResponseStatusSchema.enum.completed; | ||
| checkout.order = { | ||
| id: orderId, | ||
| permalink_url: order.permalink_url, | ||
| }; | ||
|
|
||
| // Save Checkout | ||
| checkout.order = { | ||
| id: orderId, | ||
| permalink_url: order.permalink_url, | ||
| }; | ||
| saveOrder(order.id, order); | ||
| saveCheckout(id, checkout.status, checkout); | ||
| return {}; | ||
| }); | ||
|
|
||
| saveCheckout(id, checkout.status, checkout); | ||
| if (outcome.outOfStockItem) { | ||
| return ucpErrorResponse( | ||
| c, | ||
| new OutOfStockError( | ||
| `Item ${outcome.outOfStockItem} is out of stock`, | ||
| 409 | ||
| ) | ||
| ); | ||
| } |
There was a problem hiding this comment.
db.transaction(fn)() in better-sqlite3 executes COMMIT whenever fn returns without throwing. If checkout.line_items has multiple items and item 1 reserves stock while item 2 returns false, returning { outOfStockItem: line.item.id } commits item 1's inventory deduction even though completion fails with 409.
Throwing OutOfStockError inside runInTransaction lets better-sqlite3 roll back all prior reservations in the transaction:
runInTransaction(() => {
for (const line of checkout.line_items) {
const product = getProduct(line.item.id);
if (product && !reserveStock(line.item.id, line.quantity)) {
throw new OutOfStockError(
`Item ${line.item.id} is out of stock`,
409
);
}
}
checkout.status = CheckoutResponseStatusSchema.enum.completed;
checkout.order = {
id: orderId,
permalink_url: order.permalink_url,
};
saveOrder(order.id, order);
saveCheckout(id, checkout.status, checkout);
if (idempotencyKey) {
saveIdempotencyRecord(
idempotencyKey,
requestHash,
200,
JSON.stringify(checkout)
);
}
});Then in completeCheckout's catch (e) block below (lines 1217–1220), guard UcpError before the 500 fallback (matching createCheckout and updateCheckout):
} catch (e) {
if (e instanceof UcpError) {
return ucpErrorResponse(c, e);
}
console.error("Error completing checkout", e);
return c.json({ detail: "Internal server error" }, 500);
}Returning a sentinel from the transaction callback commits it: better-sqlite3 commits whenever the callback returns normally. A multi-item checkout whose second line is out of stock therefore kept the first line's stock decrement while answering 409. The out-of-stock path now throws inside the transaction so every reservation rolls back, and the handler forwards UcpError instances instead of collapsing them into a 500. The idempotency record is written inside the same transaction, before the webhook, so a retry that lands while delivery is in flight replays the response rather than seeing 'completed' and answering 409. Regression: a two-line checkout whose second line sells out now returns 409 with the first line's stock intact (99 !== 100 before the fix), and an idempotent replay reserves stock once.
|
Both points are addressed in cdf0bf7 — thanks, the first one was a real defect.
Full suite: 175 tests pass, |
damaz91
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround, @FanouZeng-TT! Throwing OutOfStockError inside runInTransaction, persisting the idempotency record in the same transaction before webhook delivery, and adding the multi-line rollback and replay tests look great. LGTM!
karangoel16
left a comment
There was a problem hiding this comment.
Thanks for fixing this properly. I went through the diff and the new tests, and the atomicity story holds up.
The throw instead of return on out-of-stock is the detail that makes this work. Returning there would have committed the earlier lines' reservations, so throwing to roll everything back is the right move.
Moving the idempotency record inside the transaction, before the webhook, is also the right call. A retry while delivery is in flight now replays instead of hitting completed and getting a 409. And keeping the webhook itself outside the transaction is correct, external I/O should not hold the write lock.
I verified the 409 path still works since OutOfStockError extends UcpError, and there are no leftover uses of releaseStock. Approving.
Category (Required)
ucp-schematool (resolver, linter, validator). (Requires Maintainer approval)Description
Complete Checkout is commented as an "Atomic Inventory Reservation and Completion", but the reservation, the order insert and the checkout update were three independent writes. A technical failure in the middle answered
500without rolling anything back:This change runs the reservation,
saveOrderandsaveCheckoutinside onebetter-sqlite3transaction, so any failure rolls the three tables back together and the client can retry safely. An out-of-stock line short-circuits the transaction instead of the previous hand-rolled release of already-reserved items. Webhook delivery stays outside the transaction because external I/O must not hold the SQLite write lock.Scope
Only the Node reference completion path and its data helper change. The HTTP contract, error envelopes, payment handling, webhook signing/delivery semantics, Python sample and schemas are untouched. The Python idempotency-race fix (#245) is a different failure mode (concurrent requests, one winner) and a different language/runtime; the Node referential-integrity and concurrency candidates are tracked separately and do not overlap these three files.
Tests
Fail-before (with the source changes reverted, fault injected by a SQLite trigger):
an order write failure rolls back the inventory reservation— expected stock unchanged, got99instead of100.a checkout write failure rolls back the order and the reservation— expected stock unchanged, got98instead of99.Pass-after:
npm test --prefix rest/nodejs— 173 passed, 0 failednpm run build --prefix rest/nodejsnpx prettier@4.0.0-alpha.8 --checkon the three changed filesgit diff --check