nuxt-cf-jobs 0.5.2 → 0.6.0
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/dist/cli/index.mjs +47 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +1 -0
- package/dist/runtime/server/batch.d.ts +1 -0
- package/dist/runtime/server/batch.js +1 -0
- package/dist/runtime/server/cloudflare.d.ts +46 -0
- package/dist/runtime/server/cloudflare.js +31 -0
- package/dist/runtime/server/d1.d.ts +2 -2
- package/dist/runtime/server/d1.js +62 -3
- package/dist/runtime/server/index.d.ts +2 -0
- package/dist/runtime/server/index.js +2 -0
- package/dist/runtime/server/metrics.d.ts +53 -0
- package/dist/runtime/server/metrics.js +59 -0
- package/dist/runtime/server/outbox.d.ts +62 -1
- package/dist/runtime/server/outbox.js +33 -4
- package/dist/runtime/server/runtime.d.ts +156 -0
- package/dist/runtime/server/runtime.js +201 -0
- package/dist/runtime/server/schema.js +1 -0
- package/dist/runtime/server/types.d.ts +17 -0
- package/package.json +9 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -201,6 +201,22 @@ function forgetSql(id, t = defaultTableNames) {
|
|
|
201
201
|
function flushSql(queue, t = defaultTableNames) {
|
|
202
202
|
return `DELETE FROM ${t.failed} ${queue ? `WHERE queue = ${sqlString(queue)}` : ""}`.trimEnd();
|
|
203
203
|
}
|
|
204
|
+
function pruneCompletedJobsSql(hours, t = defaultTableNames) {
|
|
205
|
+
return `DELETE FROM ${t.jobs} WHERE completed_at IS NOT NULL AND completed_at <= unixepoch() - ${sqlInt(hours * 3600)}`;
|
|
206
|
+
}
|
|
207
|
+
function pruneFailedJobsSql(hours, t = defaultTableNames) {
|
|
208
|
+
return `DELETE FROM ${t.failed} WHERE failed_at <= unixepoch() - ${sqlInt(hours * 3600)}`;
|
|
209
|
+
}
|
|
210
|
+
function pruneFinishedBatchesSql(hours, t = defaultTableNames) {
|
|
211
|
+
return `DELETE FROM ${t.batches} WHERE finished_at IS NOT NULL AND finished_at <= unixepoch() - ${sqlInt(hours * 3600)}`;
|
|
212
|
+
}
|
|
213
|
+
function pruneSql(opts, t = defaultTableNames) {
|
|
214
|
+
return [
|
|
215
|
+
pruneCompletedJobsSql(opts.completedHours, t),
|
|
216
|
+
pruneFailedJobsSql(opts.failedHours, t),
|
|
217
|
+
pruneFinishedBatchesSql(opts.batchesHours, t)
|
|
218
|
+
].join(";\n");
|
|
219
|
+
}
|
|
204
220
|
function clearSql(filters = {}, t = defaultTableNames) {
|
|
205
221
|
return `DELETE FROM ${t.jobs}
|
|
206
222
|
${andClauses(
|
|
@@ -586,6 +602,36 @@ const clear = defineCommand({
|
|
|
586
602
|
`);
|
|
587
603
|
}
|
|
588
604
|
});
|
|
605
|
+
const prune = defineCommand({
|
|
606
|
+
meta: { name: "prune", description: "Delete terminal rows past retention (artisan queue:prune-batches + queue:prune-failed)" },
|
|
607
|
+
args: {
|
|
608
|
+
...sharedArgs,
|
|
609
|
+
"completed-hours": { type: "string", description: "Completed-jobs retention in hours", default: "24" },
|
|
610
|
+
"failed-hours": { type: "string", description: "Failed-jobs retention in hours", default: "168" },
|
|
611
|
+
"batches-hours": { type: "string", description: "Finished-batches retention in hours", default: "72" },
|
|
612
|
+
"yes": { type: "boolean", alias: "y", description: "Skip confirmation", default: false }
|
|
613
|
+
},
|
|
614
|
+
async run({ args }) {
|
|
615
|
+
const { target, tables } = context(args);
|
|
616
|
+
const hours = {
|
|
617
|
+
completedHours: Number(args["completed-hours"]),
|
|
618
|
+
failedHours: Number(args["failed-hours"]),
|
|
619
|
+
batchesHours: Number(args["batches-hours"])
|
|
620
|
+
};
|
|
621
|
+
if (Object.values(hours).some((h) => !Number.isFinite(h) || h < 0)) {
|
|
622
|
+
process$1.stderr.write(`${color.red("\u2717")} --*-hours must be non-negative numbers.
|
|
623
|
+
`);
|
|
624
|
+
process$1.exitCode = 1;
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const scope = `completed >${hours.completedHours}h, failed >${hours.failedHours}h, batches >${hours.batchesHours}h`;
|
|
628
|
+
if (!await confirm(`Prune ${scope} (${target.remote ? "remote" : "local"})?`, args.yes))
|
|
629
|
+
return;
|
|
630
|
+
await execD1(target, `${pruneSql(hours, tables)};`);
|
|
631
|
+
process$1.stdout.write(`${color.green("\u2713")} Pruned ${scope}.
|
|
632
|
+
`);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
589
635
|
const migrate = defineCommand({
|
|
590
636
|
meta: { name: "migrate", description: "Create the job tables/indexes in D1" },
|
|
591
637
|
args: {
|
|
@@ -658,7 +704,7 @@ const main = defineCommand({
|
|
|
658
704
|
description: "Inspect and manage nuxt-cf-jobs durable jobs in Cloudflare D1"
|
|
659
705
|
},
|
|
660
706
|
args: sharedArgs,
|
|
661
|
-
subCommands: { status, jobs, failed, retry, forget, flush, clear, migrate, schedule, tasks },
|
|
707
|
+
subCommands: { status, jobs, failed, retry, forget, flush, clear, prune, migrate, schedule, tasks },
|
|
662
708
|
async run({ args, rawArgs }) {
|
|
663
709
|
if (rawArgs.length === 0 || rawArgs.every((a) => a.startsWith("-")))
|
|
664
710
|
await runStatus(args);
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -131,6 +131,7 @@ const module$1 = defineNuxtModule({
|
|
|
131
131
|
async setup(options, nuxt) {
|
|
132
132
|
const resolver = createResolver(import.meta.url);
|
|
133
133
|
nuxt.options.alias["#cf-jobs/server"] = resolver.resolve("./runtime/server");
|
|
134
|
+
nuxt.options.alias["#cf-jobs/cloudflare"] = resolver.resolve("./runtime/server/cloudflare");
|
|
134
135
|
addServerImports([
|
|
135
136
|
{ name: "defineJob", from: resolver.resolve("./runtime/server/registry") },
|
|
136
137
|
{ name: "defineScheduledTask", from: resolver.resolve("./runtime/server/scheduled") }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { JobMetricsEvent, JobMetricsSink } from './metrics.js';
|
|
2
|
+
/**
|
|
3
|
+
* Minimal structural shape of a Cloudflare Analytics Engine binding
|
|
4
|
+
* (`AnalyticsEngineDataset`). Declared locally so this module needs no
|
|
5
|
+
* `@cloudflare/workers-types` dependency; the real binding is assignable.
|
|
6
|
+
*/
|
|
7
|
+
export interface AnalyticsEngineDataset {
|
|
8
|
+
writeDataPoint: (point: {
|
|
9
|
+
indexes?: string[];
|
|
10
|
+
blobs?: Array<string | null>;
|
|
11
|
+
doubles?: number[];
|
|
12
|
+
}) => void;
|
|
13
|
+
}
|
|
14
|
+
export type AnalyticsEngineDataPoint = Parameters<AnalyticsEngineDataset['writeDataPoint']>[0];
|
|
15
|
+
export interface AnalyticsEngineSinkOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Override the event → data-point mapping. Mind the Analytics Engine limits:
|
|
18
|
+
* at most ONE index (≤96 bytes), ≤20 doubles, and blobs totalling ≤5120 bytes.
|
|
19
|
+
*/
|
|
20
|
+
toDataPoint?: (event: JobMetricsEvent) => AnalyticsEngineDataPoint;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Default mapping. `queue` is the single index (the natural rollup key); status
|
|
24
|
+
* and jobType ride as blobs for filtering; duration + attempts as doubles for
|
|
25
|
+
* latency/retry aggregates.
|
|
26
|
+
*/
|
|
27
|
+
export declare function defaultJobDataPoint(event: JobMetricsEvent): AnalyticsEngineDataPoint;
|
|
28
|
+
/** Build a sink that writes each event to the given Analytics Engine dataset. */
|
|
29
|
+
export declare function createAnalyticsEngineSink(dataset: AnalyticsEngineDataset, opts?: AnalyticsEngineSinkOptions): JobMetricsSink;
|
|
30
|
+
export interface ResolveAnalyticsEngineSinkOptions extends AnalyticsEngineSinkOptions {
|
|
31
|
+
/** Env binding name of the Analytics Engine dataset (e.g. `'JOB_ANALYTICS'`). */
|
|
32
|
+
binding: string;
|
|
33
|
+
/**
|
|
34
|
+
* Sink used when the binding is absent (local dev / not yet provisioned) — so
|
|
35
|
+
* wiring this in is a safe no-op until the dataset exists. Defaults to
|
|
36
|
+
* {@link noopMetricsSink}; pass `createConsoleMetricsSink()` for dev logs.
|
|
37
|
+
*/
|
|
38
|
+
fallback?: JobMetricsSink;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve an Analytics Engine sink from a Workers `env`, degrading to a fallback
|
|
42
|
+
* (no-op by default) when the binding is missing or malformed. This is the
|
|
43
|
+
* "provide the binding name, it just works" entry point — drop it straight into
|
|
44
|
+
* a repository's hooks via `metricsSinkToRepoHooks`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveAnalyticsEngineSink(env: Record<string, unknown> | undefined, opts: ResolveAnalyticsEngineSinkOptions): JobMetricsSink;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { noopMetricsSink } from "./metrics.js";
|
|
2
|
+
export function defaultJobDataPoint(event) {
|
|
3
|
+
return {
|
|
4
|
+
indexes: [event.queue],
|
|
5
|
+
blobs: [event.queue, event.jobType, event.status, event.error ?? null],
|
|
6
|
+
// doubles are positional — keep this order stable (the AE SQL API references
|
|
7
|
+
// double1..double6). duration, attempts, then the reported execution stats.
|
|
8
|
+
doubles: [
|
|
9
|
+
event.durationMs ?? 0,
|
|
10
|
+
event.attempts,
|
|
11
|
+
event.rowsFetched ?? 0,
|
|
12
|
+
event.rowsInserted ?? 0,
|
|
13
|
+
event.d1RowsRead ?? 0,
|
|
14
|
+
event.d1RowsWritten ?? 0
|
|
15
|
+
]
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function createAnalyticsEngineSink(dataset, opts = {}) {
|
|
19
|
+
const toDataPoint = opts.toDataPoint ?? defaultJobDataPoint;
|
|
20
|
+
return {
|
|
21
|
+
record(event) {
|
|
22
|
+
dataset.writeDataPoint(toDataPoint(event));
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function resolveAnalyticsEngineSink(env, opts) {
|
|
27
|
+
const dataset = env?.[opts.binding];
|
|
28
|
+
if (dataset && typeof dataset.writeDataPoint === "function")
|
|
29
|
+
return createAnalyticsEngineSink(dataset, opts);
|
|
30
|
+
return opts.fallback ?? noopMetricsSink;
|
|
31
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DurableJobFailureRepository, DurableJobLifecycle, DurableJobRecord, DurableJobRecoveryRepository, DurableJobRepository, ReleaseDurableJobOptions } from './outbox.js';
|
|
1
|
+
import type { DurableJobFailureRepository, DurableJobLifecycle, DurableJobPruneRepository, DurableJobRecord, DurableJobRecoveryRepository, DurableJobRepository, ReleaseDurableJobOptions } from './outbox.js';
|
|
2
2
|
export interface D1PreparedStatementLike<T = unknown> {
|
|
3
3
|
bind: (...values: unknown[]) => D1PreparedStatementLike<T>;
|
|
4
4
|
run: () => Promise<{
|
|
@@ -108,7 +108,7 @@ export interface D1InsertJobsResult<Queue extends string = string> {
|
|
|
108
108
|
inserted: Array<DurableJobRecord<Queue>>;
|
|
109
109
|
chunks: D1InsertJobsChunkResult[];
|
|
110
110
|
}
|
|
111
|
-
export type D1DurableJobRepository<Queue extends string = string> = DurableJobRepository<Queue, DurableJobRecord<Queue>> & DurableJobRecoveryRepository<Queue, D1DurableJobRecord<Queue>> & DurableJobLifecycle<D1DurableJobRecord<Queue>> & DurableJobFailureRepository & {
|
|
111
|
+
export type D1DurableJobRepository<Queue extends string = string> = DurableJobRepository<Queue, DurableJobRecord<Queue>> & DurableJobRecoveryRepository<Queue, D1DurableJobRecord<Queue>> & DurableJobLifecycle<D1DurableJobRecord<Queue>> & DurableJobFailureRepository & DurableJobPruneRepository & {
|
|
112
112
|
migrate: () => Promise<void>;
|
|
113
113
|
insertJobs: (records: readonly DurableJobRecord<Queue>[], opts?: {
|
|
114
114
|
batchSize?: number;
|
|
@@ -14,6 +14,8 @@ export const d1DurableJobMigrationSql = [
|
|
|
14
14
|
"CREATE INDEX IF NOT EXISTS idx_jobs_batch ON jobs (batch_id)",
|
|
15
15
|
"CREATE INDEX IF NOT EXISTS idx_jobs_trace ON jobs (trace_id)",
|
|
16
16
|
"CREATE INDEX IF NOT EXISTS idx_jobs_sync_dedup ON jobs (site_id, job_type)",
|
|
17
|
+
// Partial index backing pruneCompletedJobs (completed_at IS NOT NULL AND <= ?).
|
|
18
|
+
"CREATE INDEX IF NOT EXISTS idx_jobs_completed_at ON jobs (completed_at) WHERE completed_at IS NOT NULL",
|
|
17
19
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_unique_active ON jobs (unique_key) WHERE unique_key IS NOT NULL AND completed_at IS NULL AND failed_at IS NULL",
|
|
18
20
|
"CREATE INDEX IF NOT EXISTS idx_failed_jobs_queue ON failed_jobs (queue)",
|
|
19
21
|
"CREATE INDEX IF NOT EXISTS idx_failed_jobs_site ON failed_jobs (site_id)",
|
|
@@ -23,6 +25,7 @@ export const d1DurableJobMigrationSql = [
|
|
|
23
25
|
export function createD1DurableJobRepository(db, opts = {}) {
|
|
24
26
|
const jobsTable = opts.jobsTable ?? "jobs";
|
|
25
27
|
const failedJobsTable = opts.failedJobsTable ?? "failed_jobs";
|
|
28
|
+
const batchesTable = opts.batchesTable ?? "job_batches";
|
|
26
29
|
const insertJobSql = `
|
|
27
30
|
INSERT OR IGNORE INTO ${jobsTable} (
|
|
28
31
|
id, queue, job_type, batch_id, user_id, site_id, partner_id, trace_id, unique_key, payload,
|
|
@@ -116,12 +119,26 @@ export function createD1DurableJobRepository(db, opts = {}) {
|
|
|
116
119
|
return "in-flight";
|
|
117
120
|
},
|
|
118
121
|
async completeJob(job, result) {
|
|
119
|
-
const
|
|
122
|
+
const stats = readResultStat(result);
|
|
123
|
+
const reportedDuration = stats("durationMs");
|
|
124
|
+
const durationMs = reportedDuration ?? (job.reserved_at != null ? (currentUnixSeconds() - job.reserved_at) * 1e3 : null);
|
|
120
125
|
await db.prepare(`
|
|
121
126
|
UPDATE ${jobsTable}
|
|
122
|
-
SET completed_at = unixepoch(), reserved_at = NULL,
|
|
127
|
+
SET completed_at = unixepoch(), reserved_at = NULL,
|
|
128
|
+
duration_ms = COALESCE(?, duration_ms),
|
|
129
|
+
rows_fetched = COALESCE(?, rows_fetched),
|
|
130
|
+
rows_inserted = COALESCE(?, rows_inserted),
|
|
131
|
+
d1_rows_read = COALESCE(?, d1_rows_read),
|
|
132
|
+
d1_rows_written = COALESCE(?, d1_rows_written)
|
|
123
133
|
WHERE id = ?
|
|
124
|
-
`).bind(
|
|
134
|
+
`).bind(
|
|
135
|
+
durationMs,
|
|
136
|
+
stats("rowsFetched") ?? null,
|
|
137
|
+
stats("rowsInserted") ?? null,
|
|
138
|
+
stats("d1RowsRead") ?? null,
|
|
139
|
+
stats("d1RowsWritten") ?? null,
|
|
140
|
+
job.id
|
|
141
|
+
).run();
|
|
125
142
|
fireHook(() => opts.onJobCompleted?.({ job, durationMs, result }));
|
|
126
143
|
},
|
|
127
144
|
async failJob(job, error) {
|
|
@@ -235,12 +252,54 @@ export function createD1DurableJobRepository(db, opts = {}) {
|
|
|
235
252
|
siteId: job.site_id,
|
|
236
253
|
userId: job.user_id
|
|
237
254
|
};
|
|
255
|
+
},
|
|
256
|
+
async pruneCompletedJobs(query) {
|
|
257
|
+
return await pruneInChunks(db, jobsTable, "completed_at IS NOT NULL AND completed_at <= ?", query);
|
|
258
|
+
},
|
|
259
|
+
async pruneFailedJobs(query) {
|
|
260
|
+
return await pruneInChunks(db, failedJobsTable, "failed_at <= ?", query);
|
|
261
|
+
},
|
|
262
|
+
async pruneFinishedBatches(query) {
|
|
263
|
+
return await pruneInChunks(
|
|
264
|
+
db,
|
|
265
|
+
batchesTable,
|
|
266
|
+
`finished_at IS NOT NULL AND finished_at <= ? AND NOT EXISTS (SELECT 1 FROM ${jobsTable} WHERE ${jobsTable}.batch_id = ${batchesTable}.id)`,
|
|
267
|
+
query
|
|
268
|
+
);
|
|
238
269
|
}
|
|
239
270
|
};
|
|
240
271
|
}
|
|
272
|
+
const DEFAULT_PRUNE_CHUNK = 1e3;
|
|
273
|
+
async function pruneInChunks(db, table, whereClause, query) {
|
|
274
|
+
const chunk = Math.max(1, query.limit ?? DEFAULT_PRUNE_CHUNK);
|
|
275
|
+
const sql = `
|
|
276
|
+
DELETE FROM ${table}
|
|
277
|
+
WHERE id IN (
|
|
278
|
+
SELECT id FROM ${table}
|
|
279
|
+
WHERE ${whereClause}
|
|
280
|
+
LIMIT ?
|
|
281
|
+
)
|
|
282
|
+
`;
|
|
283
|
+
let total = 0;
|
|
284
|
+
for (; ; ) {
|
|
285
|
+
const result = await db.prepare(sql).bind(query.before, chunk).run();
|
|
286
|
+
const changes = result.meta?.changes ?? 0;
|
|
287
|
+
total += changes;
|
|
288
|
+
if (changes < chunk)
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
return total;
|
|
292
|
+
}
|
|
241
293
|
function currentUnixSeconds() {
|
|
242
294
|
return Math.floor(Date.now() / 1e3);
|
|
243
295
|
}
|
|
296
|
+
function readResultStat(result) {
|
|
297
|
+
const obj = result && typeof result === "object" ? result : void 0;
|
|
298
|
+
return (key) => {
|
|
299
|
+
const v = obj?.[key];
|
|
300
|
+
return typeof v === "number" ? v : void 0;
|
|
301
|
+
};
|
|
302
|
+
}
|
|
244
303
|
function fireHook(fn) {
|
|
245
304
|
try {
|
|
246
305
|
const result = fn();
|
|
@@ -3,12 +3,14 @@ export * from './batch.js';
|
|
|
3
3
|
export * from './d1.js';
|
|
4
4
|
export * from './dispatch.js';
|
|
5
5
|
export * from './errors.js';
|
|
6
|
+
export * from './metrics.js';
|
|
6
7
|
export * from './outbox.js';
|
|
7
8
|
export * from './policy.js';
|
|
8
9
|
export { CF_QUEUE_MAX_BATCH_BYTES, CF_QUEUE_MAX_BATCH_SIZE, CF_QUEUE_MAX_DELAY_SECONDS, CF_QUEUE_MAX_MESSAGE_BYTES, createJobQueue, defineCfJobsQueues, exponentialBackoff, resolveNitroTaskEnv, resolveQueueBindingName, } from './queue.js';
|
|
9
10
|
export type { JobQueuePublisher, QueueSource } from './queue.js';
|
|
10
11
|
export * from './registry.js';
|
|
11
12
|
export * from './result.js';
|
|
13
|
+
export * from './runtime.js';
|
|
12
14
|
export * from './scheduled.js';
|
|
13
15
|
export * from './schema.js';
|
|
14
16
|
export * from './types.js';
|
|
@@ -3,6 +3,7 @@ export * from "./batch.js";
|
|
|
3
3
|
export * from "./d1.js";
|
|
4
4
|
export * from "./dispatch.js";
|
|
5
5
|
export * from "./errors.js";
|
|
6
|
+
export * from "./metrics.js";
|
|
6
7
|
export * from "./outbox.js";
|
|
7
8
|
export * from "./policy.js";
|
|
8
9
|
export {
|
|
@@ -18,6 +19,7 @@ export {
|
|
|
18
19
|
} from "./queue.js";
|
|
19
20
|
export * from "./registry.js";
|
|
20
21
|
export * from "./result.js";
|
|
22
|
+
export * from "./runtime.js";
|
|
21
23
|
export * from "./scheduled.js";
|
|
22
24
|
export * from "./schema.js";
|
|
23
25
|
export * from "./types.js";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { D1DurableJobRepositoryOptions } from './d1.js';
|
|
2
|
+
export type JobMetricStatus = 'completed' | 'failed' | 'released';
|
|
3
|
+
/**
|
|
4
|
+
* One terminal-ish job lifecycle event, engine-neutral. `released` is a
|
|
5
|
+
* deliberate retry (`ctx.release()` / a handler throw) rather than a terminal
|
|
6
|
+
* state, but it is worth recording for retry-rate dashboards.
|
|
7
|
+
*/
|
|
8
|
+
export interface JobMetricsEvent {
|
|
9
|
+
jobId: string;
|
|
10
|
+
queue: string;
|
|
11
|
+
jobType: string;
|
|
12
|
+
status: JobMetricStatus;
|
|
13
|
+
attempts: number;
|
|
14
|
+
/** Wall-clock duration when known (completed jobs); null otherwise. */
|
|
15
|
+
durationMs: number | null;
|
|
16
|
+
batchId: string | null;
|
|
17
|
+
siteId: string | null;
|
|
18
|
+
userId: number | null;
|
|
19
|
+
/** Present for `failed` / `released` (the release reason). */
|
|
20
|
+
error?: string;
|
|
21
|
+
rowsFetched?: number;
|
|
22
|
+
rowsInserted?: number;
|
|
23
|
+
d1RowsRead?: number;
|
|
24
|
+
d1RowsWritten?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface JobMetricsSink {
|
|
27
|
+
record: (event: JobMetricsEvent) => void | Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Adapt a {@link JobMetricsSink} to the repository's lifecycle hooks. Spread the
|
|
31
|
+
* result into {@link D1DurableJobRepositoryOptions}:
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* createD1DurableJobRepository(db, { ...metricsSinkToRepoHooks(sink) })
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* The repo invokes these via its own `fireHook` (swallows sync throws + async
|
|
38
|
+
* rejections), so a misbehaving sink can never break a job's lifecycle.
|
|
39
|
+
*/
|
|
40
|
+
export declare function metricsSinkToRepoHooks(sink: JobMetricsSink): Pick<D1DurableJobRepositoryOptions, 'onJobCompleted' | 'onJobFailed' | 'onJobReleased'>;
|
|
41
|
+
/**
|
|
42
|
+
* Fan an event out to several sinks, isolating each so one throwing/rejecting
|
|
43
|
+
* sink never starves the others (the repo guards the outermost call, but a sink
|
|
44
|
+
* combined here is also reachable directly).
|
|
45
|
+
*/
|
|
46
|
+
export declare function combineMetricsSinks(...sinks: JobMetricsSink[]): JobMetricsSink;
|
|
47
|
+
/**
|
|
48
|
+
* A console sink — a zero-dependency default for local dev where no real metrics
|
|
49
|
+
* engine binding exists. Pass your own `log` to route elsewhere.
|
|
50
|
+
*/
|
|
51
|
+
export declare function createConsoleMetricsSink(log?: (event: JobMetricsEvent) => void): JobMetricsSink;
|
|
52
|
+
/** A sink that records nothing — the safe default when metrics are unconfigured. */
|
|
53
|
+
export declare const noopMetricsSink: JobMetricsSink;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const STAT_KEYS = ["rowsFetched", "rowsInserted", "d1RowsRead", "d1RowsWritten"];
|
|
2
|
+
function readStats(result) {
|
|
3
|
+
if (!result || typeof result !== "object")
|
|
4
|
+
return {};
|
|
5
|
+
const out = {};
|
|
6
|
+
for (const k of STAT_KEYS) {
|
|
7
|
+
const v = result[k];
|
|
8
|
+
if (typeof v === "number")
|
|
9
|
+
out[k] = v;
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
function toEvent(job, status, extra) {
|
|
14
|
+
return {
|
|
15
|
+
jobId: job.id,
|
|
16
|
+
queue: job.queue,
|
|
17
|
+
jobType: job.job_type,
|
|
18
|
+
status,
|
|
19
|
+
attempts: job.attempts,
|
|
20
|
+
durationMs: extra.durationMs ?? null,
|
|
21
|
+
batchId: job.batch_id,
|
|
22
|
+
siteId: job.site_id,
|
|
23
|
+
userId: job.user_id,
|
|
24
|
+
error: extra.error
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function metricsSinkToRepoHooks(sink) {
|
|
28
|
+
return {
|
|
29
|
+
onJobCompleted({ job, durationMs, result }) {
|
|
30
|
+
return sink.record({ ...toEvent(job, "completed", { durationMs }), ...readStats(result) });
|
|
31
|
+
},
|
|
32
|
+
onJobFailed({ job, error }) {
|
|
33
|
+
return sink.record(toEvent(job, "failed", { durationMs: job.duration_ms ?? null, error }));
|
|
34
|
+
},
|
|
35
|
+
onJobReleased({ job, opts }) {
|
|
36
|
+
return sink.record(toEvent(job, "released", { error: opts?.error }));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function combineMetricsSinks(...sinks) {
|
|
41
|
+
return {
|
|
42
|
+
record(event) {
|
|
43
|
+
for (const sink of sinks) {
|
|
44
|
+
try {
|
|
45
|
+
const result = sink.record(event);
|
|
46
|
+
if (result && typeof result.then === "function")
|
|
47
|
+
result.catch(() => {
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function createConsoleMetricsSink(log = (event) => console.debug("[cf-jobs:metric]", event)) {
|
|
56
|
+
return { record: log };
|
|
57
|
+
}
|
|
58
|
+
export const noopMetricsSink = { record() {
|
|
59
|
+
} };
|
|
@@ -122,6 +122,54 @@ export interface DurableJobRecoveryRepository<Queue extends string = string, Rec
|
|
|
122
122
|
findStaleReservedJobs?: (query: DurableJobStaleRecoveryQuery) => Promise<Record[]>;
|
|
123
123
|
releaseStaleReservedJobs?: (query: DurableJobStaleRecoveryQuery) => Promise<number>;
|
|
124
124
|
}
|
|
125
|
+
export interface PruneDurableJobsQuery {
|
|
126
|
+
/** Unix-seconds cutoff: terminal rows with a timestamp at or before this are deleted. */
|
|
127
|
+
before: number;
|
|
128
|
+
/**
|
|
129
|
+
* Max rows deleted per statement. Implementations chunk the delete in a loop
|
|
130
|
+
* (D1 caps rows touched per statement), so the full backlog older than `before`
|
|
131
|
+
* is removed regardless of this value. Defaults to the implementation's chunk size.
|
|
132
|
+
*/
|
|
133
|
+
limit?: number;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Pruning the three terminal-row tables (Laravel `queue:prune-batches` /
|
|
137
|
+
* `queue:prune-failed` parity). Each method deletes only rows that are genuinely
|
|
138
|
+
* terminal (completed / failed / finished) and older than `before`, returning the
|
|
139
|
+
* total deleted. In-flight rows (`completed_at`/`failed_at`/`finished_at` IS NULL)
|
|
140
|
+
* are never touched.
|
|
141
|
+
*/
|
|
142
|
+
export interface DurableJobPruneRepository {
|
|
143
|
+
/** Delete `jobs` rows with `completed_at <= before` (soft-completed, kept for observability). */
|
|
144
|
+
pruneCompletedJobs: (query: PruneDurableJobsQuery) => Promise<number>;
|
|
145
|
+
/** Delete `job_batches` rows with `finished_at <= before` (terminal batches). */
|
|
146
|
+
pruneFinishedBatches: (query: PruneDurableJobsQuery) => Promise<number>;
|
|
147
|
+
/** Delete `failed_jobs` rows with `failed_at <= before`. */
|
|
148
|
+
pruneFailedJobs: (query: PruneDurableJobsQuery) => Promise<number>;
|
|
149
|
+
}
|
|
150
|
+
export interface PruneDurableJobsOptions {
|
|
151
|
+
/** Cutoff for `completeJob`-soft-completed `jobs` rows. Omit to skip. */
|
|
152
|
+
completedBefore?: number;
|
|
153
|
+
/** Cutoff for terminal `job_batches` rows. Omit to skip. */
|
|
154
|
+
finishedBatchesBefore?: number;
|
|
155
|
+
/** Cutoff for `failed_jobs` rows. Omit to skip. */
|
|
156
|
+
failedBefore?: number;
|
|
157
|
+
/** Per-statement chunk size forwarded to each prune method. */
|
|
158
|
+
limit?: number;
|
|
159
|
+
}
|
|
160
|
+
export interface PruneDurableJobsResult {
|
|
161
|
+
completedJobs: number;
|
|
162
|
+
finishedBatches: number;
|
|
163
|
+
failedJobs: number;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Convenience over {@link DurableJobPruneRepository} that prunes all three tables
|
|
167
|
+
* with independent cutoffs. Ordering matters: `jobs.batch_id` FKs `job_batches(id)`,
|
|
168
|
+
* so member jobs (completed + failed) are pruned BEFORE their batches — otherwise a
|
|
169
|
+
* batch delete can violate the FK where D1 enforces it. A cutoff left `undefined`
|
|
170
|
+
* skips that table (the corresponding count is 0).
|
|
171
|
+
*/
|
|
172
|
+
export declare function pruneDurableJobs(repository: DurableJobPruneRepository, opts: PruneDurableJobsOptions): Promise<PruneDurableJobsResult>;
|
|
125
173
|
export interface PrepareDurableJobOptions<Name extends string, Payload extends object, Queue extends string> {
|
|
126
174
|
name: Name;
|
|
127
175
|
payload: Payload;
|
|
@@ -231,7 +279,7 @@ export interface RunDurableJobMessageOptions<StoredJob, Job extends Dispatchable
|
|
|
231
279
|
message: Pick<QueueMessage<Message>, 'body' | 'ack' | 'retry'>;
|
|
232
280
|
lifecycle: Pick<DurableJobLifecycle<StoredJob, CompleteResult, FailOptions>, 'claimJob' | 'resolveClaimMiss' | 'completeJob' | 'failJob' | 'releaseJob'>;
|
|
233
281
|
registry: {
|
|
234
|
-
getHandler: (name: string) => JobHandler<unknown, Env, Db, Logger> | undefined
|
|
282
|
+
getHandler: (name: string) => JobHandler<unknown, Env, Db, Logger> | undefined | Promise<JobHandler<unknown, Env, Db, Logger> | undefined>;
|
|
235
283
|
getJobDefinition?: (name: string) => JobDefinition<string, unknown, string, Env, Db, Logger> | undefined;
|
|
236
284
|
};
|
|
237
285
|
toDispatchableJob: (job: StoredJob) => Job;
|
|
@@ -252,6 +300,14 @@ export interface RunDurableJobMessageOptions<StoredJob, Job extends Dispatchable
|
|
|
252
300
|
job: StoredJob;
|
|
253
301
|
dispatch: DispatchResult;
|
|
254
302
|
}) => unknown | Promise<unknown>;
|
|
303
|
+
/**
|
|
304
|
+
* Laravel worker model: a handler that throws is retried until `attempts`
|
|
305
|
+
* reaches the job's max, then failed (→ `failed_jobs`) instead of released for
|
|
306
|
+
* another try. Supply this to read the stored job's max so the consumer — not
|
|
307
|
+
* just the queue transport's `max_retries` — enforces the per-job attempt cap.
|
|
308
|
+
* Omit to keep retrying every throw (the transport/DLQ then decides terminal).
|
|
309
|
+
*/
|
|
310
|
+
maxAttemptsOf?: (job: StoredJob) => number | undefined;
|
|
255
311
|
}
|
|
256
312
|
/**
|
|
257
313
|
* Outcome of {@link runDurableJobMessage}, discriminated on `status` so each
|
|
@@ -264,6 +320,8 @@ export interface RunDurableJobMessageOptions<StoredJob, Job extends Dispatchable
|
|
|
264
320
|
* - `errored`: the handler threw an unexpected defect; the message is retried and
|
|
265
321
|
* `error` carries the defect as a `handler-threw` `JobError` (`error.cause` is the
|
|
266
322
|
* original throw). Distinct from `released` (a deliberate `ctx.release()`).
|
|
323
|
+
* - `exhausted`: the handler threw AND `attempts` reached `maxAttemptsOf` — the job
|
|
324
|
+
* was failed (→ `failed_jobs`) rather than retried. Terminal.
|
|
267
325
|
*/
|
|
268
326
|
export type RunDurableJobMessageResult = {
|
|
269
327
|
status: 'invalid-message';
|
|
@@ -285,5 +343,8 @@ export type RunDurableJobMessageResult = {
|
|
|
285
343
|
} | {
|
|
286
344
|
status: 'errored';
|
|
287
345
|
error: JobError;
|
|
346
|
+
} | {
|
|
347
|
+
status: 'exhausted';
|
|
348
|
+
error: JobError;
|
|
288
349
|
};
|
|
289
350
|
export declare function runDurableJobMessage<StoredJob, Job extends DispatchableJob, Message extends QueueJobMessage = QueueJobMessage, Env = unknown, Db = unknown, Logger = unknown, CompleteResult = unknown, FailOptions = unknown>(opts: RunDurableJobMessageOptions<StoredJob, Job, Message, Env, Db, Logger, CompleteResult, FailOptions>): Promise<RunDurableJobMessageResult>;
|
|
@@ -8,6 +8,12 @@ import { err, ok, unwrapResult } from "./result.js";
|
|
|
8
8
|
function byteLength(value) {
|
|
9
9
|
return typeof Buffer !== "undefined" ? Buffer.byteLength(value, "utf8") : new TextEncoder().encode(value).byteLength;
|
|
10
10
|
}
|
|
11
|
+
export async function pruneDurableJobs(repository, opts) {
|
|
12
|
+
const completedJobs = typeof opts.completedBefore === "number" ? await repository.pruneCompletedJobs({ before: opts.completedBefore, limit: opts.limit }) : 0;
|
|
13
|
+
const failedJobs = typeof opts.failedBefore === "number" ? await repository.pruneFailedJobs({ before: opts.failedBefore, limit: opts.limit }) : 0;
|
|
14
|
+
const finishedBatches = typeof opts.finishedBatchesBefore === "number" ? await repository.pruneFinishedBatches({ before: opts.finishedBatchesBefore, limit: opts.limit }) : 0;
|
|
15
|
+
return { completedJobs, failedJobs, finishedBatches };
|
|
16
|
+
}
|
|
11
17
|
export async function prepareDurableJobResult(opts) {
|
|
12
18
|
const now = opts.now ?? Math.floor(Date.now() / 1e3);
|
|
13
19
|
const definition = opts.definition ?? opts.registry?.getJobDefinition?.(opts.name);
|
|
@@ -221,11 +227,18 @@ export async function runDurableJobMessage(opts) {
|
|
|
221
227
|
}
|
|
222
228
|
const storedJob = claimed.job;
|
|
223
229
|
const job = opts.toDispatchableJob(storedJob);
|
|
230
|
+
const reportedStats = {};
|
|
231
|
+
const reportStats = (s) => {
|
|
232
|
+
for (const k of ["rowsFetched", "rowsInserted", "d1RowsRead", "d1RowsWritten"]) {
|
|
233
|
+
if (typeof s[k] === "number")
|
|
234
|
+
reportedStats[k] = (reportedStats[k] ?? 0) + s[k];
|
|
235
|
+
}
|
|
236
|
+
};
|
|
224
237
|
try {
|
|
225
238
|
const dispatch = await dispatchRegisteredJob({
|
|
226
239
|
registry: opts.registry,
|
|
227
240
|
job,
|
|
228
|
-
createContext: (input) => opts.createJobContext({ ...input, storedJob })
|
|
241
|
+
createContext: async (input) => ({ ...await opts.createJobContext({ ...input, storedJob }), reportStats })
|
|
229
242
|
});
|
|
230
243
|
if (!dispatch.success) {
|
|
231
244
|
if (opts.failDispatchFailure !== false)
|
|
@@ -234,13 +247,29 @@ export async function runDurableJobMessage(opts) {
|
|
|
234
247
|
return { status: "dispatch-failed", dispatch, error: dispatch.error };
|
|
235
248
|
}
|
|
236
249
|
if (dispatch.control?.handled) {
|
|
237
|
-
|
|
238
|
-
|
|
250
|
+
if (dispatch.control.action === "failed") {
|
|
251
|
+
await failDurableJob(opts.lifecycle, storedJob, dispatch.control.error ?? "Job failed via ctx.fail()");
|
|
252
|
+
opts.message.ack();
|
|
253
|
+
return { status: "failed", dispatch };
|
|
254
|
+
}
|
|
255
|
+
const delaySeconds = dispatch.control.delaySeconds ?? 0;
|
|
256
|
+
await releaseDurableJob(opts.lifecycle, storedJob, { delaySeconds, error: dispatch.control.error });
|
|
257
|
+
opts.message.retry({ delaySeconds });
|
|
258
|
+
return { status: "released", dispatch };
|
|
239
259
|
}
|
|
240
|
-
|
|
260
|
+
const result = await opts.completeResult?.({ job: storedJob, dispatch });
|
|
261
|
+
const hasStats = Object.keys(reportedStats).length > 0;
|
|
262
|
+
const completeWith = hasStats ? { ...result && typeof result === "object" ? result : {}, ...reportedStats } : result;
|
|
263
|
+
await completeDurableJob(opts.lifecycle, storedJob, completeWith);
|
|
241
264
|
opts.message.ack();
|
|
242
265
|
return { status: "completed", dispatch };
|
|
243
266
|
} catch (error) {
|
|
267
|
+
const maxAttempts = opts.maxAttemptsOf?.(storedJob);
|
|
268
|
+
if (typeof maxAttempts === "number" && job.attempts >= maxAttempts) {
|
|
269
|
+
await failDurableJob(opts.lifecycle, storedJob, describeCause(error));
|
|
270
|
+
opts.message.ack();
|
|
271
|
+
return { status: "exhausted", error: jobErrors.handlerThrew(error) };
|
|
272
|
+
}
|
|
244
273
|
const delaySeconds = typeof opts.retryDelaySeconds === "function" ? opts.retryDelaySeconds({ error, job: storedJob }) : opts.retryDelaySeconds ?? 0;
|
|
245
274
|
await releaseDurableJob(opts.lifecycle, storedJob, {
|
|
246
275
|
delaySeconds,
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { BatchProgress, DurableBatchStore, SettleBatchMemberOptions, SettleBatchMemberResult } from './batch.js';
|
|
2
|
+
import type { D1DatabaseLike, D1DurableJobRepository } from './d1.js';
|
|
3
|
+
import type { JobMetricsSink } from './metrics.js';
|
|
4
|
+
import type { CreateJobBatchOptions, CreateJobBatchResult, DurableJobRecord, EnqueueDurableJobResult, PruneDurableJobsOptions, PruneDurableJobsResult, QueuePublisher, RunDurableJobMessageOptions, RunDurableJobMessageResult } from './outbox.js';
|
|
5
|
+
import type { DispatchableJob, JobDefinition, JobHandler, QueueBatch, QueueMessage } from './types.js';
|
|
6
|
+
export interface RunDurableJobBatchMessageOptions<StoredJob, Job extends DispatchableJob, Message extends {
|
|
7
|
+
jobId: string;
|
|
8
|
+
queue: string;
|
|
9
|
+
} = {
|
|
10
|
+
jobId: string;
|
|
11
|
+
queue: string;
|
|
12
|
+
}, Env = unknown, Db = unknown, Logger = unknown> extends RunDurableJobMessageOptions<StoredJob, Job, Message, Env, Db, Logger> {
|
|
13
|
+
store: DurableBatchStore;
|
|
14
|
+
/** Run the winning settle's `onFinish`. Defaults to nothing. */
|
|
15
|
+
dispatchOnFinish?: SettleBatchMemberOptions['dispatchOnFinish'];
|
|
16
|
+
/** Notified after each settle with the batch's progress (live UI / telemetry). */
|
|
17
|
+
onBatchProgress?: (progress: BatchProgress) => void | Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export interface RunDurableJobBatchMessageResult {
|
|
20
|
+
run: RunDurableJobMessageResult;
|
|
21
|
+
settled: SettleBatchMemberResult | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Run one queue message through the full durable lifecycle, then settle its
|
|
25
|
+
* batch. Only terminal outcomes (`completed` / `failed` / `dispatch-failed`)
|
|
26
|
+
* settle — a `released`/`errored` message will re-run, so settling it would
|
|
27
|
+
* double-count. The settle's progress is forwarded to `onBatchProgress`, always
|
|
28
|
+
* awaited so `onFinish` has fired before this resolves.
|
|
29
|
+
*/
|
|
30
|
+
export declare function runDurableJobBatchMessage<StoredJob, Job extends DispatchableJob, Message extends {
|
|
31
|
+
jobId: string;
|
|
32
|
+
queue: string;
|
|
33
|
+
} = {
|
|
34
|
+
jobId: string;
|
|
35
|
+
queue: string;
|
|
36
|
+
}, Env = unknown, Db = unknown, Logger = unknown>(opts: RunDurableJobBatchMessageOptions<StoredJob, Job, Message, Env, Db, Logger>): Promise<RunDurableJobBatchMessageResult>;
|
|
37
|
+
export interface RunLightweightMessageOptions<Env = unknown, Db = unknown, Logger = unknown> {
|
|
38
|
+
message: Pick<QueueMessage, 'id' | 'body' | 'attempts' | 'ack' | 'retry'>;
|
|
39
|
+
registry: DurableJobsRuntimeRegistry<Env, Db, Logger>;
|
|
40
|
+
createJobContext: ConsumerContextFactory<Env, Db, Logger>;
|
|
41
|
+
/** Per-isolate dedup of at-least-once redeliveries. Return true to drop. */
|
|
42
|
+
isDuplicate?: (id: string | undefined) => boolean;
|
|
43
|
+
/** Diagnostic sink for dropped/invalid messages (no throw). */
|
|
44
|
+
onLog?: (event: {
|
|
45
|
+
stage: string;
|
|
46
|
+
taskName?: string;
|
|
47
|
+
error?: string;
|
|
48
|
+
}) => void;
|
|
49
|
+
}
|
|
50
|
+
export type RunLightweightMessageResult = {
|
|
51
|
+
status: 'invalid' | 'duplicate' | 'dispatch-failed' | 'released' | 'completed' | 'errored';
|
|
52
|
+
};
|
|
53
|
+
export declare function runLightweightMessage<Env, Db, Logger>(opts: RunLightweightMessageOptions<Env, Db, Logger>): Promise<RunLightweightMessageResult>;
|
|
54
|
+
export interface ConsumeQueueBatchOptions<Queue extends string, Env, Db, Logger> {
|
|
55
|
+
batch: QueueBatch;
|
|
56
|
+
repository: D1DurableJobRepository<Queue>;
|
|
57
|
+
store: DurableBatchStore;
|
|
58
|
+
registry: DurableJobsRuntimeRegistry<Env, Db, Logger>;
|
|
59
|
+
createJobContext: ConsumerContextFactory<Env, Db, Logger>;
|
|
60
|
+
dispatchOnFinish?: SettleBatchMemberOptions['dispatchOnFinish'];
|
|
61
|
+
onBatchProgress?: (progress: BatchProgress) => void | Promise<void>;
|
|
62
|
+
retryDelaySeconds?: RunDurableJobMessageOptions<unknown, DispatchableJob>['retryDelaySeconds'];
|
|
63
|
+
/** Defaults to a `-dlq` suffix check. */
|
|
64
|
+
isDlqQueue?: (queue: string) => boolean;
|
|
65
|
+
isDuplicate?: (id: string | undefined) => boolean;
|
|
66
|
+
onLog?: (event: {
|
|
67
|
+
stage: string;
|
|
68
|
+
queue?: string;
|
|
69
|
+
taskName?: string;
|
|
70
|
+
jobId?: string;
|
|
71
|
+
error?: string;
|
|
72
|
+
}) => void;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Process one CF queue batch end-to-end — the loop both apps hand-roll:
|
|
76
|
+
* - a DLQ-queue batch settles each durable member (failed) so a CF-exhausted
|
|
77
|
+
* message can't hang its batch;
|
|
78
|
+
* - `{ jobId }` envelopes take the durable path (claim → run → settle + progress);
|
|
79
|
+
* - `{ _task }` payloads take the lightweight path.
|
|
80
|
+
*/
|
|
81
|
+
export declare function consumeQueueBatch<Queue extends string, Env, Db, Logger>(opts: ConsumeQueueBatchOptions<Queue, Env, Db, Logger>): Promise<void>;
|
|
82
|
+
export interface DurableJobsRuntimeRegistry<Env = unknown, Db = unknown, Logger = unknown> {
|
|
83
|
+
/** May resolve async for lazily-loaded jobs (dispatchRegisteredJob awaits it). */
|
|
84
|
+
getHandler: (name: string) => JobHandler<unknown, Env, Db, Logger> | undefined | Promise<JobHandler<unknown, Env, Db, Logger> | undefined>;
|
|
85
|
+
getJobDefinition?: (name: string) => JobDefinition<string, unknown, string, Env, Db, Logger> | undefined;
|
|
86
|
+
getJobRoute?: (name: string) => {
|
|
87
|
+
queue: string;
|
|
88
|
+
jobType: string;
|
|
89
|
+
} | undefined;
|
|
90
|
+
}
|
|
91
|
+
type ConsumerMessage = RunDurableJobMessageOptions<unknown, DispatchableJob>['message'];
|
|
92
|
+
type ConsumerContextFactory<Env, Db, Logger> = RunDurableJobMessageOptions<unknown, DispatchableJob, {
|
|
93
|
+
jobId: string;
|
|
94
|
+
queue: string;
|
|
95
|
+
}, Env, Db, Logger>['createJobContext'];
|
|
96
|
+
export interface CreateDurableJobsRuntimeOptions<Queue extends string = string, Env = unknown, Db = unknown, Logger = unknown> {
|
|
97
|
+
/** Raw D1 binding. The repo + batch store are built from it. */
|
|
98
|
+
db: D1DatabaseLike;
|
|
99
|
+
/** Worker `env`, used to resolve queue bindings. */
|
|
100
|
+
env: Record<string, unknown>;
|
|
101
|
+
registry: DurableJobsRuntimeRegistry<Env, Db, Logger>;
|
|
102
|
+
/** Map a logical queue to its `env` binding name. */
|
|
103
|
+
resolveQueueBinding: (queue: Queue) => string | undefined;
|
|
104
|
+
/** Build the per-job context (db, log, control, …). */
|
|
105
|
+
createJobContext: ConsumerContextFactory<Env, Db, Logger>;
|
|
106
|
+
/** Laravel `retry_after` — reclaim a reservation older than this on claim. */
|
|
107
|
+
reclaimAfterSeconds?: number;
|
|
108
|
+
/** Telemetry sink; wired into the repo's lifecycle hooks automatically. */
|
|
109
|
+
metricsSink?: JobMetricsSink;
|
|
110
|
+
/** Notified after each batch settle. */
|
|
111
|
+
onBatchProgress?: (progress: BatchProgress) => void | Promise<void>;
|
|
112
|
+
/** Per-throw retry backoff for the consumer. */
|
|
113
|
+
retryDelaySeconds?: RunDurableJobMessageOptions<unknown, DispatchableJob>['retryDelaySeconds'];
|
|
114
|
+
onMissingBinding?: (queue: Queue, count: number) => void | Promise<void>;
|
|
115
|
+
/** Classify a queue as a dead-letter queue (defaults to a `-dlq` suffix check). */
|
|
116
|
+
isDlqQueue?: (queue: string) => boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Per-isolate dedup set for the lightweight `_task` path. Pass a module-level
|
|
119
|
+
* Set so redeliveries dedup across invocations (durable jobs are claim-guarded,
|
|
120
|
+
* so they don't need this).
|
|
121
|
+
*/
|
|
122
|
+
dedup?: Set<string>;
|
|
123
|
+
/** Diagnostic log sink for DLQ / dropped lightweight messages. */
|
|
124
|
+
onLog?: (event: {
|
|
125
|
+
stage: string;
|
|
126
|
+
queue?: string;
|
|
127
|
+
taskName?: string;
|
|
128
|
+
jobId?: string;
|
|
129
|
+
error?: string;
|
|
130
|
+
}) => void;
|
|
131
|
+
}
|
|
132
|
+
export interface DurableJobsRuntime<Queue extends string = string> {
|
|
133
|
+
repository: D1DurableJobRepository<Queue>;
|
|
134
|
+
store: DurableBatchStore;
|
|
135
|
+
publisher: QueuePublisher<Queue>;
|
|
136
|
+
/** Durably enqueue a prepared record (persist row + dispatch message). */
|
|
137
|
+
enqueue: (record: DurableJobRecord<Queue>, opts?: {
|
|
138
|
+
delaySeconds?: number;
|
|
139
|
+
}) => Promise<EnqueueDurableJobResult>;
|
|
140
|
+
/** Register + dispatch a batch; `onFinish` fires once every member settles. */
|
|
141
|
+
createBatch: (opts: Omit<CreateJobBatchOptions<string, Record<string, unknown>, Queue>, 'store' | 'repository' | 'publisher'>) => Promise<CreateJobBatchResult>;
|
|
142
|
+
/** Run one durable queue message end-to-end (lifecycle + batch settle + progress). */
|
|
143
|
+
consumeMessage: (message: ConsumerMessage) => Promise<RunDurableJobBatchMessageResult>;
|
|
144
|
+
/** Process a whole CF queue batch (DLQ + durable + lightweight) — the consumer entrypoint. */
|
|
145
|
+
consumeBatch: (batch: QueueBatch) => Promise<void>;
|
|
146
|
+
/** Prune terminal rows (completed jobs / finished batches / failed jobs). */
|
|
147
|
+
prune: (opts: PruneDurableJobsOptions) => Promise<PruneDurableJobsResult>;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Bundle the durable-jobs verbs behind one factory so consumers stop
|
|
151
|
+
* hand-assembling repo + store + publisher + enqueue + batch + consumer loop.
|
|
152
|
+
* The metrics sink is wired into the repo here (no `metricsSinkToRepoHooks` at
|
|
153
|
+
* call sites), and `onFinish` continuations are enqueued durably by default.
|
|
154
|
+
*/
|
|
155
|
+
export declare function createDurableJobsRuntime<Queue extends string = string, Env = unknown, Db = unknown, Logger = unknown>(opts: CreateDurableJobsRuntimeOptions<Queue, Env, Db, Logger>): DurableJobsRuntime<Queue>;
|
|
156
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createD1DurableBatchStore, createJobBatch, settleBatchMember } from "./batch.js";
|
|
2
|
+
import { createD1DurableJobRepository } from "./d1.js";
|
|
3
|
+
import { dispatchRegisteredJob } from "./dispatch.js";
|
|
4
|
+
import { formatJobError } from "./errors.js";
|
|
5
|
+
import { metricsSinkToRepoHooks } from "./metrics.js";
|
|
6
|
+
import { createQueuePublisher, enqueueDurableJob, prepareDurableJob, pruneDurableJobs, runDurableJobMessage } from "./outbox.js";
|
|
7
|
+
import { resolveJobRetryDelay } from "./policy.js";
|
|
8
|
+
export async function runDurableJobBatchMessage(opts) {
|
|
9
|
+
const run = await runDurableJobMessage(opts);
|
|
10
|
+
const terminal = run.status === "completed" || run.status === "failed" || run.status === "dispatch-failed" || run.status === "exhausted";
|
|
11
|
+
if (!terminal)
|
|
12
|
+
return { run, settled: null };
|
|
13
|
+
const jobId = opts.getJobId?.(opts.message.body) ?? opts.message.body.jobId;
|
|
14
|
+
if (!jobId)
|
|
15
|
+
return { run, settled: null };
|
|
16
|
+
const settled = await settleBatchMember({
|
|
17
|
+
store: opts.store,
|
|
18
|
+
jobId,
|
|
19
|
+
failed: run.status !== "completed",
|
|
20
|
+
dispatchOnFinish: opts.dispatchOnFinish
|
|
21
|
+
});
|
|
22
|
+
if (settled.progress && opts.onBatchProgress)
|
|
23
|
+
await opts.onBatchProgress(settled.progress);
|
|
24
|
+
return { run, settled };
|
|
25
|
+
}
|
|
26
|
+
export async function runLightweightMessage(opts) {
|
|
27
|
+
const { message, registry, createJobContext } = opts;
|
|
28
|
+
const body = message.body ?? {};
|
|
29
|
+
const taskName = typeof body._task === "string" ? body._task : "";
|
|
30
|
+
const definition = taskName ? registry.getJobDefinition?.(taskName) : void 0;
|
|
31
|
+
if (!definition) {
|
|
32
|
+
opts.onLog?.({ stage: "invalid_payload", taskName, error: taskName ? `No handler for task: ${taskName}` : "No _task in payload" });
|
|
33
|
+
message.ack();
|
|
34
|
+
return { status: "invalid" };
|
|
35
|
+
}
|
|
36
|
+
if (opts.isDuplicate?.(message.id)) {
|
|
37
|
+
message.ack();
|
|
38
|
+
return { status: "duplicate" };
|
|
39
|
+
}
|
|
40
|
+
const job = {
|
|
41
|
+
id: `${taskName}:${typeof body.jobId === "string" ? body.jobId : crypto.randomUUID()}`,
|
|
42
|
+
queue: definition.queue ?? "",
|
|
43
|
+
payload: body,
|
|
44
|
+
attempts: message.attempts,
|
|
45
|
+
batchId: null,
|
|
46
|
+
siteId: typeof body.siteId === "string" ? body.siteId : null,
|
|
47
|
+
userId: typeof body.userId === "number" ? body.userId : null
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
const dispatch = await dispatchRegisteredJob({
|
|
51
|
+
registry,
|
|
52
|
+
job,
|
|
53
|
+
createContext: ({ control }) => createJobContext({ job, storedJob: job, taskName, payload: job.payload, control })
|
|
54
|
+
});
|
|
55
|
+
if (!dispatch.success) {
|
|
56
|
+
opts.onLog?.({ stage: "invalid_payload", taskName, error: dispatch.error ? formatJobError(dispatch.error) : "invalid payload" });
|
|
57
|
+
message.ack();
|
|
58
|
+
return { status: "dispatch-failed" };
|
|
59
|
+
}
|
|
60
|
+
if (dispatch.control?.action === "released") {
|
|
61
|
+
message.retry({ delaySeconds: dispatch.control.delaySeconds ?? 0 });
|
|
62
|
+
return { status: "released" };
|
|
63
|
+
}
|
|
64
|
+
message.ack();
|
|
65
|
+
return { status: "completed" };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const delaySeconds = resolveJobRetryDelay(definition, message.attempts);
|
|
68
|
+
opts.onLog?.({ stage: "unexpected", taskName, error: error instanceof Error ? error.message : String(error) });
|
|
69
|
+
message.retry({ delaySeconds });
|
|
70
|
+
return { status: "errored" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function defaultIsDlqQueue(queue) {
|
|
74
|
+
return queue.includes("-dlq");
|
|
75
|
+
}
|
|
76
|
+
export async function consumeQueueBatch(opts) {
|
|
77
|
+
const isDlq = opts.isDlqQueue ?? defaultIsDlqQueue;
|
|
78
|
+
if (isDlq(opts.batch.queue)) {
|
|
79
|
+
for (const message of opts.batch.messages) {
|
|
80
|
+
const body = message.body ?? {};
|
|
81
|
+
const jobId = typeof body.jobId === "string" ? body.jobId : void 0;
|
|
82
|
+
opts.onLog?.({ stage: "dlq", queue: opts.batch.queue, taskName: typeof body._task === "string" ? body._task : void 0, jobId });
|
|
83
|
+
if (jobId) {
|
|
84
|
+
const claimed = await opts.repository.claimJob(jobId).catch(() => null);
|
|
85
|
+
if (claimed) {
|
|
86
|
+
await opts.repository.failJob(claimed, "Exhausted retries (dead-letter queue)").catch(() => {
|
|
87
|
+
});
|
|
88
|
+
await settleBatchMember({ store: opts.store, jobId, failed: true, dispatchOnFinish: opts.dispatchOnFinish }).then((r) => r.progress && opts.onBatchProgress ? opts.onBatchProgress(r.progress) : void 0).catch(() => {
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
message.ack();
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const message of opts.batch.messages) {
|
|
97
|
+
const body = message.body ?? {};
|
|
98
|
+
const jobId = typeof body.jobId === "string" ? body.jobId : void 0;
|
|
99
|
+
if (jobId) {
|
|
100
|
+
await runDurableJobBatchMessage({
|
|
101
|
+
message,
|
|
102
|
+
lifecycle: opts.repository,
|
|
103
|
+
registry: opts.registry,
|
|
104
|
+
store: opts.store,
|
|
105
|
+
toDispatchableJob: opts.repository.toDispatchableJob,
|
|
106
|
+
createJobContext: opts.createJobContext,
|
|
107
|
+
retryDelaySeconds: opts.retryDelaySeconds,
|
|
108
|
+
// Honour the stored job's attempt cap (Laravel worker model).
|
|
109
|
+
maxAttemptsOf: (stored) => stored.max_attempts,
|
|
110
|
+
dispatchOnFinish: opts.dispatchOnFinish,
|
|
111
|
+
onBatchProgress: opts.onBatchProgress
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
await runLightweightMessage({
|
|
115
|
+
message,
|
|
116
|
+
registry: opts.registry,
|
|
117
|
+
createJobContext: opts.createJobContext,
|
|
118
|
+
isDuplicate: opts.isDuplicate,
|
|
119
|
+
onLog: opts.onLog
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const DEFAULT_DEDUP_CAPACITY = 1024;
|
|
125
|
+
function makeIsDuplicate(seen) {
|
|
126
|
+
if (!seen)
|
|
127
|
+
return void 0;
|
|
128
|
+
return (id) => {
|
|
129
|
+
if (!id)
|
|
130
|
+
return false;
|
|
131
|
+
if (seen.has(id))
|
|
132
|
+
return true;
|
|
133
|
+
seen.add(id);
|
|
134
|
+
if (seen.size > DEFAULT_DEDUP_CAPACITY) {
|
|
135
|
+
const first = seen.values().next().value;
|
|
136
|
+
if (first !== void 0)
|
|
137
|
+
seen.delete(first);
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function createDurableJobsRuntime(opts) {
|
|
143
|
+
const repository = createD1DurableJobRepository(opts.db, {
|
|
144
|
+
reclaimAfterSeconds: opts.reclaimAfterSeconds,
|
|
145
|
+
...opts.metricsSink ? metricsSinkToRepoHooks(opts.metricsSink) : {}
|
|
146
|
+
});
|
|
147
|
+
const store = createD1DurableBatchStore(opts.db);
|
|
148
|
+
const publisher = createQueuePublisher(
|
|
149
|
+
opts.env,
|
|
150
|
+
opts.resolveQueueBinding,
|
|
151
|
+
{ onMissingBinding: opts.onMissingBinding }
|
|
152
|
+
);
|
|
153
|
+
const dispatchOnFinish = async ({ continuation, batch }) => {
|
|
154
|
+
const c = continuation;
|
|
155
|
+
try {
|
|
156
|
+
const record = await prepareDurableJob({ name: c.name, payload: c.payload, registry: opts.registry });
|
|
157
|
+
await enqueueDurableJob(repository, publisher, record);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
opts.onLog?.({ stage: "onfinish-failed", taskName: c.name, jobId: batch.id, error: error instanceof Error ? error.message : String(error) });
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const isDuplicate = makeIsDuplicate(opts.dedup);
|
|
163
|
+
return {
|
|
164
|
+
repository,
|
|
165
|
+
store,
|
|
166
|
+
publisher,
|
|
167
|
+
enqueue: (record, enqueueOpts) => enqueueDurableJob(repository, publisher, record, enqueueOpts),
|
|
168
|
+
createBatch: (batchOpts) => createJobBatch({
|
|
169
|
+
store,
|
|
170
|
+
repository,
|
|
171
|
+
publisher,
|
|
172
|
+
...batchOpts
|
|
173
|
+
}),
|
|
174
|
+
consumeMessage: (message) => runDurableJobBatchMessage({
|
|
175
|
+
message,
|
|
176
|
+
lifecycle: repository,
|
|
177
|
+
registry: opts.registry,
|
|
178
|
+
store,
|
|
179
|
+
toDispatchableJob: repository.toDispatchableJob,
|
|
180
|
+
createJobContext: opts.createJobContext,
|
|
181
|
+
retryDelaySeconds: opts.retryDelaySeconds,
|
|
182
|
+
maxAttemptsOf: (stored) => stored.max_attempts,
|
|
183
|
+
dispatchOnFinish,
|
|
184
|
+
onBatchProgress: opts.onBatchProgress
|
|
185
|
+
}),
|
|
186
|
+
consumeBatch: (batch) => consumeQueueBatch({
|
|
187
|
+
batch,
|
|
188
|
+
repository,
|
|
189
|
+
store,
|
|
190
|
+
registry: opts.registry,
|
|
191
|
+
createJobContext: opts.createJobContext,
|
|
192
|
+
dispatchOnFinish,
|
|
193
|
+
onBatchProgress: opts.onBatchProgress,
|
|
194
|
+
retryDelaySeconds: opts.retryDelaySeconds,
|
|
195
|
+
isDlqQueue: opts.isDlqQueue,
|
|
196
|
+
isDuplicate,
|
|
197
|
+
onLog: opts.onLog
|
|
198
|
+
}),
|
|
199
|
+
prune: (pruneOpts) => pruneDurableJobs(repository, pruneOpts)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -55,6 +55,7 @@ export const cfJobs = sqliteTable("jobs", {
|
|
|
55
55
|
index("idx_jobs_batch").on(t.batchId),
|
|
56
56
|
index("idx_jobs_trace").on(t.traceId),
|
|
57
57
|
index("idx_jobs_sync_dedup").on(t.siteId, t.jobType),
|
|
58
|
+
index("idx_jobs_completed_at").on(t.completedAt).where(sql`completed_at IS NOT NULL`),
|
|
58
59
|
uniqueIndex("idx_jobs_unique_active").on(t.uniqueKey).where(sql`unique_key IS NOT NULL AND completed_at IS NULL AND failed_at IS NULL`)
|
|
59
60
|
]);
|
|
60
61
|
export const cfFailedJobs = sqliteTable("failed_jobs", {
|
|
@@ -60,6 +60,17 @@ export interface JobControlResult {
|
|
|
60
60
|
delaySeconds?: number;
|
|
61
61
|
error?: string;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Per-run execution stats a handler can report (rows touched, D1 reads/writes).
|
|
65
|
+
* Reported via {@link JobContext.reportStats}; the consumer persists them to the
|
|
66
|
+
* job row + forwards them to the metrics sink. Multiple calls accumulate (sum).
|
|
67
|
+
*/
|
|
68
|
+
export interface JobRunStats {
|
|
69
|
+
rowsFetched?: number;
|
|
70
|
+
rowsInserted?: number;
|
|
71
|
+
d1RowsRead?: number;
|
|
72
|
+
d1RowsWritten?: number;
|
|
73
|
+
}
|
|
63
74
|
export interface JobContext<Env, Db, Logger> {
|
|
64
75
|
env: Env;
|
|
65
76
|
jobId: string;
|
|
@@ -69,6 +80,12 @@ export interface JobContext<Env, Db, Logger> {
|
|
|
69
80
|
log: Logger;
|
|
70
81
|
release: (delaySeconds: number) => Promise<void>;
|
|
71
82
|
fail: (error: string) => Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Report execution stats (rows fetched/inserted, D1 reads/writes) for metrics +
|
|
85
|
+
* observability. Injected by the durable consumer; optional so plain handlers
|
|
86
|
+
* and hand-built contexts need not provide it.
|
|
87
|
+
*/
|
|
88
|
+
reportStats?: (stats: JobRunStats) => void;
|
|
72
89
|
}
|
|
73
90
|
export type JobHandler<Payload, Env, Db, Logger> = (payload: Payload, ctx: JobContext<Env, Db, Logger>) => Promise<void>;
|
|
74
91
|
export type JobFailedHandler<Payload, Env, Db, Logger> = (payload: Payload, ctx: JobContext<Env, Db, Logger>, error: unknown) => Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-cf-jobs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"description": "Nuxt module for typed Cloudflare queue jobs.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Harlan Wilton",
|
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
"import": "./dist/runtime/server/index.js",
|
|
24
24
|
"default": "./dist/runtime/server/index.js"
|
|
25
25
|
},
|
|
26
|
+
"./cloudflare": {
|
|
27
|
+
"types": "./dist/runtime/server/cloudflare.d.ts",
|
|
28
|
+
"import": "./dist/runtime/server/cloudflare.js",
|
|
29
|
+
"default": "./dist/runtime/server/cloudflare.js"
|
|
30
|
+
},
|
|
26
31
|
"./d1": {
|
|
27
32
|
"types": "./dist/runtime/server/d1.d.ts",
|
|
28
33
|
"import": "./dist/runtime/server/d1.js",
|
|
@@ -46,6 +51,9 @@
|
|
|
46
51
|
"server": [
|
|
47
52
|
"dist/runtime/server/index.d.ts"
|
|
48
53
|
],
|
|
54
|
+
"cloudflare": [
|
|
55
|
+
"dist/runtime/server/cloudflare.d.ts"
|
|
56
|
+
],
|
|
49
57
|
"d1": [
|
|
50
58
|
"dist/runtime/server/d1.d.ts"
|
|
51
59
|
],
|