From b1955f61937519acf8b9234750e59564bddd0169 Mon Sep 17 00:00:00 2001 From: FanouZeng-TT <18280587072@163.com> Date: Wed, 21 Oct 2026 15:35:14 +0800 Subject: [PATCH] fix(rest/nodejs): commit completion as one SQLite transaction 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. --- rest/nodejs/src/api/checkout.ts | 69 ++++++++++---------- rest/nodejs/src/data/transactions.ts | 17 +++++ rest/nodejs/test/lifecycle.test.ts | 98 ++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 33 deletions(-) diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index da774cd..f16119e 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -25,8 +25,8 @@ import { getOrder, getProduct, logRequest, - releaseStock, reserveStock, + runInTransaction, saveCheckout, saveIdempotencyRecord, saveOrder, @@ -1067,30 +1067,9 @@ export class CheckoutService { // Atomic Inventory Reservation and Completion try { - const reservedItems: Array<{ id: string; qty: number }> = []; - - for (const line of checkout.line_items) { - const product = getProduct(line.item.id); - if (product) { - const success = reserveStock(line.item.id, line.quantity); - if (!success) { - // Rollback - for (const reserved of reservedItems) { - releaseStock(reserved.id, reserved.qty); - } - return ucpErrorResponse( - c, - new OutOfStockError(`Item ${line.item.id} is out of stock`, 409) - ); - } - reservedItems.push({ id: line.item.id, qty: line.quantity }); - } - } - - // Success - checkout.status = CheckoutResponseStatusSchema.enum.completed; - - // Create Order + // Build the order before opening the transaction: constructing it is + // pure computation, while the transaction must stay synchronous so the + // SQLite write lock never spans external I/O. const orderId = `ord_${uuidv4()}`; // Order Fulfillment @@ -1184,17 +1163,41 @@ export class CheckoutService { currency: checkout.currency, }; - saveOrder(order.id, order); + // Reserve stock, persist the order and mark the checkout completed as + // one SQLite transaction: a failure at any step (including an order or + // checkout write) rolls the reservations back instead of leaving stock + // decremented behind a failed 500. + 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 + ) + ); + } - // Notify webhook + // Notify webhook (outside the transaction: external I/O must not hold + // the SQLite write lock). await this.notifyWebhook( checkout, "order_placed", diff --git a/rest/nodejs/src/data/transactions.ts b/rest/nodejs/src/data/transactions.ts index d6e4dff..b805fda 100644 --- a/rest/nodejs/src/data/transactions.ts +++ b/rest/nodejs/src/data/transactions.ts @@ -163,3 +163,20 @@ export function saveIdempotencyRecord( ); stmt.run(key, requestHash, status, responseBody); } + +/** + * Runs `fn` as a single SQLite transaction on the transactions database. + * + * Every write performed by `fn` (inventory, checkouts, orders) commits + * together; if `fn` throws, SQLite rolls all of them back and the error is + * rethrown so callers keep their existing error handling. + * + * @param fn The synchronous unit of work to commit atomically. It must not + * await: an external I/O call inside the transaction would hold the write + * lock across a network round trip. + * @returns Whatever `fn` returns. + */ +export function runInTransaction(fn: () => T): T { + const db = getTransactionsDb(); + return db.transaction(fn)(); +} diff --git a/rest/nodejs/test/lifecycle.test.ts b/rest/nodejs/test/lifecycle.test.ts index 7ac3916..2278c41 100644 --- a/rest/nodejs/test/lifecycle.test.ts +++ b/rest/nodejs/test/lifecycle.test.ts @@ -19,6 +19,7 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { CheckoutService } from "../src/api/checkout"; +import { getInventory, getOrder } from "../src/data"; import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db"; import { CheckoutCompleteRequestSchema, @@ -374,3 +375,100 @@ test("an idempotent create replay still carries ucp.payment_handlers", async () const replayed = (await replay.json()) as { ucp?: unknown }; assertPaymentHandlers("idempotent replay", replayed.ucp); }); + +// Completion writes three tables (inventory, orders, checkouts). A technical +// failure partway through must not leave stock decremented behind a 500: the +// whole unit has to roll back so the client can retry safely. +function countRows(table: "orders"): number { + const row = getTransactionsDb() + .prepare(`SELECT COUNT(*) AS n FROM ${table}`) + .get() as { n: number }; + return row.n; +} + +function failOnWrite(table: "orders" | "checkouts"): void { + // Fail only the completion writes, so the checkout can still be created and + // updated before the fault is armed. + const sql = + table === "orders" + ? `CREATE TRIGGER fail_completion BEFORE INSERT ON orders + BEGIN SELECT RAISE(ABORT, 'injected order failure'); END` + : `CREATE TRIGGER fail_completion BEFORE UPDATE ON checkouts + WHEN NEW.status = 'completed' + BEGIN SELECT RAISE(ABORT, 'injected checkout failure'); END`; + getTransactionsDb().exec(sql); +} + +function clearFault(): void { + getTransactionsDb().exec("DROP TRIGGER IF EXISTS fail_completion"); +} + +test("an order write failure rolls back the inventory reservation", async () => { + const app = buildApp(); + const id = await createReadyToComplete(app); + const stockBefore = getInventory("bouquet_roses"); + const ordersBefore = countRows("orders"); + + failOnWrite("orders"); + try { + const res = await complete(app, id); + assert.equal(res.status, 500); + } finally { + clearFault(); + } + + assert.equal( + getInventory("bouquet_roses"), + stockBefore, + "a failed completion must not consume stock" + ); + assert.equal(countRows("orders"), ordersBefore, "no order may be persisted"); + const checkout = (await ( + await app.request(`/checkout-sessions/${id}`) + ).json()) as { status: string }; + assert.equal( + checkout.status, + "ready_for_complete", + "the checkout must remain completable after the rollback" + ); +}); + +test("a checkout write failure rolls back the order and the reservation", async () => { + const app = buildApp(); + const id = await createReadyToComplete(app); + const stockBefore = getInventory("bouquet_roses"); + const ordersBefore = countRows("orders"); + + failOnWrite("checkouts"); + try { + const res = await complete(app, id); + assert.equal(res.status, 500); + } finally { + clearFault(); + } + + assert.equal(getInventory("bouquet_roses"), stockBefore); + assert.equal( + countRows("orders"), + ordersBefore, + "the order inserted before the failing checkout update must roll back" + ); + const checkout = (await ( + await app.request(`/checkout-sessions/${id}`) + ).json()) as { status: string; order?: unknown }; + assert.equal(checkout.status, "ready_for_complete"); + assert.equal(checkout.order, undefined, "no order reference may survive"); +}); + +test("a successful completion reserves stock exactly once", async () => { + const app = buildApp(); + const id = await createReadyToComplete(app); + const stockBefore = getInventory("bouquet_roses"); + + const res = await complete(app, id); + assert.equal(res.status, 200); + const body = (await res.json()) as { order?: { id: string } }; + assert.ok(body.order?.id); + assert.equal(getInventory("bouquet_roses"), (stockBefore ?? 0) - 1); + assert.ok(getOrder(body.order.id), "the order must be readable"); +});