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
7 changes: 7 additions & 0 deletions drizzle/0011_idempotency_records.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS "idempotency_records" (
"key" text PRIMARY KEY NOT NULL,
"record" jsonb NOT NULL,
"expires_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_idempotency_records_expires_at" ON "idempotency_records" ("expires_at");
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@
"when": 1780300000000,
"tag": "0010_admin_delisted_at",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780400000000,
"tag": "0011_idempotency_records",
"breakpoints": true
}
]
}
8 changes: 8 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
timestamp,
pgEnum,
index,
jsonb,
} from "drizzle-orm/pg-core";
import { createId } from "@paralleldrive/cuid2";

Expand Down Expand Up @@ -232,3 +233,10 @@ export const ledgerCheckpoints = pgTable("ledger_checkpoints", {
lastLedger: integer("last_ledger").notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

// Idempotency records shared across replicas (#71).
export const idempotencyRecords = pgTable("idempotency_records", {
key: text("key").primaryKey(),
record: jsonb("record").notNull(),
expiresAt: timestamp("expires_at").notNull(),
});
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,22 @@ import { startPoolMetrics, stopPoolMetrics } from "./db/client.js";
import { startRetryPendingWorker, stopRetryPendingWorker } from "./workers/retryPendingWorker.js";
import { startEventListener, stopEventListener } from "./workers/eventListener.js";
import { closeRateLimitStore } from "./lib/rateLimit/index.js";
import { useSharedIdempotencyStore } from "./lib/idempotency.js";
import {
createPostgresRowStore,
createSharedIdempotencyStore,
type PgSql,
} from "./lib/idempotencyPgStore.js";

initSentry();

// Share idempotency records across replicas via Postgres (#71).
useSharedIdempotencyStore(
createSharedIdempotencyStore(createPostgresRowStore(pgClient as unknown as PgSql), {
defaultTtlMs: config.IDEMPOTENCY_TTL_MS,
}),
);

const app = createApp();

// Advisory lock key used to elect a single leader across replicas. Only the
Expand Down
13 changes: 12 additions & 1 deletion src/lib/idempotency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ const defaultStore = createTtlCache<IdempotencyRecord>({
cacheName: "idempotency",
});

// Shared (Postgres-backed) store installed at boot so retries that land on a
// different replica see the original result (#71). When unset (tests, local
// scripts) the in-memory store above is used.
let sharedStore: unknown;

export function useSharedIdempotencyStore(store: unknown): void {
sharedStore = store;
}

// Typed as TtlCache for existing callers; the shared store has the same
// methods but returns promises, so route code must `await` store calls.
export function getIdempotencyStore(): TtlCache<IdempotencyRecord> {
return defaultStore;
return (sharedStore ?? defaultStore) as TtlCache<IdempotencyRecord>;
}
22 changes: 22 additions & 0 deletions src/lib/idempotencyKey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { parseIdempotencyKey } from "./idempotencyKey.js";

describe("parseIdempotencyKey", () => {
it("returns undefined when the header is absent", () => {
expect(parseIdempotencyKey(undefined)).toBeUndefined();
});

it("accepts valid keys", () => {
expect(parseIdempotencyKey("abc_DEF-123")).toBe("abc_DEF-123");
expect(parseIdempotencyKey("a".repeat(255))).toBe("a".repeat(255));
});

it("rejects a 300-character key", () => {
expect(() => parseIdempotencyKey("a".repeat(300))).toThrow(/Idempotency-Key/);
});

it("rejects empty keys and invalid characters", () => {
expect(() => parseIdempotencyKey("")).toThrow();
expect(() => parseIdempotencyKey("bad key!")).toThrow();
});
});
21 changes: 21 additions & 0 deletions src/lib/idempotencyKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { AppError } from "./errors.js";

// Idempotency-Key format (#72): 1 to 255 characters from [A-Za-z0-9_-].
const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_-]{1,255}$/;

export function isValidIdempotencyKey(key: string): boolean {
return IDEMPOTENCY_KEY_PATTERN.test(key);
}

// Returns the validated key, or undefined when the header is absent.
// Throws a 400 VALIDATION_ERROR when the header is present but malformed.
export function parseIdempotencyKey(raw: string | undefined): string | undefined {
if (raw === undefined) return undefined;
if (!isValidIdempotencyKey(raw)) {
throw new AppError(
"VALIDATION_ERROR",
"Invalid Idempotency-Key: must be 1-255 characters from [A-Za-z0-9_-]",
);
}
return raw;
}
52 changes: 52 additions & 0 deletions src/lib/idempotencyPgStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import {
createSharedIdempotencyStore,
type IdempotencyRow,
type IdempotencyRowStore,
} from "./idempotencyPgStore.js";

// A fake "database" shared by several store instances (simulating replicas).
function fakeDatabase(): IdempotencyRowStore {
const table = new Map<string, IdempotencyRow>();
return {
async select(key) {
const row = table.get(key);
return row ? structuredClone(row) : undefined;
},
async upsert(key, record, expiresAt) {
table.set(key, { record: structuredClone(record), expiresAt });
},
async remove(key) {
table.delete(key);
},
async removeAll() {
table.clear();
},
};
}

describe("shared idempotency store", () => {
it("returns the original result on retry from another instance", async () => {
const db = fakeDatabase();
const a = createSharedIdempotencyStore(db, { defaultTtlMs: 60_000 });
const b = createSharedIdempotencyStore(db, { defaultTtlMs: 60_000 });
const result = { status: 201, body: { id: "r1" } };

await a.set("pub:k", { inProgress: true });
expect(await b.get("pub:k")).toEqual({ inProgress: true });
await a.set("pub:k", { inProgress: false, result });
expect(await b.get("pub:k")).toEqual({ inProgress: false, result });
});

it("expires records and supports delete", async () => {
let t = 1_000;
const db = fakeDatabase();
const s = createSharedIdempotencyStore(db, { defaultTtlMs: 100, now: () => t });
await s.set("k", { inProgress: true });
t += 200;
expect(await s.get("k")).toBeUndefined();
await s.set("k2", { inProgress: true });
await s.delete("k2");
expect(await s.get("k2")).toBeUndefined();
});
});
77 changes: 77 additions & 0 deletions src/lib/idempotencyPgStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { IdempotencyRecord } from "./idempotency.js";

// Shared idempotency store (#71). Records live in the `idempotency_records`
// Postgres table (unique key, JSON record, expiry) so a retry that lands on a
// different replica sees the original result instead of creating a duplicate.
// The in-memory TtlCache remains the default (and is what tests use).

export interface IdempotencyRow {
record: IdempotencyRecord;
expiresAt: Date;
}

// Thin persistence seam so the store logic can be tested against a shared fake.
export interface IdempotencyRowStore {
select(key: string): Promise<IdempotencyRow | undefined>;
upsert(key: string, record: IdempotencyRecord, expiresAt: Date): Promise<void>;
remove(key: string): Promise<void>;
removeAll(): Promise<void>;
}

// Minimal shape of the postgres.js tagged-template client.
export type PgSql = (strings: TemplateStringsArray, ...values: any[]) => Promise<any[]>;

export function createPostgresRowStore(sql: PgSql): IdempotencyRowStore {
return {
async select(key) {
const rows = await sql`SELECT record, expires_at FROM idempotency_records WHERE key = ${key}`;
const row = rows[0];
if (!row) return undefined;
const record =
typeof row.record === "string" ? (JSON.parse(row.record) as IdempotencyRecord) : row.record;
return { record, expiresAt: new Date(row.expires_at) };
},
async upsert(key, record, expiresAt) {
await sql`
INSERT INTO idempotency_records (key, record, expires_at)
VALUES (${key}, ${JSON.stringify(record)}::jsonb, ${expiresAt})
ON CONFLICT (key) DO UPDATE SET record = EXCLUDED.record, expires_at = EXCLUDED.expires_at`;
},
async remove(key) {
await sql`DELETE FROM idempotency_records WHERE key = ${key}`;
},
async removeAll() {
await sql`DELETE FROM idempotency_records`;
},
};
}

export interface SharedIdempotencyStore {
get(key: string): Promise<IdempotencyRecord | undefined>;
set(key: string, value: IdempotencyRecord, ttlMs?: number): Promise<void>;
delete(key: string): Promise<void>;
clear(): Promise<void>;
}

export function createSharedIdempotencyStore(
rows: IdempotencyRowStore,
options: { defaultTtlMs: number; now?: () => number },
): SharedIdempotencyStore {
const now = options.now ?? Date.now;
return {
async get(key) {
const row = await rows.select(key);
if (!row) return undefined;
if (now() >= row.expiresAt.getTime()) {
await rows.remove(key);
return undefined;
}
return row.record;
},
async set(key, value, ttlMs) {
await rows.upsert(key, value, new Date(now() + (ttlMs ?? options.defaultTtlMs)));
},
delete: (key) => rows.remove(key),
clear: () => rows.removeAll(),
};
}
14 changes: 14 additions & 0 deletions src/lib/requestAbort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Response } from "express";

// Per-request AbortSignal (#73). The timeout middleware aborts it when the
// request times out so handlers can stop starting new work (storage writes,
// database inserts) for a request the client has already been told failed.
const KEY = "requestAbortSignal";

export function setRequestSignal(res: Response, signal: AbortSignal): void {
(res.locals ??= {})[KEY] = signal;
}

export function getRequestSignal(res: Response): AbortSignal | undefined {
return res.locals?.[KEY] as AbortSignal | undefined;
}
43 changes: 43 additions & 0 deletions src/middleware/timeout.abort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Request, Response } from "express";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { requestTimeout } from "./timeout.js";
import { getRequestSignal } from "../lib/requestAbort.js";
import { createTtlCache } from "../lib/ttlCache.js";
import type { IdempotencyRecord } from "../lib/idempotency.js";

describe("requestTimeout abort signal (#73)", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it("aborts the signal on timeout and the idempotency record still ends final", async () => {
const res = {
headersSent: false,
locals: {},
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
on: vi.fn(),
} as unknown as Response;
const req = { method: "POST", originalUrl: "/resources", url: "/resources" } as Request;

const store = createTtlCache<IdempotencyRecord>({ defaultTtlMs: 60_000 });
store.set("p:k", { inProgress: true });

requestTimeout(100)(req, res, vi.fn());
const signal = getRequestSignal(res)!;
expect(signal.aborted).toBe(false);

vi.advanceTimersByTime(100);
expect(signal.aborted).toBe(true);
expect(res.status).toHaveBeenCalledWith(503);

// Handler finishes after the timeout: records the final outcome and skips
// writing to the already-answered response.
(res as any).headersSent = true;
store.set("p:k", { inProgress: false, result: { status: 201, body: { id: "r1" } } });
expect(store.get("p:k")).toEqual({
inProgress: false,
result: { status: 201, body: { id: "r1" } },
});
});
});
13 changes: 13 additions & 0 deletions src/middleware/timeout.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import type { NextFunction, Request, Response } from "express";
import { getLogger } from "../lib/logger.js";
import { getRequestLogContext } from "../lib/requestLogContext.js";
import { setRequestSignal } from "../lib/requestAbort.js";

// Per-request timeout (issue #113). When a slow upstream (RPC, facilitator,
// storage) keeps a request open past the limit, respond 503 instead of letting
// the connection hang. The timer is cleared once the response finishes (or the
// client disconnects) so completed requests never trip it.
//
// Abort semantics (#73): on timeout the request's AbortSignal (see
// lib/requestAbort.ts) is aborted. Handlers check it before starting new work,
// so a publish that has not yet created anything is cancelled and its
// idempotency key released. Work that is already past that point is not
// interrupted: it completes, records its final outcome under the idempotency
// key (so a retry returns the committed result rather than a stale
// "in progress" 409), and skips writing to the already-answered response.
export function requestTimeout(ms: number) {
return (req: Request, res: Response, next: NextFunction): void => {
const controller = new AbortController();
setRequestSignal(res, controller.signal);

const timer = setTimeout(() => {
controller.abort();
// Headers may already be on the wire (e.g. a streamed download in
// progress) — don't try to rewrite the response in that case.
if (res.headersSent) return;
Expand Down
Loading