@gobing-ai/ts-infra 0.4.58 → 0.4.60

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.
Files changed (43) hide show
  1. package/README.md +14 -4
  2. package/dist/application/index.d.ts.map +1 -1
  3. package/dist/application/index.js +3 -0
  4. package/dist/application/types.d.ts +11 -2
  5. package/dist/application/types.d.ts.map +1 -1
  6. package/dist/application-node.d.ts.map +1 -1
  7. package/dist/application-node.js +34 -4
  8. package/dist/execution-policy.d.ts +76 -0
  9. package/dist/execution-policy.d.ts.map +1 -0
  10. package/dist/execution-policy.js +123 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/job-queue/db-job-queue.d.ts +14 -1
  15. package/dist/job-queue/db-job-queue.d.ts.map +1 -1
  16. package/dist/job-queue/db-job-queue.js +165 -26
  17. package/dist/job-queue/types.d.ts +47 -7
  18. package/dist/job-queue/types.d.ts.map +1 -1
  19. package/dist/scheduler/cloudflare.d.ts +4 -0
  20. package/dist/scheduler/cloudflare.d.ts.map +1 -1
  21. package/dist/scheduler/cloudflare.js +2 -1
  22. package/dist/scheduler/index.d.ts +1 -1
  23. package/dist/scheduler/index.d.ts.map +1 -1
  24. package/dist/scheduler/node.d.ts +10 -1
  25. package/dist/scheduler/node.d.ts.map +1 -1
  26. package/dist/scheduler/node.js +28 -6
  27. package/dist/scheduler/types.d.ts +20 -3
  28. package/dist/scheduler/types.d.ts.map +1 -1
  29. package/dist/scheduler/wrap-handler.d.ts.map +1 -1
  30. package/dist/scheduler/wrap-handler.js +2 -2
  31. package/package.json +6 -6
  32. package/src/application/index.ts +3 -0
  33. package/src/application/types.ts +16 -3
  34. package/src/application-node.ts +40 -4
  35. package/src/execution-policy.ts +168 -0
  36. package/src/index.ts +3 -1
  37. package/src/job-queue/db-job-queue.ts +184 -25
  38. package/src/job-queue/types.ts +47 -7
  39. package/src/scheduler/cloudflare.ts +3 -1
  40. package/src/scheduler/index.ts +6 -1
  41. package/src/scheduler/node.ts +39 -6
  42. package/src/scheduler/types.ts +34 -5
  43. package/src/scheduler/wrap-handler.ts +3 -2
@@ -1,3 +1,4 @@
1
+ import { resolveExecutionTimeoutMs, runWithExecutionDeadline } from '../execution-policy.js';
1
2
  import { settleWithin } from '../internals/drain.js';
2
3
  import { getLogger } from '../logger.js';
3
4
  import { getQueueJobCompletedTotal, getQueueJobEnqueuedTotal, getQueueJobFailedTotal, getQueueJobProcessingDuration, } from '../telemetry/metrics.js';
@@ -51,7 +52,7 @@ function enqueuedDetail(jobId, type, options) {
51
52
  detail.ttlMs = options.ttlMs;
52
53
  return detail;
53
54
  }
54
- /** DB-backed queue consumer with polling, retry, and visibility-timeout handling. */
55
+ /** DB-backed queue consumer with polling, retry, lease ownership, and execution-deadline handling. */
55
56
  export class DBQueueConsumer {
56
57
  dao;
57
58
  handlers = new Map();
@@ -62,6 +63,11 @@ export class DBQueueConsumer {
62
63
  baseDelay;
63
64
  maxDelay;
64
65
  drainTimeoutMs;
66
+ drainPolicy;
67
+ /** Default execution policy for jobs without their own persisted decision; `null` = unlimited. */
68
+ defaultTimeoutMs;
69
+ /** Live attempts owned by this consumer, keyed by job id — the target of `cancel()`. */
70
+ activeAttempts = new Map();
65
71
  /**
66
72
  * Validated event sink: the bus plus the non-empty queue name its lifecycle rows
67
73
  * require (ADR-068). `undefined` for silent consumers — no `events`, no identity.
@@ -80,6 +86,15 @@ export class DBQueueConsumer {
80
86
  this.baseDelay = nonNegativeFiniteConfig('baseDelay', config.baseDelay ?? 1_000);
81
87
  this.maxDelay = nonNegativeFiniteConfig('maxDelay', config.maxDelay ?? 60_000);
82
88
  this.drainTimeoutMs = nonNegativeFiniteConfig('drainTimeoutMs', config.drainTimeoutMs ?? 30_000);
89
+ this.drainPolicy = config.drainPolicy ?? 'bounded';
90
+ if (config.defaultTimeoutMs !== undefined &&
91
+ config.defaultTimeoutMs !== null &&
92
+ (typeof config.defaultTimeoutMs !== 'number' ||
93
+ !Number.isInteger(config.defaultTimeoutMs) ||
94
+ config.defaultTimeoutMs <= 0)) {
95
+ throw new RangeError(`Queue consumer defaultTimeoutMs must be a positive integer or null; received ${String(config.defaultTimeoutMs)}`);
96
+ }
97
+ this.defaultTimeoutMs = config.defaultTimeoutMs ?? null;
83
98
  this.eventSink = resolveEventSink(config);
84
99
  }
85
100
  register(type, handler) {
@@ -110,6 +125,7 @@ export class DBQueueConsumer {
110
125
  clearTimeout(this.timer);
111
126
  this.timer = null;
112
127
  }
128
+ const drainToCompletion = this.drainPolicy === 'drain-to-completion';
113
129
  const deadline = Date.now() + this.drainTimeoutMs;
114
130
  // Wait for an already-running poll cycle before consulting `inFlight`.
115
131
  // `inFlight` is only incremented once claimReady() has resolved, so a stop()
@@ -118,10 +134,26 @@ export class DBQueueConsumer {
118
134
  // to claim and process jobs after stop() resolved.
119
135
  const pending = this.pollPromise;
120
136
  if (pending !== null) {
121
- await settleWithin(pending, deadline);
137
+ if (drainToCompletion) {
138
+ await pending;
139
+ }
140
+ else {
141
+ await settleWithin(pending, deadline);
142
+ }
122
143
  }
123
- while (this.inFlight > 0 && Date.now() < deadline) {
124
- await sleep(10);
144
+ if (drainToCompletion) {
145
+ // Shutdown policy (A21): wait for every in-flight attempt to settle,
146
+ // however long that takes. Leases keep renewing while we wait.
147
+ while (this.inFlight > 0) {
148
+ await sleep(10);
149
+ }
150
+ }
151
+ else {
152
+ while (this.inFlight > 0 && Date.now() < deadline) {
153
+ await sleep(10);
154
+ }
155
+ // Bounded drain expiry (A21): attempts still running keep ownership —
156
+ // their leases keep renewing and they are acknowledged when they settle.
125
157
  }
126
158
  if (wasRunning) {
127
159
  const drained = this.inFlight === 0;
@@ -138,6 +170,14 @@ export class DBQueueConsumer {
138
170
  }
139
171
  }
140
172
  }
173
+ async cancel(jobId) {
174
+ const attempt = this.activeAttempts.get(jobId);
175
+ if (attempt === undefined)
176
+ return false;
177
+ attempt.state.cancelled = true;
178
+ attempt.controller.abort();
179
+ return true;
180
+ }
141
181
  async stats() {
142
182
  return this.dao.getStats();
143
183
  }
@@ -146,7 +186,7 @@ export class DBQueueConsumer {
146
186
  return traceAsync('queue.poll', async () => {
147
187
  await this.dao.resetStuckJobs(this.visibilityTimeout);
148
188
  await this.dao.failExpiredJobs();
149
- const jobs = await this.dao.claimReady(this.batchSize);
189
+ const jobs = await this.dao.claimReady(this.batchSize, { leaseMs: this.visibilityTimeout });
150
190
  let processed = 0;
151
191
  for (let index = 0; index < jobs.length; index += this.maxConcurrency) {
152
192
  const batch = jobs.slice(index, index + this.maxConcurrency);
@@ -203,7 +243,7 @@ export class DBQueueConsumer {
203
243
  catch (error) {
204
244
  // Corrupt payload — the job can never parse; route it through the
205
245
  // retry/fail path instead of rejecting the whole batch.
206
- await this.failOrRetry(record, error, 0);
246
+ await this.failOrRetry(record, error, 0, record.attemptToken ?? undefined);
207
247
  return;
208
248
  }
209
249
  return traceAsync('queue.job.process', async () => {
@@ -213,38 +253,135 @@ export class DBQueueConsumer {
213
253
  'queue.job_attempt': job.attempts,
214
254
  });
215
255
  const handler = this.handlers.get(job.type);
256
+ const token = record.attemptToken ?? undefined;
216
257
  if (handler === undefined) {
217
- await this.failOrRetry(job, new Error(`No handler registered for job type "${job.type}"`), 0);
258
+ await this.failOrRetry(job, new Error(`No handler registered for job type "${job.type}"`), 0, token);
218
259
  return;
219
260
  }
220
- const startMs = performance.now();
261
+ // Per-attempt ownership (A21): the fresh claim token fences every
262
+ // acknowledgement, and the external controller carries lease-loss and
263
+ // manual cancellation into the shared deadline clock.
264
+ let attemptWake;
265
+ const attempt = { lost: false, cancelled: false, settled: false };
266
+ const attemptAbort = new AbortController();
267
+ if (token !== undefined) {
268
+ const { promise: settledSignal, resolve: resolveSettled } = Promise.withResolvers();
269
+ attempt.settledSignal = settledSignal;
270
+ attemptWake = resolveSettled;
271
+ // Shares the `attempt` object reference so cancel() mutations are
272
+ // visible in the outcome dispatch below.
273
+ this.activeAttempts.set(record.id, { controller: attemptAbort, state: attempt });
274
+ }
275
+ const renewal = token === undefined
276
+ ? undefined
277
+ : this.renewUntilSettled(record.id, token, attempt, attemptAbort).catch(() => { });
221
278
  try {
222
- await handler(job);
223
- await this.dao.markCompleted(job.id);
279
+ const resolvedTimeout = resolveExecutionTimeoutMs(`job "${job.type}"`, record.timeoutUnlimited === 1 ? null : (record.timeoutMs ?? undefined), this.defaultTimeoutMs);
280
+ const startMs = performance.now();
281
+ const outcome = await runWithExecutionDeadline((context) => handler(job, context), {
282
+ timeoutMs: resolvedTimeout,
283
+ signal: attemptAbort.signal,
284
+ });
285
+ attempt.settled = true;
286
+ attemptWake?.();
224
287
  const durationMs = performance.now() - startMs;
225
- getQueueJobCompletedTotal().add(1, { type: job.type });
226
- getQueueJobProcessingDuration().record(durationMs, { type: job.type });
227
- const completed = {
228
- jobId: job.id,
288
+ getQueueJobProcessingDuration().record(Number.isFinite(durationMs) ? durationMs : 0, {
229
289
  type: job.type,
230
- durationMs: Number.isFinite(durationMs) ? durationMs : 0,
231
- attempt: job.attempts,
232
- severity: 'info',
233
- };
234
- await this.eventSink?.bus.emit('queue.job.completed', completed);
290
+ });
291
+ if (attempt.lost) {
292
+ // Ownership was lost: the row now belongs to a replacement attempt
293
+ // claimed through the expired lease. Fence every acknowledgement —
294
+ // a stale ack must never mutate the replacement's work (R4).
295
+ queueLogger().warn('queue attempt lost lease ownership; acknowledgement fenced', {
296
+ jobId: record.id,
297
+ type: job.type,
298
+ outcome: outcome.outcome,
299
+ });
300
+ return;
301
+ }
302
+ if (outcome.outcome === 'error') {
303
+ await this.failOrRetry(job, outcome.error, Number.isFinite(durationMs) ? durationMs : 0, token);
304
+ }
305
+ else if (outcome.timedOut) {
306
+ // Deadline expiry is a failure like any other, reported only after
307
+ // the handler has settled — even if it ignored the abort.
308
+ await this.failOrRetry(job, outcome.error, Number.isFinite(durationMs) ? durationMs : 0, token);
309
+ }
310
+ else if (attempt.cancelled) {
311
+ // Manual cancellation is terminal: settle, then fail without retry.
312
+ const message = 'job cancelled';
313
+ const applied = await this.dao.markFailed(record.id, job.attempts + 1, message, token);
314
+ if (applied) {
315
+ getQueueJobFailedTotal().add(1, { type: job.type });
316
+ await this.eventSink?.bus.emit('queue.job.failed', {
317
+ jobId: job.id,
318
+ type: job.type,
319
+ error: message,
320
+ attempt: job.attempts + 1,
321
+ maxRetries: job.maxRetries,
322
+ durationMs: Number.isFinite(durationMs) ? durationMs : 0,
323
+ severity: 'warning',
324
+ });
325
+ }
326
+ }
327
+ else {
328
+ await this.dao.markCompleted(record.id, token);
329
+ getQueueJobCompletedTotal().add(1, { type: job.type });
330
+ const completed = {
331
+ jobId: job.id,
332
+ type: job.type,
333
+ durationMs: Number.isFinite(durationMs) ? durationMs : 0,
334
+ attempt: job.attempts,
335
+ severity: 'info',
336
+ };
337
+ await this.eventSink?.bus.emit('queue.job.completed', completed);
338
+ }
235
339
  }
236
- catch (error) {
237
- const durationMs = performance.now() - startMs;
238
- getQueueJobProcessingDuration().record(durationMs, { type: job.type });
239
- await this.failOrRetry(job, error, Number.isFinite(durationMs) ? durationMs : 0);
340
+ finally {
341
+ attempt.settled = true;
342
+ attemptWake?.();
343
+ if (renewal !== undefined)
344
+ await renewal;
345
+ if (token !== undefined)
346
+ this.activeAttempts.delete(record.id);
240
347
  }
241
348
  });
242
349
  }
243
- async failOrRetry(job, error, durationMs) {
350
+ /**
351
+ * Extend the attempt's lease on an interval derived from the visibility
352
+ * timeout, proving this consumer is still alive and working. A failed or
353
+ * erroring renewal means ownership was lost: abort the attempt so the
354
+ * handler can stop, and mark the attempt fenced.
355
+ */
356
+ async renewUntilSettled(id, token, attempt, attemptAbort) {
357
+ const intervalMs = Math.max(1, Math.floor(this.visibilityTimeout / 3));
358
+ while (!attempt.settled) {
359
+ // Settle-aware wait: wakes early when the attempt finishes so a long
360
+ // visibility timeout never delays acknowledgement past the handler.
361
+ await Promise.race([sleep(intervalMs), attempt.settledSignal]);
362
+ if (attempt.settled)
363
+ return;
364
+ let renewed = false;
365
+ try {
366
+ renewed = await this.dao.renewLease(id, token, this.visibilityTimeout);
367
+ }
368
+ catch {
369
+ renewed = false; // DB unavailability is treated like ownership loss
370
+ }
371
+ if (attempt.settled)
372
+ return;
373
+ if (!renewed) {
374
+ attempt.lost = true;
375
+ attemptAbort.abort();
376
+ return;
377
+ }
378
+ }
379
+ }
380
+ async failOrRetry(job, error, durationMs, attemptToken) {
244
381
  const attempts = job.attempts + 1;
245
382
  const message = error instanceof Error ? error.message : String(error);
246
383
  if (attempts >= job.maxRetries) {
247
- await this.dao.markFailed(job.id, attempts, message);
384
+ await this.dao.markFailed(job.id, attempts, message, attemptToken);
248
385
  getQueueJobFailedTotal().add(1, { type: job.type });
249
386
  const failed = {
250
387
  jobId: job.id,
@@ -260,7 +397,7 @@ export class DBQueueConsumer {
260
397
  }
261
398
  const delay = Math.min(this.maxDelay, this.baseDelay * 2 ** Math.max(0, attempts - 1));
262
399
  const nextRetryAt = Date.now() + delay;
263
- await this.dao.markForRetry(job.id, attempts, message, nextRetryAt);
400
+ await this.dao.markForRetry(job.id, attempts, message, nextRetryAt, attemptToken);
264
401
  const retrying = {
265
402
  jobId: job.id,
266
403
  type: job.type,
@@ -286,6 +423,8 @@ function toJob(record) {
286
423
  nextRetryAt: record.nextRetryAt,
287
424
  lastError: record.lastError,
288
425
  processingAt: record.processingAt,
426
+ // Persisted job policy: explicit unlimited stays `null`, absence stays undefined.
427
+ timeoutMs: record.timeoutUnlimited === 1 ? null : (record.timeoutMs ?? undefined),
289
428
  };
290
429
  }
291
430
  function sleep(ms) {
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import type { EventBus } from '../event-bus/event-bus';
8
8
  import type { QueueEvents } from '../events';
9
- /** A queued job with status tracking, retry metadata, and timestamps. */
9
+ import type { ExecutionContext } from '../execution-policy';
10
+ /** A queued job with status tracking, retry metadata, timestamps, and its persisted execution policy. */
10
11
  export interface Job<T = unknown> {
11
12
  id: string;
12
13
  type: string;
@@ -20,13 +21,26 @@ export interface Job<T = unknown> {
20
21
  nextRetryAt: number | null;
21
22
  lastError: string | null;
22
23
  processingAt: number | null;
24
+ /**
25
+ * Job-level execution policy as persisted: a positive integer ms deadline,
26
+ * explicit `null` = unlimited, or `undefined` = no job-level decision (the
27
+ * consumer default applies). Never the resolved per-attempt value.
28
+ */
29
+ timeoutMs?: number | null;
23
30
  }
24
- /** Options for enqueuing a job: retry policy, delay, and TTL. */
31
+ /** Options for enqueuing a job: retry policy, delay, TTL, and execution policy. */
25
32
  export interface EnqueueOptions {
26
33
  /** Total attempts allowed (default 3) — `maxRetries: 1` runs the job once with no retry. */
27
34
  maxRetries?: number;
28
35
  delay?: number;
29
36
  ttlMs?: number;
37
+ /**
38
+ * Job execution policy persisted with the row: a positive integer ms
39
+ * deadline, explicit `null` = unlimited (disables this job's deadline),
40
+ * omitted = no job-level decision (consumers apply their own default).
41
+ * Invalid explicit values are rejected before the row is created.
42
+ */
43
+ timeoutMs?: number | null;
30
44
  }
31
45
  /** Producer interface for the job queue — enqueue single jobs or batches. */
32
46
  export interface JobQueue<T = unknown> {
@@ -37,8 +51,12 @@ export interface JobQueue<T = unknown> {
37
51
  } & EnqueueOptions>): Promise<string[]>;
38
52
  stats(): Promise<QueueStats>;
39
53
  }
40
- /** Async handler that processes a single job. */
41
- export type JobHandler<T = unknown> = (job: Job<T>) => Promise<void>;
54
+ /**
55
+ * Async handler that processes a single job. The context carries the shared
56
+ * execution deadline clock for this attempt; single-argument handlers stay
57
+ * assignable.
58
+ */
59
+ export type JobHandler<T = unknown> = (job: Job<T>, context: ExecutionContext) => Promise<void>;
42
60
  /** Aggregate statistics for a job queue: counts by status. */
43
61
  export interface QueueStats {
44
62
  pending: number;
@@ -46,7 +64,7 @@ export interface QueueStats {
46
64
  completed: number;
47
65
  failed: number;
48
66
  }
49
- /** Configuration for a queue consumer: polling, concurrency, and backoff. */
67
+ /** Configuration for a queue consumer: polling, concurrency, backoff, and the default execution policy. */
50
68
  export interface QueueConsumerConfig {
51
69
  pollInterval?: number;
52
70
  batchSize?: number;
@@ -56,6 +74,20 @@ export interface QueueConsumerConfig {
56
74
  maxDelay?: number;
57
75
  /** Upper bound (ms, default 30_000) on how long `stop()` waits for in-flight work to drain. */
58
76
  drainTimeoutMs?: number;
77
+ /**
78
+ * Shutdown drain policy (A21). `'bounded'` (default): `stop()` gives up after
79
+ * `drainTimeoutMs`; attempts still running keep their leases, keep renewing, and
80
+ * are acknowledged when they settle. `'drain-to-completion'`: `stop()` waits for
81
+ * all in-flight attempts to settle, however long that takes.
82
+ */
83
+ drainPolicy?: 'bounded' | 'drain-to-completion';
84
+ /**
85
+ * Default execution policy for jobs without their own persisted decision
86
+ * (A21): a positive integer ms deadline or explicit `null` = unlimited.
87
+ * First-match-wins: an explicit job option — including job `null` — beats
88
+ * this default; absence inherits it.
89
+ */
90
+ defaultTimeoutMs?: number | null;
59
91
  /**
60
92
  * Identity of the queue this consumer polls. Runtime-required whenever `events` is
61
93
  * configured (ADR-068): an observable consumer must never emit an anonymous lifecycle
@@ -76,10 +108,18 @@ export interface QueueConsumerConfig {
76
108
  export interface QueueConsumer<T = unknown> {
77
109
  register(type: string, handler: JobHandler<T>): void;
78
110
  start(): Promise<void>;
111
+ /**
112
+ * Request cooperative cancellation of a claimed attempt (A21). The context
113
+ * signal aborts with reason `'cancelled'`; the attempt is allowed to settle
114
+ * and is then failed without retry. Resolves `false` when the job is not
115
+ * currently owned by this consumer.
116
+ */
117
+ cancel(jobId: string): Promise<boolean>;
79
118
  /**
80
119
  * Stop polling and drain work already in flight, including a poll cycle that has
81
- * claimed nothing yet. Resolves once the drain completes or `drainTimeoutMs`
82
- * elapses — whichever comes first, so a hung handler cannot block shutdown.
120
+ * claimed nothing yet. Under the default `'bounded'` policy this resolves once the
121
+ * drain completes or `drainTimeoutMs` elapses; `'drain-to-completion'` ignores the
122
+ * bound and waits for every in-flight attempt.
83
123
  */
84
124
  stop(): Promise<void>;
85
125
  stats(): Promise<QueueStats>;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/job-queue/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAE7C,yEAAyE;AACzE,MAAM,WAAW,GAAG,CAAC,CAAC,GAAG,OAAO;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,CAAC,CAAC;IACX,MAAM,EAAE,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,QAAQ,CAAC;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,yGAAyG;IACzG,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAED,iEAAiE;AACjE,MAAM,WAAW,cAAc;IAC3B,4FAA4F;IAC5F,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,6EAA6E;AAC7E,MAAM,WAAW,QAAQ,CAAC,CAAC,GAAG,OAAO;IACjC,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7E,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,CAAC,CAAA;KAAE,GAAG,cAAc,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC5F,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;CAChC;AAED,iDAAiD;AACjD,MAAM,MAAM,UAAU,CAAC,CAAC,GAAG,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAErE,8DAA8D;AAC9D,MAAM,WAAW,UAAU;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAClB;AAED,6EAA6E;AAC7E,MAAM,WAAW,mBAAmB;IAChC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+FAA+F;IAC/F,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;CAClC;AAED,gGAAgG;AAChG,MAAM,WAAW,aAAa,CAAC,CAAC,GAAG,OAAO;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IACrD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB;;;;OAIG;IACH,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;CAChC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/job-queue/types.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Cloudflare Workers scheduler adapter using Cron Triggers.
3
+ * Uses minimal local type declarations — no @cloudflare/workers-types dependency.
4
+ */
1
5
  import type { ScheduledAction, SchedulerAdapter } from './types';
2
6
  interface CfScheduledEvent {
3
7
  cron: string;
@@ -1 +1 @@
1
- {"version":3,"file":"cloudflare.d.ts","sourceRoot":"","sources":["../../src/scheduler/cloudflare.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEjE,UAAU,gBAAgB;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC;CAC9C;AAED,UAAU,cAAc;IACpB,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC;CAC9C;AAED;;;;;GAKG;AACH,qBAAa,0BAA2B,YAAW,gBAAgB;IAC/D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsC;;IAI9D,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI;IAI/C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAK5B;;;;;;OAMG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B;;;OAGG;IACH,oBAAoB,CAAC,KAAK,EAAE,gBAAgB,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI;CAmB3E"}
1
+ {"version":3,"file":"cloudflare.d.ts","sourceRoot":"","sources":["../../src/scheduler/cloudflare.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAQH,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEjE,UAAU,gBAAgB;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC;CAC9C;AAED,UAAU,cAAc;IACpB,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC;CAC9C;AAED;;;;;GAKG;AACH,qBAAa,0BAA2B,YAAW,gBAAgB;IAC/D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsC;;IAI9D,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI;IAI/C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAK5B;;;;;;OAMG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B;;;OAGG;IACH,oBAAoB,CAAC,KAAK,EAAE,gBAAgB,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI;CAmB3E"}
@@ -2,6 +2,7 @@
2
2
  * Cloudflare Workers scheduler adapter using Cron Triggers.
3
3
  * Uses minimal local type declarations — no @cloudflare/workers-types dependency.
4
4
  */
5
+ import { unlimitedExecutionContext } from '../execution-policy.js';
5
6
  import { getSchedulerJobDuration, getSchedulerJobExecutedTotal, getSchedulerJobFailedTotal, } from '../telemetry/metrics.js';
6
7
  /**
7
8
  * Scheduler adapter for Cloudflare Workers Cron Triggers.
@@ -38,7 +39,7 @@ export class CloudflareSchedulerAdapter {
38
39
  if (action) {
39
40
  const startMs = performance.now();
40
41
  getSchedulerJobExecutedTotal().add(1, { cron: event.cron });
41
- ctx.waitUntil(action()
42
+ ctx.waitUntil(action(unlimitedExecutionContext())
42
43
  .catch((error) => {
43
44
  getSchedulerJobFailedTotal().add(1, { cron: event.cron });
44
45
  throw error;
@@ -1,6 +1,6 @@
1
1
  export { ActionRegistry, type CreateDefaultRegistryOptions, createDefaultRegistry, HealthPingAction, type HealthPingWriter, LogAction, QueueStatsAction, type QueueStatsDaoProvider, type SchedulerAction, toScheduledAction, } from './action';
2
2
  export { initScheduler } from './factory';
3
3
  export { NoopSchedulerAdapter } from './noop';
4
- export type { ScheduledAction, SchedulerAdapter, SchedulerJobConfig } from './types';
4
+ export type { ScheduledAction, ScheduledActionOptions, SchedulerAdapter, SchedulerJobConfig, } from './types';
5
5
  export { wrapScheduledHandler } from './wrap-handler';
6
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scheduler/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,cAAc,EACd,KAAK,4BAA4B,EACjC,qBAAqB,EACrB,gBAAgB,EAChB,KAAK,gBAAgB,EACrB,SAAS,EACT,gBAAgB,EAChB,KAAK,qBAAqB,EAC1B,KAAK,eAAe,EACpB,iBAAiB,GACpB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,oBAAoB,EAAE,MAAM,QAAQ,CAAC;AAC9C,YAAY,EAAE,eAAe,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AACrF,OAAO,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scheduler/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,cAAc,EACd,KAAK,4BAA4B,EACjC,qBAAqB,EACrB,gBAAgB,EAChB,KAAK,gBAAgB,EACrB,SAAS,EACT,gBAAgB,EAChB,KAAK,qBAAqB,EAC1B,KAAK,eAAe,EACpB,iBAAiB,GACpB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,oBAAoB,EAAE,MAAM,QAAQ,CAAC;AAC9C,YAAY,EACR,eAAe,EACf,sBAAsB,EACtB,gBAAgB,EAChB,kBAAkB,GACrB,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC"}
@@ -8,6 +8,12 @@
8
8
  import type { ScheduledAction, SchedulerAdapter } from './types';
9
9
  /** Constructor options for {@link NodeSchedulerAdapter}. */
10
10
  export interface NodeSchedulerAdapterConfig {
11
+ /**
12
+ * Default execution policy (A21) for entries registered without their own
13
+ * `timeoutMs` option: a positive integer ms deadline or explicit `null` =
14
+ * unlimited. Invalid values throw at construction.
15
+ */
16
+ readonly timeoutMs?: number | null;
11
17
  /**
12
18
  * Upper bound (ms) on how long `stop()` waits for an in-flight tick to settle.
13
19
  * A hung action is abandoned at this deadline so it cannot block shutdown.
@@ -32,11 +38,14 @@ export interface NodeSchedulerAdapterConfig {
32
38
  export declare class NodeSchedulerAdapter implements SchedulerAdapter {
33
39
  private readonly entries;
34
40
  private readonly drainTimeoutMs;
41
+ private readonly defaultTimeoutMs;
35
42
  private readonly now;
36
43
  private running;
37
44
  private readonly inflight;
38
45
  constructor(config?: NodeSchedulerAdapterConfig);
39
- register(cron: string, action: ScheduledAction): void;
46
+ register(cron: string, action: ScheduledAction, options?: {
47
+ timeoutMs?: number | null;
48
+ }): void;
40
49
  private parseEntry;
41
50
  start(): Promise<void>;
42
51
  stop(): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../../src/scheduler/node.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AASH,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AA0DjE,4DAA4D;AAC5D,MAAM,WAAW,0BAA0B;IACvC;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,qBAAa,oBAAqB,YAAW,gBAAgB;IACzD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA4B;gBAEzC,MAAM,GAAE,0BAA+B;IAWnD,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI;IAWrD,OAAO,CAAC,UAAU;IAYZ,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAStB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAwB3B,OAAO,CAAC,UAAU;IAiBlB;;;;OAIG;IACH,OAAO,CAAC,OAAO;IAwBf;;;;;OAKG;YACW,QAAQ;YAsBR,gBAAgB;CAYjC"}
1
+ {"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../../src/scheduler/node.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAWH,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AA4DjE,4DAA4D;AAC5D,MAAM,WAAW,0BAA0B;IACvC;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,qBAAa,oBAAqB,YAAW,gBAAgB;IACzD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAsB;IACvD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA4B;gBAEzC,MAAM,GAAE,0BAA+B;IAYnD,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI;IAW9F,OAAO,CAAC,UAAU;IAoBZ,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAStB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAwB3B,OAAO,CAAC,UAAU;IAiBlB;;;;OAIG;IACH,OAAO,CAAC,OAAO;IAwBf;;;;;OAKG;YACW,QAAQ;YAsBR,gBAAgB;CAyBjC"}
@@ -5,7 +5,9 @@
5
5
  * No external cron library dependency — cron expressions are parsed by the
6
6
  * internal `scheduler/cron.ts` grammar shared with `application-node.ts`.
7
7
  */
8
+ import { resolveExecutionTimeoutMs, runWithExecutionDeadline } from '../execution-policy.js';
8
9
  import { settleWithin } from '../internals/drain.js';
10
+ import { getLogger } from '../logger.js';
9
11
  import { getSchedulerJobDuration, getSchedulerJobExecutedTotal, getSchedulerJobFailedTotal, } from '../telemetry/metrics.js';
10
12
  import { nextCronTime, parseCronExpression } from './cron.js';
11
13
  /** Platform timer maximum for `setTimeout` (2^31 - 1 ms). Longer delays are chunked. */
@@ -53,6 +55,7 @@ function parseInterval(cron) {
53
55
  export class NodeSchedulerAdapter {
54
56
  entries = [];
55
57
  drainTimeoutMs;
58
+ defaultTimeoutMs;
56
59
  now;
57
60
  running = false;
58
61
  inflight = new Set();
@@ -62,28 +65,33 @@ export class NodeSchedulerAdapter {
62
65
  throw new RangeError(`NodeSchedulerAdapter drainTimeoutMs must be a non-negative finite number; received ${drainTimeoutMs}`);
63
66
  }
64
67
  this.drainTimeoutMs = drainTimeoutMs ?? 30_000;
68
+ this.defaultTimeoutMs = resolveExecutionTimeoutMs('NodeSchedulerAdapter', config.timeoutMs ?? undefined);
65
69
  this.now = now ?? (() => Date.now());
66
70
  }
67
- register(cron, action) {
71
+ register(cron, action, options) {
68
72
  // Fail at registration time: an unsupported expression must never reach
69
73
  // start(), where it would otherwise create a silently-wrong interval
70
74
  // (task 0060 F7) or a never-firing cron (task 0734 R1).
71
- const entry = this.parseEntry(cron, action);
75
+ const entry = this.parseEntry(cron, action, options);
72
76
  this.entries.push(entry);
73
77
  if (this.running) {
74
78
  this.startEntry(entry);
75
79
  }
76
80
  }
77
- parseEntry(cron, action) {
81
+ parseEntry(cron, action, options) {
82
+ // Resolve the entry's execution policy now: an explicit option (including
83
+ // explicit null) wins, absence inherits the adapter default. Invalid
84
+ // options throw here rather than leaving a tick to discover it mid-flight.
85
+ const timeoutMs = resolveExecutionTimeoutMs(`scheduler entry "${cron}"`, options?.timeoutMs, this.defaultTimeoutMs);
78
86
  const intervalMs = parseInterval(cron);
79
87
  if (intervalMs !== undefined) {
80
- return { kind: 'interval', cron, action, intervalMs };
88
+ return { kind: 'interval', cron, action, intervalMs, timeoutMs };
81
89
  }
82
90
  // Real five-field cron. Validate and verify a next occurrence exists at
83
91
  // registration (bounded scan) so an unsatisfiable expression fails here.
84
92
  const expr = parseCronExpression(cron);
85
93
  const target = nextCronTime(expr, this.now()).getTime();
86
- return { kind: 'cron', cron, action, expr, target };
94
+ return { kind: 'cron', cron, action, expr, target, timeoutMs };
87
95
  }
88
96
  async start() {
89
97
  if (this.running)
@@ -187,7 +195,21 @@ export class NodeSchedulerAdapter {
187
195
  const startMs = performance.now();
188
196
  getSchedulerJobExecutedTotal().add(1, { cron: entry.cron });
189
197
  try {
190
- await entry.action();
198
+ // One shared deadline clock per tick (A21): expiry aborts the
199
+ // context and the action is always awaited to settlement. Timeout
200
+ // counts as a failure; the adapter keeps ticking.
201
+ const outcome = await runWithExecutionDeadline(entry.action, { timeoutMs: entry.timeoutMs });
202
+ if (outcome.timedOut) {
203
+ getSchedulerJobFailedTotal().add(1, { cron: entry.cron });
204
+ getLogger('scheduler').warn('scheduled tick exceeded its execution deadline', {
205
+ cron: entry.cron,
206
+ timeoutMs: entry.timeoutMs,
207
+ elapsedMs: Math.round(outcome.elapsedMs),
208
+ });
209
+ }
210
+ else if (outcome.outcome === 'error') {
211
+ getSchedulerJobFailedTotal().add(1, { cron: entry.cron });
212
+ }
191
213
  }
192
214
  catch {
193
215
  // Swallow — scheduler errors should not crash the process
@@ -1,8 +1,23 @@
1
1
  /**
2
2
  * Scheduler types and interface.
3
3
  */
4
- /** Signature for scheduled action handlers. */
5
- export type ScheduledAction = () => Promise<void>;
4
+ import type { ExecutionContext } from '../execution-policy';
5
+ /**
6
+ * Signature for scheduled action handlers. The context carries the shared
7
+ * execution-deadline clock for this tick (A21); zero-argument handlers stay
8
+ * assignable — notably the Cloudflare adapter, which invokes actions with an
9
+ * unlimited execution context (no wall-clock deadline on that runtime).
10
+ */
11
+ export type ScheduledAction = (context: ExecutionContext) => Promise<void>;
12
+ /** Per-entry execution policy options for {@link SchedulerAdapter.register}. */
13
+ export interface ScheduledActionOptions {
14
+ /**
15
+ * Execution policy for this entry's ticks: a positive integer ms deadline,
16
+ * explicit `null` = unlimited, omitted = inherit the adapter default.
17
+ * Invalid explicit values throw at registration time.
18
+ */
19
+ timeoutMs?: number | null;
20
+ }
6
21
  /**
7
22
  * Abstract scheduler interface — implementations for Node and Cloudflare.
8
23
  *
@@ -11,7 +26,7 @@ export type ScheduledAction = () => Promise<void>;
11
26
  * action is abandoned at the deadline rather than blocking shutdown forever.
12
27
  */
13
28
  export interface SchedulerAdapter {
14
- register(cron: string, action: ScheduledAction): void;
29
+ register(cron: string, action: ScheduledAction, options?: ScheduledActionOptions): void;
15
30
  start(): Promise<void>;
16
31
  stop(): Promise<void>;
17
32
  }
@@ -29,10 +44,12 @@ export type SchedulerJobConfig = {
29
44
  readonly command: string;
30
45
  readonly intervalMinutes: number;
31
46
  readonly cron?: never;
47
+ readonly timeoutMs?: number | null;
32
48
  } | {
33
49
  readonly name: string;
34
50
  readonly command: string;
35
51
  readonly cron: string;
36
52
  readonly intervalMinutes?: never;
53
+ readonly timeoutMs?: number | null;
37
54
  };
38
55
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/scheduler/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,+CAA+C;AAC/C,MAAM,MAAM,eAAe,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;AAElD;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAAC;IACtD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,kBAAkB,GACxB;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,KAAK,CAAA;CAAE,GAC5G;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,eAAe,CAAC,EAAE,KAAK,CAAA;CAAE,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/scheduler/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAE3E,gFAAgF;AAChF,MAAM,WAAW,sBAAsB;IACnC;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,CAAC,EAAE,sBAAsB,GAAG,IAAI,CAAC;IACxF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,kBAAkB,GACxB;IACI,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,IAAI,CAAC,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC,GACD;IACI,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,KAAK,CAAC;IACjC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"wrap-handler.d.ts","sourceRoot":"","sources":["../../src/scheduler/wrap-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAEjD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE/C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAChC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,eAAe,EACvB,SAAS,CAAC,EAAE,QAAQ,CAAC,eAAe,CAAC,GAAG,IAAI,GAC7C,eAAe,CA4BjB"}
1
+ {"version":3,"file":"wrap-handler.d.ts","sourceRoot":"","sources":["../../src/scheduler/wrap-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAGjD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE/C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAChC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,eAAe,EACvB,SAAS,CAAC,EAAE,QAAQ,CAAC,eAAe,CAAC,GAAG,IAAI,GAC7C,eAAe,CA4BjB"}
@@ -17,13 +17,13 @@ import { addSpanAttributes, addSpanEvent, traceAsync } from '../telemetry/tracin
17
17
  * @param systemBus - Optional bus to emit `scheduler.job.executed` on.
18
18
  */
19
19
  export function wrapScheduledHandler(name, action, systemBus) {
20
- return async () => {
20
+ return async (context) => {
21
21
  const startTime = performance.now();
22
22
  return traceAsync('scheduler.job', async (span) => {
23
23
  addSpanAttributes({ 'scheduler.job_name': name });
24
24
  let execError;
25
25
  try {
26
- await action();
26
+ await action(context);
27
27
  }
28
28
  catch (error) {
29
29
  execError = error instanceof Error ? error.message : String(error);