@gobing-ai/ts-infra 0.4.13 → 0.4.15

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.
@@ -1,12 +1,31 @@
1
+ /**
2
+ * Node.js scheduler adapter using a simple setInterval-based approach.
3
+ * No external cron library dependency — cron expressions are parsed minimally.
4
+ */
1
5
  import type { ScheduledAction, SchedulerAdapter } from './types';
6
+ /** Constructor options for {@link NodeSchedulerAdapter}. */
7
+ export interface NodeSchedulerAdapterConfig {
8
+ /**
9
+ * Upper bound (ms) on how long `stop()` waits for an in-flight tick to settle.
10
+ * A hung action is abandoned at this deadline so it cannot block shutdown.
11
+ * Defaults to 30000 (matching `DBQueueConsumer`).
12
+ */
13
+ readonly drainTimeoutMs?: number;
14
+ }
2
15
  /**
3
16
  * Scheduler adapter for Node.js using a setInterval-based approach.
4
17
  * No external cron library dependency — cron expressions are parsed minimally.
18
+ *
19
+ * `stop()` drains in-flight ticks, bounded by `drainTimeoutMs` (ADR-024): an
20
+ * action already running when `stop()` is called is awaited rather than torn
21
+ * down, which would leave a half-written row or a half-flushed batch.
5
22
  */
6
23
  export declare class NodeSchedulerAdapter implements SchedulerAdapter {
7
24
  private readonly entries;
25
+ private readonly drainTimeoutMs;
8
26
  private running;
9
- constructor();
27
+ private readonly inflight;
28
+ constructor(config?: NodeSchedulerAdapterConfig);
10
29
  register(cron: string, action: ScheduledAction): void;
11
30
  start(): Promise<void>;
12
31
  stop(): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../../src/scheduler/node.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAoCjE;;;GAGG;AACH,qBAAa,oBAAqB,YAAW,gBAAgB;IACzD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,OAAO,CAAS;;IAIxB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI;IAU/C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAStB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAU3B,OAAO,CAAC,UAAU;YAOJ,gBAAgB;CAYjC"}
1
+ {"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../../src/scheduler/node.ts"],"names":[],"mappings":"AAAA;;;GAGG;AASH,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAoCjE,4DAA4D;AAC5D,MAAM,WAAW,0BAA0B;IACvC;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,qBAAa,oBAAqB,YAAW,gBAAgB;IACzD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA4B;gBAEzC,MAAM,GAAE,0BAA+B;IAUnD,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI;IAU/C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAStB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAmB3B,OAAO,CAAC,UAAU;YAiBJ,gBAAgB;CAYjC"}
@@ -2,6 +2,7 @@
2
2
  * Node.js scheduler adapter using a simple setInterval-based approach.
3
3
  * No external cron library dependency — cron expressions are parsed minimally.
4
4
  */
5
+ import { settleWithin } from '../internals/drain.js';
5
6
  import { getLogger } from '../logger.js';
6
7
  import { getSchedulerJobDuration, getSchedulerJobExecutedTotal, getSchedulerJobFailedTotal, } from '../telemetry/metrics.js';
7
8
  /** Simple helper to parse cron-like interval strings into milliseconds. */
@@ -34,11 +35,23 @@ function parseInterval(cron) {
34
35
  /**
35
36
  * Scheduler adapter for Node.js using a setInterval-based approach.
36
37
  * No external cron library dependency — cron expressions are parsed minimally.
38
+ *
39
+ * `stop()` drains in-flight ticks, bounded by `drainTimeoutMs` (ADR-024): an
40
+ * action already running when `stop()` is called is awaited rather than torn
41
+ * down, which would leave a half-written row or a half-flushed batch.
37
42
  */
38
43
  export class NodeSchedulerAdapter {
39
44
  entries = [];
45
+ drainTimeoutMs;
40
46
  running = false;
41
- constructor() { }
47
+ inflight = new Set();
48
+ constructor(config = {}) {
49
+ const { drainTimeoutMs } = config;
50
+ if (drainTimeoutMs !== undefined && (!Number.isFinite(drainTimeoutMs) || drainTimeoutMs < 0)) {
51
+ throw new RangeError(`NodeSchedulerAdapter drainTimeoutMs must be a non-negative finite number; received ${drainTimeoutMs}`);
52
+ }
53
+ this.drainTimeoutMs = drainTimeoutMs ?? 30_000;
54
+ }
42
55
  register(cron, action) {
43
56
  this.entries.push({ cron, action });
44
57
  if (this.running) {
@@ -64,12 +77,30 @@ export class NodeSchedulerAdapter {
64
77
  entry.timer = undefined;
65
78
  }
66
79
  }
80
+ // Drain in-flight ticks, bounded by a shared absolute deadline. clearInterval
81
+ // cancels future ticks but not one already executing; without this wait a tick
82
+ // mid-action when stop() is called would keep running after stop() resolves,
83
+ // tearing down a half-written row or half-flushed batch (ADR-024).
84
+ const deadline = Date.now() + this.drainTimeoutMs;
85
+ for (const p of [...this.inflight]) {
86
+ await settleWithin(p, deadline);
87
+ }
67
88
  }
68
89
  startEntry(entry) {
69
90
  if (entry.timer)
70
91
  return;
71
92
  const interval = parseInterval(entry.cron);
72
- entry.timer = setInterval(this._onScheduledTick.bind(this, entry), interval);
93
+ // setInterval discards the async handler's returned promise, so capture it
94
+ // here for stop() to drain. _onScheduledTick never rejects (try/catch),
95
+ // so the cleanup runs on both paths with no unhandled-rejection risk.
96
+ entry.timer = setInterval(() => {
97
+ const tick = this._onScheduledTick(entry);
98
+ this.inflight.add(tick);
99
+ const cleanup = () => {
100
+ this.inflight.delete(tick);
101
+ };
102
+ tick.then(cleanup, cleanup);
103
+ }, interval);
73
104
  }
74
105
  async _onScheduledTick(entry) {
75
106
  const startMs = performance.now();
@@ -3,7 +3,13 @@
3
3
  */
4
4
  /** Signature for scheduled action handlers. */
5
5
  export type ScheduledAction = () => Promise<void>;
6
- /** Abstract scheduler interface — implementations for Node (node-cron) and Cloudflare. */
6
+ /**
7
+ * Abstract scheduler interface — implementations for Node and Cloudflare.
8
+ *
9
+ * `stop()` cancels future ticks and drains any currently-executing tick,
10
+ * bounded by an implementation-configured timeout (see ADR-024). A stuck
11
+ * action is abandoned at the deadline rather than blocking shutdown forever.
12
+ */
7
13
  export interface SchedulerAdapter {
8
14
  register(cron: string, action: ScheduledAction): void;
9
15
  start(): Promise<void>;
@@ -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,0FAA0F;AAC1F,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"}
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"}
@@ -1,2 +1,2 @@
1
- export { NodeSchedulerAdapter } from './scheduler/node';
1
+ export { NodeSchedulerAdapter, type NodeSchedulerAdapterConfig } from './scheduler/node';
2
2
  //# sourceMappingURL=scheduler-node.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scheduler-node.d.ts","sourceRoot":"","sources":["../src/scheduler-node.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"scheduler-node.d.ts","sourceRoot":"","sources":["../src/scheduler-node.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,KAAK,0BAA0B,EAAE,MAAM,kBAAkB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-infra",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "@gobing-ai/ts-infra — Infrastructure backbone: event bus, job queue, scheduler, telemetry, API client, and logging.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -78,12 +78,12 @@
78
78
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-infra-v<version> && git push --tags' && exit 1"
79
79
  },
80
80
  "dependencies": {
81
- "@gobing-ai/ts-utils": "^0.4.13",
81
+ "@gobing-ai/ts-utils": "^0.4.15",
82
82
  "@logtape/logtape": "^2.0.0"
83
83
  },
84
84
  "peerDependencies": {
85
- "@gobing-ai/ts-db": "^0.4.13",
86
- "@gobing-ai/ts-runtime": "^0.4.13",
85
+ "@gobing-ai/ts-db": "^0.4.15",
86
+ "@gobing-ai/ts-runtime": "^0.4.15",
87
87
  "@opentelemetry/api": "^1.9.0",
88
88
  "@opentelemetry/sdk-trace-node": "^2.0.0",
89
89
  "@opentelemetry/sdk-metrics": "^2.0.0",
@@ -116,8 +116,8 @@
116
116
  }
117
117
  },
118
118
  "devDependencies": {
119
- "@gobing-ai/ts-db": "^0.4.13",
120
- "@gobing-ai/ts-runtime": "^0.4.13",
119
+ "@gobing-ai/ts-db": "^0.4.15",
120
+ "@gobing-ai/ts-runtime": "^0.4.15",
121
121
  "@types/bun": "1.3.14",
122
122
  "@opentelemetry/api": "^1.9.0",
123
123
  "@opentelemetry/sdk-trace-node": "^2.0.0",
@@ -265,12 +265,19 @@ export async function runNodeApplication<TAppConfig = unknown, TEvents extends E
265
265
  typeof logFilePath === 'string' ? { ...loggingOpts, fileSink: createFileSink(logFilePath) } : loggingOpts;
266
266
 
267
267
  // ── Scheduler adapter ───────────────────────────────────────────────
268
+ // Honour a caller-supplied `SchedulerOptions.adapter` (documented injection
269
+ // point, application/types.ts:82) instead of unconditionally overwriting
270
+ // it. Only default-construct a NodeSchedulerAdapter when none was provided.
271
+ // `drainTimeoutMs` (ADR-024) is reachable by passing a pre-built adapter;
272
+ // the auto-wired default keeps the 30000 ms bound (CHANGELOG.md:16).
268
273
  const schedulerConfig: SchedulerOptions = {};
269
- const rawSched = { ...schedulerOpts } as Record<string, unknown>;
270
- if (rawSched.enabled === true) {
274
+ if (schedulerOpts.enabled === true) {
271
275
  schedulerConfig.enabled = true;
272
276
  schedulerConfig.autoStart = schedulerOpts.autoStart;
273
- schedulerConfig.adapter = new NodeSchedulerAdapter();
277
+ schedulerConfig.adapter = schedulerOpts.adapter ?? new NodeSchedulerAdapter();
278
+ if (schedulerOpts.entries) {
279
+ schedulerConfig.entries = schedulerOpts.entries;
280
+ }
274
281
  }
275
282
 
276
283
  // ── Node-owned plugins ──────────────────────────────────────────────
package/src/events.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * Infrastructure-level event definitions for ts-infra observability.
3
3
  *
4
- * Mirrors the package-local pattern in `@gobing-ai/ts-ai-runner` (`events.ts`):
5
- * these maps define the infra-level event *contract*. ts-infra itself currently
6
- * emits only `queue.stats` (`QueueStatsAction`) and `scheduler.job.executed`
7
- * (`wrapScheduledHandler`); the remaining events are contracts for the consuming
8
- * app or higher-level wiring to emit on the same bus. Application-domain events
9
- * (history import, HTTP server, …) belong to the consuming app, not this
10
- * library. Process events belong to `@gobing-ai/ts-runtime` (the owner of
11
- * `ProcessExecutor`) and are intentionally not re-exported here.
4
+ * These maps define the infra-level event *contract* for the consumers of
5
+ * `@gobing-ai/ts-infra`. ts-infra emits `queue.*` (DBJobQueue / DBQueueConsumer),
6
+ * `scheduler.job.executed` (`wrapScheduledHandler`), and `db.connection.error`
7
+ * (DB wiring). Application-domain events (history import, HTTP server, …) belong
8
+ * to the consuming app, not this library. Process events belong to
9
+ * `@gobing-ai/ts-runtime` (the owner of `ProcessExecutor`) and are intentionally
10
+ * not re-exported here.
11
+ *
12
+ * **Metadata-only invariant:** every `queue.*` detail object is correlator-grade —
13
+ * it carries job identity, timing, and retry counters only, and NEVER embeds the
14
+ * job business payload `T` (which may contain prompts, tokens, PII, or large
15
+ * blobs). Inspect job bodies via the Jobs DAO when needed.
12
16
  *
13
17
  * Consume via `EventBus<InfraEvents>` or compose individual maps into a wider
14
18
  * app event map.
@@ -26,22 +30,82 @@ export interface DbConnectionErrorDetail {
26
30
  adapter: string;
27
31
  }
28
32
 
29
- /** Payload for `queue.job.failed` a job exhausted retries. */
30
- export interface QueueJobFailedDetail {
33
+ /** Shared job identity correlators present on every `queue.job.*` event. */
34
+ export interface QueueJobRef {
35
+ /** Job id. */
31
36
  jobId: string;
37
+ /** Job type (the handler registration key). */
32
38
  type: string;
39
+ }
40
+
41
+ /** Payload for `queue.job.enqueued` — a new job was added to the queue. */
42
+ export interface QueueJobEnqueuedDetail extends QueueJobRef {
43
+ /** Epoch ms when the job was enqueued. */
44
+ enqueuedAt: number;
45
+ /** Max retry count from `EnqueueOptions.maxRetries`, when supplied. */
46
+ maxRetries?: number;
47
+ /** Requested delay before the job becomes ready (ms), from `EnqueueOptions.delay`. */
48
+ delayMs?: number;
49
+ /** Job TTL in ms, from `EnqueueOptions.ttlMs`. */
50
+ ttlMs?: number;
51
+ }
52
+
53
+ /** Payload for `queue.consumer.started` — the polling loop began. */
54
+ export interface QueueConsumerStartedDetail {
55
+ /** Epoch ms when the consumer was started. */
56
+ startedAt: number;
57
+ /** Polling interval in ms. */
58
+ pollInterval: number;
59
+ /** Claim batch size per poll cycle. */
60
+ batchSize: number;
61
+ /** Maximum concurrent in-flight handlers. */
62
+ maxConcurrency: number;
63
+ /** Visibility-timeout window in ms. */
64
+ visibilityTimeout: number;
65
+ }
66
+
67
+ /** Payload for `queue.consumer.stopped` — the polling loop was halted. */
68
+ export interface QueueConsumerStoppedDetail {
69
+ /** Epoch ms when `stop()` was called. */
70
+ stoppedAt: number;
71
+ /** Drain deadline in ms applied to in-flight handlers at stop time. */
72
+ drainTimeoutMs: number;
73
+ /** In-flight handler count observed at stop time. */
74
+ inFlightAtStop: number;
75
+ /** `true` when all in-flight handlers completed within the drain deadline. */
76
+ drained: boolean;
77
+ }
78
+
79
+ /** Payload for `queue.job.completed` — a job ran to success. */
80
+ export interface QueueJobCompletedDetail extends QueueJobRef {
81
+ /** Handler wall-clock duration in ms. */
82
+ durationMs: number;
83
+ /** Attempts counter on the job row at success (0 on the first successful run). */
84
+ attempt: number;
85
+ }
86
+
87
+ /** Payload for `queue.job.failed` — a job exhausted retries. */
88
+ export interface QueueJobFailedDetail extends QueueJobRef {
89
+ /** Error message from the final attempt. */
33
90
  error: string;
34
- /** Attempt number (0-indexed, incremented after each failure). */
91
+ /** 1-based attempt number of the failure. */
35
92
  attempt: number;
93
+ /** Maximum retry count configured for the job. */
94
+ maxRetries: number;
95
+ /** Handler wall-clock duration in ms, when measured at the failing attempt. */
96
+ durationMs?: number;
36
97
  }
37
98
 
38
99
  /** Payload for `queue.job.retrying` — a job will be retried after backoff. */
39
- export interface QueueJobRetryingDetail {
40
- jobId: string;
41
- type: string;
100
+ export interface QueueJobRetryingDetail extends QueueJobRef {
101
+ /** 1-based attempt number of the failure that triggers this retry. */
42
102
  attempt: number;
103
+ /** Maximum retry count configured for the job. */
104
+ maxRetries: number;
43
105
  /** When the next retry will fire (epoch ms). */
44
106
  nextRetryAt: number;
107
+ /** Error message that caused this retry. */
108
+ error: string;
45
109
  }
46
110
 
47
111
  /** Payload for `scheduler.job.executed`. */
@@ -76,13 +140,13 @@ export type DbEvents = {
76
140
  /** Job-queue lifecycle events emitted by the queue and its consumer. */
77
141
  export type QueueEvents = {
78
142
  /** A new job was enqueued. */
79
- 'queue.job.enqueued': (detail: { jobId: string; type: string }) => void;
143
+ 'queue.job.enqueued': (detail: QueueJobEnqueuedDetail) => void;
80
144
  /** Consumer polling loop started. */
81
- 'queue.consumer.started': () => void;
82
- /** Consumer stopped. */
83
- 'queue.consumer.stopped': () => void;
145
+ 'queue.consumer.started': (detail: QueueConsumerStartedDetail) => void;
146
+ /** Consumer polling loop stopped. */
147
+ 'queue.consumer.stopped': (detail: QueueConsumerStoppedDetail) => void;
84
148
  /** A job completed successfully. */
85
- 'queue.job.completed': (detail: { jobId: string; type: string }) => void;
149
+ 'queue.job.completed': (detail: QueueJobCompletedDetail) => void;
86
150
  /** A job exhausted retries and is permanently failed. */
87
151
  'queue.job.failed': (detail: QueueJobFailedDetail) => void;
88
152
  /** A job will be retried after backoff. */
package/src/index.ts CHANGED
@@ -30,8 +30,13 @@ export type {
30
30
  DbConnectionErrorDetail,
31
31
  DbEvents,
32
32
  InfraEvents,
33
+ QueueConsumerStartedDetail,
34
+ QueueConsumerStoppedDetail,
33
35
  QueueEvents,
36
+ QueueJobCompletedDetail,
37
+ QueueJobEnqueuedDetail,
34
38
  QueueJobFailedDetail,
39
+ QueueJobRef,
35
40
  QueueJobRetryingDetail,
36
41
  SchedulerEvents,
37
42
  SchedulerJobExecutedDetail,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Internal drain primitives shared by adapters that bound a shutdown wait on
3
+ * in-flight work (DB queue consumer, Node scheduler).
4
+ *
5
+ * WHY: two adapters had near-identical "await this in-flight thing but not
6
+ * forever" logic. Centralizing the bound keeps the two shapes from drifting
7
+ * and documents the contract once. Not re-exported from the package barrel —
8
+ * consumers depend on adapter behaviour, not this helper.
9
+ */
10
+
11
+ /**
12
+ * Resolve when `promise` settles or when `deadline` passes, whichever comes first.
13
+ *
14
+ * Never rejects: a rejected promise ends the wait the same way a fulfilled one
15
+ * does. `deadline` is an absolute epoch-ms timestamp (not a duration) so a single
16
+ * bound can be shared across several awaited promises without compounding.
17
+ */
18
+ export function settleWithin(promise: Promise<void>, deadline: number): Promise<void> {
19
+ const remaining = deadline - Date.now();
20
+ if (remaining <= 0) return Promise.resolve();
21
+ const { promise: settled, resolve } = Promise.withResolvers<void>();
22
+ const timer = setTimeout(resolve, remaining);
23
+ const done = (): void => {
24
+ clearTimeout(timer);
25
+ resolve();
26
+ };
27
+ promise.then(done, done);
28
+ return settled;
29
+ }
@@ -1,6 +1,14 @@
1
1
  import type { QueueJobDao, QueueJobRecord, QueueStats } from '@gobing-ai/ts-db';
2
2
  import type { EventBus } from '../event-bus/event-bus';
3
- import type { QueueEvents } from '../events';
3
+ import type {
4
+ QueueConsumerStoppedDetail,
5
+ QueueEvents,
6
+ QueueJobCompletedDetail,
7
+ QueueJobEnqueuedDetail,
8
+ QueueJobFailedDetail,
9
+ QueueJobRetryingDetail,
10
+ } from '../events';
11
+ import { settleWithin } from '../internals/drain';
4
12
  import { getLogger, type Logger } from '../logger';
5
13
  import {
6
14
  getQueueJobCompletedTotal,
@@ -27,7 +35,7 @@ export class DBJobQueue<T = unknown> implements JobQueue<T> {
27
35
  async enqueue(type: string, payload: T, options?: EnqueueOptions): Promise<string> {
28
36
  const id = await this.dao.enqueue(type, payload, options);
29
37
  getQueueJobEnqueuedTotal().add(1, { type });
30
- await this.events?.emit('queue.job.enqueued', { jobId: id, type });
38
+ await this.events?.emit('queue.job.enqueued', enqueuedDetail(id, type, options));
31
39
  return id;
32
40
  }
33
41
 
@@ -36,7 +44,8 @@ export class DBJobQueue<T = unknown> implements JobQueue<T> {
36
44
  getQueueJobEnqueuedTotal().add(jobs.length);
37
45
  if (this.events) {
38
46
  for (const [index, jobId] of ids.entries()) {
39
- await this.events.emit('queue.job.enqueued', { jobId, type: jobs[index]?.type ?? 'unknown' });
47
+ const job = jobs[index];
48
+ await this.events.emit('queue.job.enqueued', enqueuedDetail(jobId, job?.type ?? 'unknown', job));
40
49
  }
41
50
  }
42
51
  return ids;
@@ -47,6 +56,18 @@ export class DBJobQueue<T = unknown> implements JobQueue<T> {
47
56
  }
48
57
  }
49
58
 
59
+ /**
60
+ * Build the `queue.job.enqueued` correlator detail, filling optional retry/delay/TTL
61
+ * fields only when supplied. Shared by single and batch enqueue so both emit one shape.
62
+ */
63
+ function enqueuedDetail(jobId: string, type: string, options: EnqueueOptions | undefined): QueueJobEnqueuedDetail {
64
+ const detail: QueueJobEnqueuedDetail = { jobId, type, enqueuedAt: Date.now() };
65
+ if (options && options.maxRetries !== undefined) detail.maxRetries = options.maxRetries;
66
+ if (options && options.delay !== undefined) detail.delayMs = options.delay;
67
+ if (options && options.ttlMs !== undefined) detail.ttlMs = options.ttlMs;
68
+ return detail;
69
+ }
70
+
50
71
  /** DB-backed queue consumer with polling, retry, and visibility-timeout handling. */
51
72
  export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
52
73
  private readonly handlers = new Map<string, JobHandler<T>>();
@@ -61,6 +82,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
61
82
  private timer: ReturnType<typeof setTimeout> | null = null;
62
83
  private running = false;
63
84
  private inFlight = 0;
85
+ private pollPromise: Promise<void> | null = null;
64
86
 
65
87
  constructor(
66
88
  private readonly dao: QueueJobDao,
@@ -84,7 +106,13 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
84
106
  if (this.running) return;
85
107
  this.running = true;
86
108
  this.schedule(0);
87
- await this.events?.emit('queue.consumer.started');
109
+ await this.events?.emit('queue.consumer.started', {
110
+ startedAt: Date.now(),
111
+ pollInterval: this.pollInterval,
112
+ batchSize: this.batchSize,
113
+ maxConcurrency: this.maxConcurrency,
114
+ visibilityTimeout: this.visibilityTimeout,
115
+ });
88
116
  }
89
117
 
90
118
  async stop(): Promise<void> {
@@ -96,10 +124,29 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
96
124
  }
97
125
 
98
126
  const deadline = Date.now() + this.drainTimeoutMs;
127
+
128
+ // Wait for an already-running poll cycle before consulting `inFlight`.
129
+ // `inFlight` is only incremented once claimReady() has resolved, so a stop()
130
+ // landing between the timer firing and that increment would see 0, exit the
131
+ // drain loop immediately, and report `drained: true` while the cycle went on
132
+ // to claim and process jobs after stop() resolved.
133
+ const pending = this.pollPromise;
134
+ if (pending !== null) {
135
+ await settleWithin(pending, deadline);
136
+ }
137
+
99
138
  while (this.inFlight > 0 && Date.now() < deadline) {
100
139
  await sleep(10);
101
140
  }
102
- if (wasRunning) await this.events?.emit('queue.consumer.stopped');
141
+ if (wasRunning) {
142
+ const detail: QueueConsumerStoppedDetail = {
143
+ stoppedAt: Date.now(),
144
+ drainTimeoutMs: this.drainTimeoutMs,
145
+ inFlightAtStop: this.inFlight,
146
+ drained: this.inFlight === 0,
147
+ };
148
+ await this.events?.emit('queue.consumer.stopped', detail);
149
+ }
103
150
  }
104
151
 
105
152
  async stats(): Promise<QueueStats> {
@@ -137,7 +184,12 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
137
184
 
138
185
  private schedule(delay: number): void {
139
186
  this.timer = setTimeout(() => {
140
- void this.poll();
187
+ // Retained so stop() can await the cycle it interrupts; poll() contains its
188
+ // own errors, so this promise settles rather than rejecting.
189
+ const cycle = this.poll().finally(() => {
190
+ if (this.pollPromise === cycle) this.pollPromise = null;
191
+ });
192
+ this.pollPromise = cycle;
141
193
  }, delay);
142
194
  }
143
195
 
@@ -164,7 +216,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
164
216
  } catch (error) {
165
217
  // Corrupt payload — the job can never parse; route it through the
166
218
  // retry/fail path instead of rejecting the whole batch.
167
- await this.failOrRetry(record, error);
219
+ await this.failOrRetry(record, error, 0);
168
220
  return;
169
221
  }
170
222
  return traceAsync('queue.job.process', async () => {
@@ -176,7 +228,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
176
228
 
177
229
  const handler = this.handlers.get(job.type);
178
230
  if (handler === undefined) {
179
- await this.failOrRetry(job, new Error(`No handler registered for job type "${job.type}"`));
231
+ await this.failOrRetry(job, new Error(`No handler registered for job type "${job.type}"`), 0);
180
232
  return;
181
233
  }
182
234
 
@@ -184,12 +236,20 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
184
236
  try {
185
237
  await handler(job);
186
238
  await this.dao.markCompleted(job.id);
239
+ const durationMs = performance.now() - startMs;
187
240
  getQueueJobCompletedTotal().add(1, { type: job.type });
188
- getQueueJobProcessingDuration().record(performance.now() - startMs, { type: job.type });
189
- await this.events?.emit('queue.job.completed', { jobId: job.id, type: job.type });
241
+ getQueueJobProcessingDuration().record(durationMs, { type: job.type });
242
+ const completed: QueueJobCompletedDetail = {
243
+ jobId: job.id,
244
+ type: job.type,
245
+ durationMs: Number.isFinite(durationMs) ? durationMs : 0,
246
+ attempt: job.attempts,
247
+ };
248
+ await this.events?.emit('queue.job.completed', completed);
190
249
  } catch (error) {
191
- getQueueJobProcessingDuration().record(performance.now() - startMs, { type: job.type });
192
- await this.failOrRetry(job, error);
250
+ const durationMs = performance.now() - startMs;
251
+ getQueueJobProcessingDuration().record(durationMs, { type: job.type });
252
+ await this.failOrRetry(job, error, Number.isFinite(durationMs) ? durationMs : 0);
193
253
  }
194
254
  });
195
255
  }
@@ -197,30 +257,37 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
197
257
  private async failOrRetry(
198
258
  job: Pick<Job<T>, 'id' | 'type' | 'attempts' | 'maxRetries'>,
199
259
  error: unknown,
260
+ durationMs: number,
200
261
  ): Promise<void> {
201
262
  const attempts = job.attempts + 1;
202
263
  const message = error instanceof Error ? error.message : String(error);
203
264
  if (attempts >= job.maxRetries) {
204
265
  await this.dao.markFailed(job.id, attempts, message);
205
266
  getQueueJobFailedTotal().add(1, { type: job.type });
206
- await this.events?.emit('queue.job.failed', {
267
+ const failed: QueueJobFailedDetail = {
207
268
  jobId: job.id,
208
269
  type: job.type,
209
270
  error: message,
210
271
  attempt: attempts,
211
- });
272
+ maxRetries: job.maxRetries,
273
+ durationMs,
274
+ };
275
+ await this.events?.emit('queue.job.failed', failed);
212
276
  return;
213
277
  }
214
278
 
215
279
  const delay = Math.min(this.maxDelay, this.baseDelay * 2 ** Math.max(0, attempts - 1));
216
280
  const nextRetryAt = Date.now() + delay;
217
281
  await this.dao.markForRetry(job.id, attempts, message, nextRetryAt);
218
- await this.events?.emit('queue.job.retrying', {
282
+ const retrying: QueueJobRetryingDetail = {
219
283
  jobId: job.id,
220
284
  type: job.type,
221
285
  attempt: attempts,
286
+ maxRetries: job.maxRetries,
222
287
  nextRetryAt,
223
- });
288
+ error: message,
289
+ };
290
+ await this.events?.emit('queue.job.retrying', retrying);
224
291
  }
225
292
  }
226
293
 
@@ -241,7 +308,9 @@ function toJob<T>(record: QueueJobRecord): Job<T> {
241
308
  }
242
309
 
243
310
  function sleep(ms: number): Promise<void> {
244
- return new Promise((resolve) => setTimeout(resolve, ms));
311
+ const { promise, resolve } = Promise.withResolvers<void>();
312
+ setTimeout(resolve, ms);
313
+ return promise;
245
314
  }
246
315
 
247
316
  function positiveIntegerConfig(name: string, value: number): number {
@@ -15,6 +15,7 @@ export interface Job<T = unknown> {
15
15
  payload: T;
16
16
  status: 'pending' | 'processing' | 'completed' | 'failed';
17
17
  attempts: number;
18
+ /** Total attempts allowed, not retries *after* the first — a job fails once `attempts >= maxRetries`. */
18
19
  maxRetries: number;
19
20
  createdAt: number;
20
21
  updatedAt: number;
@@ -25,6 +26,7 @@ export interface Job<T = unknown> {
25
26
 
26
27
  /** Options for enqueuing a job: retry policy, delay, and TTL. */
27
28
  export interface EnqueueOptions {
29
+ /** Total attempts allowed (default 3) — `maxRetries: 1` runs the job once with no retry. */
28
30
  maxRetries?: number;
29
31
  delay?: number;
30
32
  ttlMs?: number;
@@ -56,11 +58,14 @@ export interface QueueConsumerConfig {
56
58
  visibilityTimeout?: number;
57
59
  baseDelay?: number;
58
60
  maxDelay?: number;
61
+ /** Upper bound (ms, default 30_000) on how long `stop()` waits for in-flight work to drain. */
59
62
  drainTimeoutMs?: number;
60
63
  /**
61
- * Optional bus for queue lifecycle events: `queue.consumer.started` /
62
- * `queue.consumer.stopped` and `queue.job.completed` / `queue.job.failed` /
63
- * `queue.job.retrying`. Omitting it leaves the consumer silent (default).
64
+ * Optional bus for queue lifecycle events with correlator-grade detail payloads:
65
+ * `queue.consumer.started` / `queue.consumer.stopped` (config snapshot + drain
66
+ * outcome), `queue.job.enqueued` / `queue.job.completed` / `queue.job.failed` /
67
+ * `queue.job.retrying`. Details carry job identity, timing, and retry counters
68
+ * only — never the business payload `T`. Omitting it leaves the consumer silent.
64
69
  */
65
70
  events?: EventBus<QueueEvents>;
66
71
  }
@@ -69,6 +74,11 @@ export interface QueueConsumerConfig {
69
74
  export interface QueueConsumer<T = unknown> {
70
75
  register(type: string, handler: JobHandler<T>): void;
71
76
  start(): Promise<void>;
77
+ /**
78
+ * Stop polling and drain work already in flight, including a poll cycle that has
79
+ * claimed nothing yet. Resolves once the drain completes or `drainTimeoutMs`
80
+ * elapses — whichever comes first, so a hung handler cannot block shutdown.
81
+ */
72
82
  stop(): Promise<void>;
73
83
  stats(): Promise<QueueStats>;
74
84
  }
@@ -39,6 +39,13 @@ export class CloudflareSchedulerAdapter implements SchedulerAdapter {
39
39
  // The `scheduled()` handler should call `handleScheduledEvent()`.
40
40
  }
41
41
 
42
+ /**
43
+ * No-op drain. Cloudflare Workers fire Cron Triggers externally — this adapter
44
+ * owns no timer to cancel — and `handleScheduledEvent` already bounds each
45
+ * in-flight action via `ctx.waitUntil()`, the runtime's own drain. So there is
46
+ * nothing for stop() to await here; clearing registrations is the full contract
47
+ * (ADR-024).
48
+ */
42
49
  async stop(): Promise<void> {
43
50
  this.entries.clear();
44
51
  }