@makerkit/workers-outbox 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1120 -2
- package/dist/backlog-Bd62tjCT.mjs +686 -0
- package/dist/catalog-DzvOuOHQ.d.mts +6 -0
- package/dist/catalog-G6w4wb_w.mjs +46 -0
- package/dist/cloudflare.d.mts +117 -0
- package/dist/cloudflare.mjs +300 -0
- package/dist/create-outbox-BTMmdvLs.mjs +1758 -0
- package/dist/create-outbox-C4vdn6Dr.d.mts +959 -0
- package/dist/drizzle.d.mts +654 -0
- package/dist/drizzle.mjs +166 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +1 -0
- package/dist/inspect.d.mts +2 -0
- package/dist/inspect.mjs +41 -0
- package/dist/migrations-CX7O2Lcq.mjs +98 -0
- package/dist/pg.d.mts +30 -0
- package/dist/pg.mjs +76 -0
- package/dist/sql.d.mts +7 -0
- package/dist/sql.mjs +1 -0
- package/dist/status-BZhIWoE0.mjs +30 -0
- package/dist/testing.d.mts +49 -0
- package/dist/testing.mjs +97 -0
- package/dist/types-ClomUJ_8.d.mts +33 -0
- package/package.json +94 -4
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
// src/drizzle/catalog.ts
|
|
3
|
+
async function queryRows(db, query) {
|
|
4
|
+
const result = await db.execute(query);
|
|
5
|
+
if (Array.isArray(result)) return result;
|
|
6
|
+
return result.rows;
|
|
7
|
+
}
|
|
8
|
+
async function readOutboxCatalog(db) {
|
|
9
|
+
const columns = await queryRows(db, sql`SELECT c.relname AS "table", a.attname AS "column",
|
|
10
|
+
format_type(a.atttypid, a.atttypmod) AS "type",
|
|
11
|
+
a.attnotnull AS "not_null",
|
|
12
|
+
pg_get_expr(d.adbin, d.adrelid) AS "default"
|
|
13
|
+
FROM pg_attribute a
|
|
14
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
15
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
16
|
+
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
17
|
+
WHERE n.nspname = 'outbox' AND c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped
|
|
18
|
+
ORDER BY 1, 2`);
|
|
19
|
+
const constraints = await queryRows(db, sql`SELECT c.relname AS "table", con.conname AS "name", con.contype AS "type",
|
|
20
|
+
pg_get_constraintdef(con.oid) AS "definition"
|
|
21
|
+
FROM pg_constraint con
|
|
22
|
+
JOIN pg_class c ON c.oid = con.conrelid
|
|
23
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
24
|
+
WHERE n.nspname = 'outbox' AND con.contype IN ('p', 'u', 'f', 'c')
|
|
25
|
+
ORDER BY 1, 2`);
|
|
26
|
+
const indexes = await queryRows(db, sql`SELECT tc.relname AS "table", ic.relname AS "name",
|
|
27
|
+
pg_get_indexdef(ic.oid) AS "definition", i.indisvalid AS "valid"
|
|
28
|
+
FROM pg_index i
|
|
29
|
+
JOIN pg_class ic ON ic.oid = i.indexrelid
|
|
30
|
+
JOIN pg_class tc ON tc.oid = i.indrelid
|
|
31
|
+
JOIN pg_namespace n ON n.oid = tc.relnamespace
|
|
32
|
+
WHERE n.nspname = 'outbox'
|
|
33
|
+
ORDER BY 1, 2`);
|
|
34
|
+
return {
|
|
35
|
+
columns: columns.map((row) => ({
|
|
36
|
+
table: row.table,
|
|
37
|
+
column: row.column,
|
|
38
|
+
type: row.type,
|
|
39
|
+
notNull: row.not_null,
|
|
40
|
+
default: row.default
|
|
41
|
+
})),
|
|
42
|
+
constraints,
|
|
43
|
+
indexes
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export { readOutboxCatalog as n, queryRows as t };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { H as AnyJob, _ as Budget, c as RUNTIME, h as EventSink, i as EnqueueRequest, o as Outbox, u as ScopedEnqueue } from "./create-outbox-C4vdn6Dr.mjs";
|
|
2
|
+
import { r as OutboxDb } from "./types-ClomUJ_8.mjs";
|
|
3
|
+
interface QueueMessage {
|
|
4
|
+
readonly body: unknown;
|
|
5
|
+
readonly attempts: number;
|
|
6
|
+
ack(): void;
|
|
7
|
+
retry(options?: {
|
|
8
|
+
delaySeconds?: number;
|
|
9
|
+
}): void;
|
|
10
|
+
}
|
|
11
|
+
interface QueueBatch {
|
|
12
|
+
readonly queue: string;
|
|
13
|
+
readonly messages: readonly QueueMessage[];
|
|
14
|
+
}
|
|
15
|
+
interface QueueHandlerOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Wall time shared by every message in one batch. Default 840 s (14 min); at most 870 s,
|
|
18
|
+
* leaving headroom under Cloudflare's 15-minute consumer limit.
|
|
19
|
+
*/
|
|
20
|
+
invocationBudgetMs?: number;
|
|
21
|
+
/** Default: 100 jobs within the invocation budget. Its deadline must hold the longest legal lease (635 s). */
|
|
22
|
+
nudgeBudget?: Budget;
|
|
23
|
+
/** Default: 50 jobs within 45 s. */
|
|
24
|
+
drainBudget?: Budget;
|
|
25
|
+
/** Default `'default'`; only this lane's kinds are claimed. */
|
|
26
|
+
lane?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare const messageRetryDelaySeconds: (attempts: number) => number;
|
|
29
|
+
/** A handler failure is a job outcome; only an infrastructure fault retries the message. */
|
|
30
|
+
export declare function createQueueHandler<Env, Ctx = unknown>(outboxFor: (env: Env, ctx: Ctx) => Outbox, options?: QueueHandlerOptions): (batch: QueueBatch, env: Env, ctx: Ctx) => Promise<void>;
|
|
31
|
+
/** One firing of a schedule's cron. */
|
|
32
|
+
interface ScheduleOccurrence {
|
|
33
|
+
/**
|
|
34
|
+
* The time this tick was scheduled for (`event.scheduledTime`), not when it ran: a repeated
|
|
35
|
+
* delivery of the same tick has the same value, so keys derived from it dedupe.
|
|
36
|
+
*/
|
|
37
|
+
readonly scheduledAt: Date;
|
|
38
|
+
readonly cron: string;
|
|
39
|
+
}
|
|
40
|
+
/** Enqueues one job per occurrence. */
|
|
41
|
+
interface JobSchedule<T extends AnyJob = AnyJob> {
|
|
42
|
+
/** Must match one of the Worker's `triggers.crons` exactly, as Cloudflare passes it back. */
|
|
43
|
+
readonly cron: string;
|
|
44
|
+
/** Names the schedule in events and in the default dedupe key. Default: the job's kind. */
|
|
45
|
+
readonly name?: string;
|
|
46
|
+
readonly job: T;
|
|
47
|
+
readonly payload: (occurrence: ScheduleOccurrence) => EnqueueRequest<T>['payload'];
|
|
48
|
+
/** Default `schedule:<name>:<scheduledAt as ISO>`, so a repeated tick finds its job. */
|
|
49
|
+
readonly dedupeKey?: (occurrence: ScheduleOccurrence) => string;
|
|
50
|
+
readonly tenantKey?: string | null;
|
|
51
|
+
}
|
|
52
|
+
/** Enqueues any number of jobs per occurrence, in one transaction (e.g. one per tenant). */
|
|
53
|
+
interface RunSchedule {
|
|
54
|
+
readonly cron: string;
|
|
55
|
+
readonly name: string;
|
|
56
|
+
/**
|
|
57
|
+
* Runs in a transaction on the outbox's connection, so reading app tables needs grants for the
|
|
58
|
+
* outbox role. Derive every dedupe key from `scheduledAt`, so a repeated tick enqueues nothing new.
|
|
59
|
+
*/
|
|
60
|
+
readonly run: (occurrence: ScheduleOccurrence & {
|
|
61
|
+
readonly tx: OutboxDb;
|
|
62
|
+
readonly enqueue: ScopedEnqueue<readonly AnyJob[]>;
|
|
63
|
+
}) => Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
type Schedule = JobSchedule<any> | RunSchedule;
|
|
66
|
+
/** Type-checks a schedule's payload against its job. A plain object works too, untyped. */
|
|
67
|
+
export declare function defineSchedule<T extends AnyJob>(schedule: JobSchedule<T>): JobSchedule<T>;
|
|
68
|
+
export declare function defineSchedule(schedule: RunSchedule): RunSchedule;
|
|
69
|
+
interface ScheduledHandlerOptions {
|
|
70
|
+
/**
|
|
71
|
+
* Wall time shared by every phase of one tick. Default 840 s; at most 870 s, under
|
|
72
|
+
* Cloudflare's 15-minute limit for a scheduled invocation.
|
|
73
|
+
*/
|
|
74
|
+
invocationBudgetMs?: number;
|
|
75
|
+
/** Default: 50 jobs within 180 s. */
|
|
76
|
+
budget?: Budget;
|
|
77
|
+
/** Default 50. */
|
|
78
|
+
recoveryLimit?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Recovery's share of the tick, so a crash backlog can't starve fresh work. Default 120 s.
|
|
81
|
+
* What doesn't fit stays expired for the next tick. Counted from when recovery starts, and held
|
|
82
|
+
* back from `schedules` (up to half the invocation), so schedules can't consume it.
|
|
83
|
+
*/
|
|
84
|
+
recoveryBudgetMs?: number;
|
|
85
|
+
/** Drained before the catch-all so other kinds can't starve these; keep each ≤ 45 s. */
|
|
86
|
+
reserved?: readonly {
|
|
87
|
+
name: string;
|
|
88
|
+
kinds: readonly string[];
|
|
89
|
+
budget: Budget;
|
|
90
|
+
}[];
|
|
91
|
+
backlog?: Parameters<Outbox[typeof RUNTIME]['monitorBacklog']>[0];
|
|
92
|
+
retention?: {
|
|
93
|
+
succeededRetentionMs?: number;
|
|
94
|
+
terminalRetentionMs?: number;
|
|
95
|
+
batch?: number;
|
|
96
|
+
maxBatches?: number;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Recurring jobs. On a tick whose cron matches a schedule's, its jobs are enqueued (keyed by the
|
|
100
|
+
* tick's scheduled time, so a repeated tick enqueues nothing new) before the sweep runs. A tick
|
|
101
|
+
* that never fires is skipped, not caught up.
|
|
102
|
+
*/
|
|
103
|
+
schedules?: readonly Schedule[];
|
|
104
|
+
}
|
|
105
|
+
export declare const DEFAULT_SWEEP_BUDGET: Budget;
|
|
106
|
+
interface ScheduledEventLike {
|
|
107
|
+
readonly cron?: string;
|
|
108
|
+
/** Milliseconds since the epoch; Cloudflare sets it. Default: when the tick runs. */
|
|
109
|
+
readonly scheduledTime?: number;
|
|
110
|
+
}
|
|
111
|
+
/** The durable backstop: each phase is isolated, and the handler always resolves. */
|
|
112
|
+
export declare function createScheduledHandler<Env, Ctx = unknown>(outboxFor: (env: Env, ctx: Ctx) => Outbox, options?: ScheduledHandlerOptions): (event: ScheduledEventLike, env: Env, ctx: Ctx) => Promise<void>;
|
|
113
|
+
/** A dead-lettered nudge loses nothing (cron still drives the job): report and ack. */
|
|
114
|
+
export declare function createDlqHandler(options?: {
|
|
115
|
+
events?: EventSink;
|
|
116
|
+
}): (batch: QueueBatch) => Promise<void>;
|
|
117
|
+
export type { JobSchedule, QueueBatch, QueueHandlerOptions, QueueMessage, RunSchedule, Schedule, ScheduleOccurrence, ScheduledEventLike, ScheduledHandlerOptions };
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { a as emptyReport, c as createEmitter, l as MAX_LEASE_MS, n as RUNTIME, o as serializeError, s as isTransientDbError } from "./create-outbox-BTMmdvLs.mjs";
|
|
2
|
+
import "./backlog-Bd62tjCT.mjs";
|
|
3
|
+
// src/cloudflare/limits.ts
|
|
4
|
+
/** The most an invocation budget may claim of the platform window. */
|
|
5
|
+
const MAX_INVOCATION_BUDGET_MS = 87e4;
|
|
6
|
+
// src/cloudflare/message-codec.ts
|
|
7
|
+
const MAX_BODY_PREVIEW = 500;
|
|
8
|
+
function decodeMessage(body) {
|
|
9
|
+
if (typeof body !== "object" || body === null) return reject("malformed", body);
|
|
10
|
+
const candidate = body;
|
|
11
|
+
if (candidate["v"] !== 1) return reject("unknown_version", body);
|
|
12
|
+
if (candidate["type"] === "nudge") {
|
|
13
|
+
const ids = candidate["ids"];
|
|
14
|
+
if (Array.isArray(ids) && ids.length > 0 && ids.every((id) => typeof id === "string")) return {
|
|
15
|
+
ok: true,
|
|
16
|
+
message: {
|
|
17
|
+
v: 1,
|
|
18
|
+
type: "nudge",
|
|
19
|
+
ids
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
return reject("malformed", body);
|
|
23
|
+
}
|
|
24
|
+
if (candidate["type"] === "drain" && typeof candidate["lane"] === "string") return {
|
|
25
|
+
ok: true,
|
|
26
|
+
message: {
|
|
27
|
+
v: 1,
|
|
28
|
+
type: "drain",
|
|
29
|
+
lane: candidate["lane"]
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
return reject("malformed", body);
|
|
33
|
+
}
|
|
34
|
+
function reject(reason, body) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason,
|
|
38
|
+
body: previewBody(body)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** A message body as bounded text for an event: its JSON form, or its string form if it has none. */
|
|
42
|
+
function previewBody(body) {
|
|
43
|
+
let text;
|
|
44
|
+
try {
|
|
45
|
+
text = JSON.stringify(body) ?? String(body);
|
|
46
|
+
} catch {
|
|
47
|
+
text = String(body);
|
|
48
|
+
}
|
|
49
|
+
return text.slice(0, MAX_BODY_PREVIEW);
|
|
50
|
+
}
|
|
51
|
+
// src/cloudflare/queue-handler.ts
|
|
52
|
+
const DEFAULT_DRAIN_BUDGET = {
|
|
53
|
+
maxJobs: 50,
|
|
54
|
+
deadlineMs: 45e3
|
|
55
|
+
};
|
|
56
|
+
const messageRetryDelaySeconds = (attempts) => Math.min(300, 5 * 2 ** Math.max(0, attempts - 1));
|
|
57
|
+
/** A handler failure is a job outcome; only an infrastructure fault retries the message. */
|
|
58
|
+
function createQueueHandler(outboxFor, options = {}) {
|
|
59
|
+
const invocationBudgetMs = options.invocationBudgetMs ?? 84e4;
|
|
60
|
+
if (!Number.isInteger(invocationBudgetMs) || invocationBudgetMs < 635e3 || invocationBudgetMs > 87e4) throw new Error(`createQueueHandler: invocationBudgetMs must be an integer in [${MAX_LEASE_MS}, ${MAX_INVOCATION_BUDGET_MS}], got ${invocationBudgetMs}`);
|
|
61
|
+
const nudgeBudget = options.nudgeBudget ?? {
|
|
62
|
+
maxJobs: 100,
|
|
63
|
+
deadlineMs: invocationBudgetMs
|
|
64
|
+
};
|
|
65
|
+
const drainBudget = options.drainBudget ?? DEFAULT_DRAIN_BUDGET;
|
|
66
|
+
const lane = options.lane ?? "default";
|
|
67
|
+
if (nudgeBudget.deadlineMs < 635e3 || nudgeBudget.deadlineMs > invocationBudgetMs) throw new Error(`createQueueHandler: nudgeBudget.deadlineMs must be in [${MAX_LEASE_MS}, ${invocationBudgetMs}] (the longest legal lease, the invocation budget), got ${nudgeBudget.deadlineMs}`);
|
|
68
|
+
if (drainBudget.deadlineMs > invocationBudgetMs) throw new Error(`createQueueHandler: drainBudget.deadlineMs must not exceed invocationBudgetMs (${invocationBudgetMs}), got ${drainBudget.deadlineMs}`);
|
|
69
|
+
return async (batch, env, ctx) => {
|
|
70
|
+
const runtime = outboxFor(env, ctx)[RUNTIME];
|
|
71
|
+
const started = runtime.nowMs();
|
|
72
|
+
const deadlineAt = started + invocationBudgetMs;
|
|
73
|
+
const within = (budget) => ({
|
|
74
|
+
...budget,
|
|
75
|
+
deadlineMs: Math.max(0, Math.min(budget.deadlineMs, invocationBudgetMs - (runtime.nowMs() - started)))
|
|
76
|
+
});
|
|
77
|
+
for (const message of batch.messages) {
|
|
78
|
+
const decoded = decodeMessage(message.body);
|
|
79
|
+
if (!decoded.ok) {
|
|
80
|
+
runtime.emit({
|
|
81
|
+
type: "message_rejected",
|
|
82
|
+
reason: decoded.reason,
|
|
83
|
+
body: decoded.body
|
|
84
|
+
});
|
|
85
|
+
message.ack();
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const { message: body } = decoded;
|
|
89
|
+
runtime.emit({
|
|
90
|
+
type: "message_received",
|
|
91
|
+
messageType: body.type,
|
|
92
|
+
ids: body.type === "nudge" ? body.ids : [],
|
|
93
|
+
attempts: message.attempts
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
if (body.type === "nudge") await runtime.processNudge(body.ids, within(nudgeBudget), { lane }, { deadlineAt });
|
|
97
|
+
else {
|
|
98
|
+
const budget = within(drainBudget);
|
|
99
|
+
const report = await runtime.drain(budget, "drain", { lane }, { deadlineAt });
|
|
100
|
+
const progressed = report.claimed > report.errored;
|
|
101
|
+
const starved = budget.deadlineMs < drainBudget.deadlineMs && report.reachedDeadline;
|
|
102
|
+
if ((progressed || starved) && await runtime.hasBacklog({ lane }, { deadlineAt })) await runtime.continueDrain(lane, { deadlineAt });
|
|
103
|
+
}
|
|
104
|
+
message.ack();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const delaySeconds = messageRetryDelaySeconds(message.attempts);
|
|
107
|
+
runtime.emit({
|
|
108
|
+
type: "message_retried",
|
|
109
|
+
messageType: body.type,
|
|
110
|
+
delaySeconds,
|
|
111
|
+
attempts: message.attempts,
|
|
112
|
+
error: serializeError(error)
|
|
113
|
+
});
|
|
114
|
+
message.retry({ delaySeconds });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// src/cloudflare/schedules.ts
|
|
120
|
+
function defineSchedule(schedule) {
|
|
121
|
+
return schedule;
|
|
122
|
+
}
|
|
123
|
+
const nameOf = (schedule) => "run" in schedule ? schedule.name : schedule.name ?? schedule.job.kind;
|
|
124
|
+
/** Rejects what can't work, at handler creation rather than on the first tick. */
|
|
125
|
+
function validateSchedules(schedules) {
|
|
126
|
+
const names = /* @__PURE__ */ new Set();
|
|
127
|
+
for (const schedule of schedules) {
|
|
128
|
+
const name = nameOf(schedule);
|
|
129
|
+
if (typeof schedule.cron !== "string" || schedule.cron.trim() === "") throw new Error(`createScheduledHandler: schedule ${JSON.stringify(name)} needs a cron`);
|
|
130
|
+
if ("run" in schedule === "job" in schedule) throw new Error(`createScheduledHandler: schedule ${JSON.stringify(name)} needs either job + payload, or run`);
|
|
131
|
+
if (!name) throw new Error("createScheduledHandler: a run schedule needs a name");
|
|
132
|
+
if (names.has(name)) throw new Error(`createScheduledHandler: two schedules are named ${JSON.stringify(name)}`);
|
|
133
|
+
names.add(name);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Waits between attempts; a transient failure retries within the tick, as dedupe makes it safe. */
|
|
137
|
+
const RETRY_DELAYS_MS = [1e3, 4e3];
|
|
138
|
+
/** Runs every schedule whose cron is this tick's, each in its own transaction. Never throws. */
|
|
139
|
+
async function runSchedules(outbox, schedules, cron, scheduledAtMs, deadlineAt) {
|
|
140
|
+
const runtime = outbox[RUNTIME];
|
|
141
|
+
const scheduledAt = new Date(scheduledAtMs);
|
|
142
|
+
const due = schedules.filter((schedule) => schedule.cron === cron);
|
|
143
|
+
for (const schedule of due) {
|
|
144
|
+
const name = nameOf(schedule);
|
|
145
|
+
const occurrence = {
|
|
146
|
+
scheduledAt,
|
|
147
|
+
cron: schedule.cron
|
|
148
|
+
};
|
|
149
|
+
const tally = {
|
|
150
|
+
created: 0,
|
|
151
|
+
existing: 0,
|
|
152
|
+
rejected: 0
|
|
153
|
+
};
|
|
154
|
+
for (let attempt = 1;; attempt++) {
|
|
155
|
+
tally.created = tally.existing = tally.rejected = 0;
|
|
156
|
+
try {
|
|
157
|
+
const { pending } = await runtime.withConnection((connection) => runtime.collectTransaction(connection.db, async (tx, enqueue) => {
|
|
158
|
+
const counted = async (job, request) => {
|
|
159
|
+
const result = await enqueue(job, request);
|
|
160
|
+
count(tally, result);
|
|
161
|
+
return result;
|
|
162
|
+
};
|
|
163
|
+
if ("run" in schedule) {
|
|
164
|
+
await schedule.run({
|
|
165
|
+
...occurrence,
|
|
166
|
+
tx,
|
|
167
|
+
enqueue: counted
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const result = await counted(schedule.job, {
|
|
172
|
+
payload: schedule.payload(occurrence),
|
|
173
|
+
dedupeKey: schedule.dedupeKey?.(occurrence) ?? `schedule:${name}:${scheduledAt.toISOString()}`,
|
|
174
|
+
...schedule.tenantKey === void 0 ? {} : { tenantKey: schedule.tenantKey }
|
|
175
|
+
});
|
|
176
|
+
if (result.status !== "created" && result.status !== "exists") throw new Error(`schedule ${name}: enqueue returned ${result.status}`);
|
|
177
|
+
}), deadlineAt);
|
|
178
|
+
runtime.emit({
|
|
179
|
+
type: "schedule_ran",
|
|
180
|
+
name,
|
|
181
|
+
cron: schedule.cron,
|
|
182
|
+
scheduledAt: scheduledAt.toISOString(),
|
|
183
|
+
...tally,
|
|
184
|
+
attempts: attempt
|
|
185
|
+
});
|
|
186
|
+
await runtime.nudgePending(pending, deadlineAt);
|
|
187
|
+
break;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
const delay = RETRY_DELAYS_MS[attempt - 1];
|
|
190
|
+
if (isTransientDbError(error) && delay !== void 0 && runtime.nowMs() + delay < deadlineAt) {
|
|
191
|
+
await runtime.sleep(delay);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
runtime.emit({
|
|
195
|
+
type: "schedule_failed",
|
|
196
|
+
severity: "error",
|
|
197
|
+
name,
|
|
198
|
+
cron: schedule.cron,
|
|
199
|
+
scheduledAt: scheduledAt.toISOString(),
|
|
200
|
+
attempts: attempt,
|
|
201
|
+
error: serializeError(error)
|
|
202
|
+
});
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function count(tally, result) {
|
|
209
|
+
if (result.status === "created") tally.created++;
|
|
210
|
+
else if (result.status === "exists") tally.existing++;
|
|
211
|
+
else tally.rejected++;
|
|
212
|
+
}
|
|
213
|
+
// src/cloudflare/scheduled-handler.ts
|
|
214
|
+
const DEFAULT_SWEEP_BUDGET = {
|
|
215
|
+
maxJobs: 50,
|
|
216
|
+
deadlineMs: 18e4
|
|
217
|
+
};
|
|
218
|
+
/** The durable backstop: each phase is isolated, and the handler always resolves. */
|
|
219
|
+
function createScheduledHandler(outboxFor, options = {}) {
|
|
220
|
+
const budget = options.budget ?? DEFAULT_SWEEP_BUDGET;
|
|
221
|
+
const recoveryLimit = options.recoveryLimit ?? 50;
|
|
222
|
+
const recoveryBudgetMs = options.recoveryBudgetMs ?? 12e4;
|
|
223
|
+
const invocationBudgetMs = options.invocationBudgetMs ?? 84e4;
|
|
224
|
+
const schedules = options.schedules ?? [];
|
|
225
|
+
validateSchedules(schedules);
|
|
226
|
+
if (!Number.isInteger(invocationBudgetMs) || invocationBudgetMs < 1 || invocationBudgetMs > 87e4) throw new Error(`createScheduledHandler: invocationBudgetMs must be an integer in [1, ${MAX_INVOCATION_BUDGET_MS}], got ${invocationBudgetMs}`);
|
|
227
|
+
if (!Number.isInteger(recoveryBudgetMs) || recoveryBudgetMs < 1) throw new Error(`createScheduledHandler: recoveryBudgetMs must be a positive integer, got ${recoveryBudgetMs}`);
|
|
228
|
+
const recoveryReserveMs = Math.min(recoveryBudgetMs, Math.floor(invocationBudgetMs / 2));
|
|
229
|
+
const retention = {
|
|
230
|
+
succeededRetentionMs: options.retention?.succeededRetentionMs ?? 6048e5,
|
|
231
|
+
terminalRetentionMs: options.retention?.terminalRetentionMs ?? 7776e6,
|
|
232
|
+
batch: options.retention?.batch ?? 1e3,
|
|
233
|
+
maxBatches: options.retention?.maxBatches ?? 10
|
|
234
|
+
};
|
|
235
|
+
return async (event, env, ctx) => {
|
|
236
|
+
const outbox = outboxFor(env, ctx);
|
|
237
|
+
const runtime = outbox[RUNTIME];
|
|
238
|
+
const cron = event.cron ?? null;
|
|
239
|
+
const phase = async (name, work) => {
|
|
240
|
+
try {
|
|
241
|
+
return await work();
|
|
242
|
+
} catch (error) {
|
|
243
|
+
runtime.emit({
|
|
244
|
+
type: "phase_failed",
|
|
245
|
+
phase: name,
|
|
246
|
+
cron,
|
|
247
|
+
error: serializeError(error)
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const startedAt = runtime.nowMs();
|
|
253
|
+
const deadlineAt = startedAt + invocationBudgetMs;
|
|
254
|
+
const within = { deadlineAt };
|
|
255
|
+
if (schedules.length > 0) await runSchedules(outbox, schedules, cron, event.scheduledTime ?? startedAt, deadlineAt - recoveryReserveMs);
|
|
256
|
+
const recovery = await phase("recover", () => runtime.recover(recoveryLimit, { deadlineAt: Math.min(deadlineAt, runtime.nowMs() + recoveryBudgetMs) }));
|
|
257
|
+
for (const slice of options.reserved ?? []) await phase("reserved", () => runtime.drain(slice.budget, "sweep", { kinds: slice.kinds }, within));
|
|
258
|
+
const report = await phase("drain", () => runtime.drain(budget, "sweep", {}, within));
|
|
259
|
+
await phase("handoff", () => runtime.handoff(budget.deadlineMs, 100, within));
|
|
260
|
+
const deleted = await phase("cleanup", () => runtime.cleanup(retention, within));
|
|
261
|
+
let continued = false;
|
|
262
|
+
if (report && report.claimed > report.errored) continued = await phase("drain", async () => {
|
|
263
|
+
let any = false;
|
|
264
|
+
for (const lane of runtime.lanes()) if (await runtime.hasBacklog({ lane }, within) && await runtime.continueDrain(lane, within)) any = true;
|
|
265
|
+
return any;
|
|
266
|
+
}) ?? false;
|
|
267
|
+
if (options.backlog) {
|
|
268
|
+
const backlogOptions = options.backlog;
|
|
269
|
+
await phase("backlog", () => runtime.monitorBacklog({
|
|
270
|
+
...backlogOptions,
|
|
271
|
+
...within
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
runtime.emit({
|
|
275
|
+
type: "sweep_finished",
|
|
276
|
+
cron,
|
|
277
|
+
recovered: recovery ? recovery.retrying + recovery.dead : 0,
|
|
278
|
+
report: report ?? emptyReport(),
|
|
279
|
+
deleted: deleted ?? 0,
|
|
280
|
+
continued
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
// src/cloudflare/dlq-handler.ts
|
|
285
|
+
/** A dead-lettered nudge loses nothing (cron still drives the job): report and ack. */
|
|
286
|
+
function createDlqHandler(options = {}) {
|
|
287
|
+
const emit = createEmitter(options.events);
|
|
288
|
+
return async (batch) => {
|
|
289
|
+
for (const message of batch.messages) {
|
|
290
|
+
emit({
|
|
291
|
+
type: "dlq_message",
|
|
292
|
+
queue: batch.queue,
|
|
293
|
+
attempts: message.attempts,
|
|
294
|
+
body: previewBody(message.body)
|
|
295
|
+
});
|
|
296
|
+
message.ack();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
export { DEFAULT_SWEEP_BUDGET, createDlqHandler, createQueueHandler, createScheduledHandler, defineSchedule, messageRetryDelaySeconds };
|