Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions services/api/src/__tests__/intents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>) => {
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()]]]));

Expand Down
30 changes: 22 additions & 8 deletions services/api/src/intents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> => {
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++;
}
}
}
Expand Down
Loading