diff --git a/services/api/src/__tests__/intents.test.ts b/services/api/src/__tests__/intents.test.ts index 9e920a1..568c574 100644 --- a/services/api/src/__tests__/intents.test.ts +++ b/services/api/src/__tests__/intents.test.ts @@ -236,6 +236,39 @@ describe("sweepPendingIntents", () => { expect(second).toEqual({ settled: 1, failed: 0, expired: 0, errors: 0 }); }); + it("rolls the expiry claim back when the book update fails, so a later sweep retries it", async () => { + const stale = intent({ createdAt: new Date(Date.now() - INTENT_EXPIRY_MS - 1000) }); + const { db, updated } = makeDb(new Map([[schema.txIntent, [stale]]])); + const update = db.update as any; + let linesDown = true; + db.update = ((table: unknown) => { + if (table === schema.creditLine && linesDown) throw new Error("DB connection lost during line update"); + return update(table); + }) as any; + // Discard what a throwing transaction wrote, as Postgres would. + (db as any).transaction = async (cb: (trx: any) => Promise) => { + const before = updated.length; + try { + return await cb(db); + } catch (err) { + updated.length = before; + throw err; + } + }; + const chain = chainWith({ status: "NOT_FOUND" }); + + const first = await sweepPendingIntents(db, chain, noContracts); + + expect(first).toEqual({ settled: 0, failed: 0, expired: 0, errors: 1 }); + expect(updated.some((u) => u.table === schema.txIntent && u.set.status === "expired")).toBe(false); + + linesDown = false; + const second = await sweepPendingIntents(db, chain, noContracts); + + expect(second).toEqual({ settled: 0, failed: 0, expired: 1, errors: 0 }); + expect(updated).toContainEqual({ table: schema.creditLine, set: { status: "failed" } }); + }); + it("leaves a recent unsubmitted transaction pending", async () => { const { db, updated } = makeDb(new Map([[schema.txIntent, [intent()]]])); diff --git a/services/api/src/intents.ts b/services/api/src/intents.ts index 9013760..88f3099 100644 --- a/services/api/src/intents.ts +++ b/services/api/src/intents.ts @@ -397,14 +397,28 @@ export async function sweepPendingIntents( if (status === "settled") tally.settled++; else if (status === "failed") tally.failed++; else if (notFound && now - intent.createdAt.getTime() > INTENT_EXPIRY_MS) { - const claimed = await db - .update(schema.txIntent) - .set({ status: "expired", settledAt: new Date(now) }) - .where(and(eq(schema.txIntent.txHash, intent.txHash), eq(schema.txIntent.status, "pending"))) - .returning(); - if (claimed.length > 0) { - await applyFailure(db, intent); - tally.expired++; + // The claim and the book update share a transaction, as in `settle`: a crash between them + // would otherwise leave the intent `expired` with its line or pledge still `pending`, and + // no later sweep would revisit it. + const expire = async (txDb: Database): Promise => { + const claimed = await txDb + .update(schema.txIntent) + .set({ status: "expired", settledAt: new Date(now) }) + .where(and(eq(schema.txIntent.txHash, intent.txHash), eq(schema.txIntent.status, "pending"))) + .returning(); + if (claimed.length === 0) return false; + await applyFailure(txDb, intent); + return true; + }; + try { + const wroteOff = + typeof db.transaction === "function" + ? await db.transaction(async (trx) => expire(trx as unknown as Database)) + : await expire(db); + if (wroteOff) tally.expired++; + } catch { + // Rolled back: the intent is still pending and the next sweep tries again. + tally.errors++; } } }