nuxt-cf-jobs 0.6.0 → 0.6.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/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-cf-jobs",
3
3
  "configKey": "cfJobs",
4
- "version": "0.6.0",
4
+ "version": "0.6.2",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -308,6 +308,47 @@ export interface RunDurableJobMessageOptions<StoredJob, Job extends Dispatchable
308
308
  * Omit to keep retrying every throw (the transport/DLQ then decides terminal).
309
309
  */
310
310
  maxAttemptsOf?: (job: StoredJob) => number | undefined;
311
+ /**
312
+ * Per-job scope created right after claim: `wrapDispatch` wraps the handler run
313
+ * (e.g. AsyncLocalStorage telemetry), `onSettled` observes every terminal/
314
+ * released outcome (e.g. write a run-log row). Both close over the same scope so
315
+ * the wrapper's collected data is available to the observer.
316
+ */
317
+ createJobScope?: (storedJob: StoredJob) => DurableJobScope<StoredJob>;
318
+ /**
319
+ * Decide whether a THROWN error is terminal (→ `failed_jobs`) vs released for
320
+ * retry. Overrides the default `attempts >= maxAttemptsOf` so callers can, e.g.,
321
+ * never fail on transient infra errors. `maxAttempts` is `maxAttemptsOf`'s value.
322
+ */
323
+ isPermanentFailure?: (input: {
324
+ error: unknown;
325
+ storedJob: StoredJob;
326
+ attempts: number;
327
+ maxAttempts: number | undefined;
328
+ }) => boolean;
329
+ /**
330
+ * Dispatch the job payload's `then`/`catch`/`finally` continuations after a
331
+ * terminal outcome: `then`+`finally` on success, `catch`+`finally` on terminal
332
+ * failure, none on a release/retry.
333
+ */
334
+ dispatchContinuations?: (input: {
335
+ storedJob: StoredJob;
336
+ stage: DurableJobContinuationStage;
337
+ }) => void | Promise<void>;
338
+ }
339
+ export interface DurableJobSettlement<StoredJob> {
340
+ storedJob: StoredJob;
341
+ status: RunDurableJobMessageResult['status'];
342
+ /** Wall-clock ms from claim to settle. */
343
+ durationMs: number;
344
+ /** True when the job reached a terminal failure (not a release/retry). */
345
+ permanent: boolean;
346
+ /** The error for failed/released/exhausted outcomes (raw, for the caller to classify). */
347
+ error?: unknown;
348
+ }
349
+ export interface DurableJobScope<StoredJob> {
350
+ wrapDispatch?: (run: () => Promise<DispatchResult>) => Promise<DispatchResult>;
351
+ onSettled?: (settlement: DurableJobSettlement<StoredJob>) => void | Promise<void>;
311
352
  }
312
353
  /**
313
354
  * Outcome of {@link runDurableJobMessage}, discriminated on `status` so each
@@ -234,26 +234,41 @@ export async function runDurableJobMessage(opts) {
234
234
  reportedStats[k] = (reportedStats[k] ?? 0) + s[k];
235
235
  }
236
236
  };
237
+ const scope = opts.createJobScope?.(storedJob);
238
+ const startedMs = Date.now();
239
+ const settle = async (status, permanent, error) => {
240
+ await scope?.onSettled?.({ storedJob, status, durationMs: Date.now() - startedMs, permanent, error });
241
+ };
242
+ const continuations = async (...stages) => {
243
+ for (const stage of stages)
244
+ await opts.dispatchContinuations?.({ storedJob, stage });
245
+ };
237
246
  try {
238
- const dispatch = await dispatchRegisteredJob({
247
+ const runOnce = () => dispatchRegisteredJob({
239
248
  registry: opts.registry,
240
249
  job,
241
250
  createContext: async (input) => ({ ...await opts.createJobContext({ ...input, storedJob }), reportStats })
242
251
  });
252
+ const dispatch = await (scope?.wrapDispatch ? scope.wrapDispatch(runOnce) : runOnce());
243
253
  if (!dispatch.success) {
244
254
  if (opts.failDispatchFailure !== false)
245
255
  await failDurableJob(opts.lifecycle, storedJob, dispatch.error ? formatJobError(dispatch.error) : "Job dispatch failed");
256
+ await settle("dispatch-failed", true, dispatch.error);
257
+ await continuations("catch", "finally");
246
258
  opts.message.ack();
247
259
  return { status: "dispatch-failed", dispatch, error: dispatch.error };
248
260
  }
249
261
  if (dispatch.control?.handled) {
250
262
  if (dispatch.control.action === "failed") {
251
263
  await failDurableJob(opts.lifecycle, storedJob, dispatch.control.error ?? "Job failed via ctx.fail()");
264
+ await settle("failed", true, dispatch.control.error);
265
+ await continuations("catch", "finally");
252
266
  opts.message.ack();
253
267
  return { status: "failed", dispatch };
254
268
  }
255
269
  const delaySeconds = dispatch.control.delaySeconds ?? 0;
256
270
  await releaseDurableJob(opts.lifecycle, storedJob, { delaySeconds, error: dispatch.control.error });
271
+ await settle("released", false, dispatch.control.error);
257
272
  opts.message.retry({ delaySeconds });
258
273
  return { status: "released", dispatch };
259
274
  }
@@ -261,12 +276,17 @@ export async function runDurableJobMessage(opts) {
261
276
  const hasStats = Object.keys(reportedStats).length > 0;
262
277
  const completeWith = hasStats ? { ...result && typeof result === "object" ? result : {}, ...reportedStats } : result;
263
278
  await completeDurableJob(opts.lifecycle, storedJob, completeWith);
279
+ await settle("completed", false);
280
+ await continuations("then", "finally");
264
281
  opts.message.ack();
265
282
  return { status: "completed", dispatch };
266
283
  } catch (error) {
267
284
  const maxAttempts = opts.maxAttemptsOf?.(storedJob);
268
- if (typeof maxAttempts === "number" && job.attempts >= maxAttempts) {
285
+ const permanent = opts.isPermanentFailure ? opts.isPermanentFailure({ error, storedJob, attempts: job.attempts, maxAttempts }) : typeof maxAttempts === "number" && job.attempts >= maxAttempts;
286
+ if (permanent) {
269
287
  await failDurableJob(opts.lifecycle, storedJob, describeCause(error));
288
+ await settle("exhausted", true, error);
289
+ await continuations("catch", "finally");
270
290
  opts.message.ack();
271
291
  return { status: "exhausted", error: jobErrors.handlerThrew(error) };
272
292
  }
@@ -275,6 +295,7 @@ export async function runDurableJobMessage(opts) {
275
295
  delaySeconds,
276
296
  error: describeCause(error)
277
297
  });
298
+ await settle("errored", false, error);
278
299
  opts.message.retry({ delaySeconds });
279
300
  return { status: "errored", error: jobErrors.handlerThrew(error) };
280
301
  }
@@ -18,14 +18,16 @@ export default defineNitroPlugin((nitroApp) => {
18
18
  console.error("[nuxt-cf-jobs] dev queue error:", error);
19
19
  }
20
20
  });
21
+ const taskEnvHost = globalThis;
22
+ taskEnvHost.__env__ = { ...runtime.env, ...taskEnvHost.__env__ ?? {} };
21
23
  nitroApp.hooks.hook("request", (event) => {
22
24
  const existing = event.context.cloudflare?.env;
23
25
  event.context.cloudflare = {
24
26
  ...event.context.cloudflare ?? {},
25
27
  env: existing ? { ...runtime.env, ...existing } : runtime.env
26
28
  };
29
+ if (existing)
30
+ taskEnvHost.__env__ = { ...taskEnvHost.__env__, ...existing, ...runtime.env };
27
31
  });
28
- const taskEnvHost = globalThis;
29
- taskEnvHost.__env__ = { ...runtime.env, ...taskEnvHost.__env__ ?? {} };
30
32
  nitroApp.hooks.hook("close", () => runtime.dispose());
31
33
  });
@@ -1,7 +1,7 @@
1
1
  import type { BatchProgress, DurableBatchStore, SettleBatchMemberOptions, SettleBatchMemberResult } from './batch.js';
2
- import type { D1DatabaseLike, D1DurableJobRepository } from './d1.js';
2
+ import type { D1DatabaseLike, D1DurableJobRecord, D1DurableJobRepository } from './d1.js';
3
3
  import type { JobMetricsSink } from './metrics.js';
4
- import type { CreateJobBatchOptions, CreateJobBatchResult, DurableJobRecord, EnqueueDurableJobResult, PruneDurableJobsOptions, PruneDurableJobsResult, QueuePublisher, RunDurableJobMessageOptions, RunDurableJobMessageResult } from './outbox.js';
4
+ import type { CreateJobBatchOptions, CreateJobBatchResult, DurableJobContinuationStage, DurableJobRecord, DurableJobScope, EnqueueDurableJobResult, PruneDurableJobsOptions, PruneDurableJobsResult, QueuePublisher, RunDurableJobMessageOptions, RunDurableJobMessageResult } from './outbox.js';
5
5
  import type { DispatchableJob, JobDefinition, JobHandler, QueueBatch, QueueMessage } from './types.js';
6
6
  export interface RunDurableJobBatchMessageOptions<StoredJob, Job extends DispatchableJob, Message extends {
7
7
  jobId: string;
@@ -70,6 +70,25 @@ export interface ConsumeQueueBatchOptions<Queue extends string, Env, Db, Logger>
70
70
  jobId?: string;
71
71
  error?: string;
72
72
  }) => void;
73
+ createJobScope?: (storedJob: D1DurableJobRecord<Queue>) => DurableJobScope<D1DurableJobRecord<Queue>>;
74
+ isPermanentFailure?: (input: {
75
+ error: unknown;
76
+ storedJob: D1DurableJobRecord<Queue>;
77
+ attempts: number;
78
+ maxAttempts: number | undefined;
79
+ }) => boolean;
80
+ dispatchContinuations?: (input: {
81
+ storedJob: D1DurableJobRecord<Queue>;
82
+ stage: DurableJobContinuationStage;
83
+ }) => void | Promise<void>;
84
+ /**
85
+ * Soft batch CPU budget (ms). Before each message, if the batch has run longer
86
+ * than this, the remaining messages are retried instead of processed — so a
87
+ * heavy batch can't blow the Worker CPU limit mid-message.
88
+ */
89
+ maxBatchCpuMs?: number;
90
+ /** Delay (s) for messages deferred by the CPU guard. Default 5. */
91
+ cpuGuardRetryDelaySeconds?: number;
73
92
  }
74
93
  /**
75
94
  * Process one CF queue batch end-to-end — the loop both apps hand-roll:
@@ -109,6 +128,8 @@ export interface CreateDurableJobsRuntimeOptions<Queue extends string = string,
109
128
  metricsSink?: JobMetricsSink;
110
129
  /** Notified after each batch settle. */
111
130
  onBatchProgress?: (progress: BatchProgress) => void | Promise<void>;
131
+ /** Override how a batch's onFinish continuation runs (default: durable enqueue). */
132
+ dispatchOnFinish?: SettleBatchMemberOptions['dispatchOnFinish'];
112
133
  /** Per-throw retry backoff for the consumer. */
113
134
  retryDelaySeconds?: RunDurableJobMessageOptions<unknown, DispatchableJob>['retryDelaySeconds'];
114
135
  onMissingBinding?: (queue: Queue, count: number) => void | Promise<void>;
@@ -128,6 +149,24 @@ export interface CreateDurableJobsRuntimeOptions<Queue extends string = string,
128
149
  jobId?: string;
129
150
  error?: string;
130
151
  }) => void;
152
+ /** Per-job scope: wrap dispatch (e.g. telemetry) + observe each settlement. */
153
+ createJobScope?: (storedJob: D1DurableJobRecord<Queue>) => DurableJobScope<D1DurableJobRecord<Queue>>;
154
+ /** Decide whether a thrown error is terminal (default: attempts >= max). */
155
+ isPermanentFailure?: (input: {
156
+ error: unknown;
157
+ storedJob: D1DurableJobRecord<Queue>;
158
+ attempts: number;
159
+ maxAttempts: number | undefined;
160
+ }) => boolean;
161
+ /** Dispatch then/catch/finally payload continuations after a terminal outcome. */
162
+ dispatchContinuations?: (input: {
163
+ storedJob: D1DurableJobRecord<Queue>;
164
+ stage: DurableJobContinuationStage;
165
+ }) => void | Promise<void>;
166
+ /** Soft per-batch CPU budget (ms); remaining messages are deferred once exceeded. */
167
+ maxBatchCpuMs?: number;
168
+ /** Delay (s) for CPU-guard-deferred messages. Default 5. */
169
+ cpuGuardRetryDelaySeconds?: number;
131
170
  }
132
171
  export interface DurableJobsRuntime<Queue extends string = string> {
133
172
  repository: D1DurableJobRepository<Queue>;
@@ -93,7 +93,13 @@ export async function consumeQueueBatch(opts) {
93
93
  }
94
94
  return;
95
95
  }
96
+ const batchStartedMs = Date.now();
97
+ const cpuBudget = opts.maxBatchCpuMs;
96
98
  for (const message of opts.batch.messages) {
99
+ if (cpuBudget != null && Date.now() - batchStartedMs > cpuBudget) {
100
+ message.retry({ delaySeconds: opts.cpuGuardRetryDelaySeconds ?? 5 });
101
+ continue;
102
+ }
97
103
  const body = message.body ?? {};
98
104
  const jobId = typeof body.jobId === "string" ? body.jobId : void 0;
99
105
  if (jobId) {
@@ -107,6 +113,9 @@ export async function consumeQueueBatch(opts) {
107
113
  retryDelaySeconds: opts.retryDelaySeconds,
108
114
  // Honour the stored job's attempt cap (Laravel worker model).
109
115
  maxAttemptsOf: (stored) => stored.max_attempts,
116
+ createJobScope: opts.createJobScope,
117
+ isPermanentFailure: opts.isPermanentFailure,
118
+ dispatchContinuations: opts.dispatchContinuations,
110
119
  dispatchOnFinish: opts.dispatchOnFinish,
111
120
  onBatchProgress: opts.onBatchProgress
112
121
  });
@@ -150,7 +159,7 @@ export function createDurableJobsRuntime(opts) {
150
159
  opts.resolveQueueBinding,
151
160
  { onMissingBinding: opts.onMissingBinding }
152
161
  );
153
- const dispatchOnFinish = async ({ continuation, batch }) => {
162
+ const dispatchOnFinish = opts.dispatchOnFinish ?? (async ({ continuation, batch }) => {
154
163
  const c = continuation;
155
164
  try {
156
165
  const record = await prepareDurableJob({ name: c.name, payload: c.payload, registry: opts.registry });
@@ -158,7 +167,7 @@ export function createDurableJobsRuntime(opts) {
158
167
  } catch (error) {
159
168
  opts.onLog?.({ stage: "onfinish-failed", taskName: c.name, jobId: batch.id, error: error instanceof Error ? error.message : String(error) });
160
169
  }
161
- };
170
+ });
162
171
  const isDuplicate = makeIsDuplicate(opts.dedup);
163
172
  return {
164
173
  repository,
@@ -180,6 +189,9 @@ export function createDurableJobsRuntime(opts) {
180
189
  createJobContext: opts.createJobContext,
181
190
  retryDelaySeconds: opts.retryDelaySeconds,
182
191
  maxAttemptsOf: (stored) => stored.max_attempts,
192
+ createJobScope: opts.createJobScope,
193
+ isPermanentFailure: opts.isPermanentFailure,
194
+ dispatchContinuations: opts.dispatchContinuations,
183
195
  dispatchOnFinish,
184
196
  onBatchProgress: opts.onBatchProgress
185
197
  }),
@@ -194,7 +206,12 @@ export function createDurableJobsRuntime(opts) {
194
206
  retryDelaySeconds: opts.retryDelaySeconds,
195
207
  isDlqQueue: opts.isDlqQueue,
196
208
  isDuplicate,
197
- onLog: opts.onLog
209
+ onLog: opts.onLog,
210
+ createJobScope: opts.createJobScope,
211
+ isPermanentFailure: opts.isPermanentFailure,
212
+ dispatchContinuations: opts.dispatchContinuations,
213
+ maxBatchCpuMs: opts.maxBatchCpuMs,
214
+ cpuGuardRetryDelaySeconds: opts.cpuGuardRetryDelaySeconds
198
215
  }),
199
216
  prune: (pruneOpts) => pruneDurableJobs(repository, pruneOpts)
200
217
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-cf-jobs",
3
3
  "type": "module",
4
- "version": "0.6.0",
4
+ "version": "0.6.2",
5
5
  "description": "Nuxt module for typed Cloudflare queue jobs.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",