@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.
- package/README.md +4 -0
- package/dist/application-node.d.ts.map +1 -1
- package/dist/application-node.js +10 -3
- package/dist/events.d.ts +78 -25
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +12 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/internals/drain.d.ts +18 -0
- package/dist/internals/drain.d.ts.map +1 -0
- package/dist/internals/drain.js +29 -0
- package/dist/job-queue/db-job-queue.d.ts +1 -0
- package/dist/job-queue/db-job-queue.d.ts.map +1 -1
- package/dist/job-queue/db-job-queue.js +79 -18
- package/dist/job-queue/types.d.ts +13 -3
- package/dist/job-queue/types.d.ts.map +1 -1
- package/dist/scheduler/cloudflare.d.ts +7 -0
- package/dist/scheduler/cloudflare.d.ts.map +1 -1
- package/dist/scheduler/cloudflare.js +7 -0
- package/dist/scheduler/node.d.ts +20 -1
- package/dist/scheduler/node.d.ts.map +1 -1
- package/dist/scheduler/node.js +33 -2
- package/dist/scheduler/types.d.ts +7 -1
- package/dist/scheduler/types.d.ts.map +1 -1
- package/dist/scheduler-node.d.ts +1 -1
- package/dist/scheduler-node.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/application-node.ts +10 -3
- package/src/events.ts +83 -19
- package/src/index.ts +5 -0
- package/src/internals/drain.ts +29 -0
- package/src/job-queue/db-job-queue.ts +86 -17
- package/src/job-queue/types.ts +13 -3
- package/src/scheduler/cloudflare.ts +7 -0
- package/src/scheduler/node.ts +47 -2
- package/src/scheduler/types.ts +7 -1
- package/src/scheduler-node.ts +1 -1
package/dist/scheduler/node.d.ts
CHANGED
|
@@ -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
|
-
|
|
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":"
|
|
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"}
|
package/dist/scheduler/node.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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"}
|
package/dist/scheduler-node.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
86
|
-
"@gobing-ai/ts-runtime": "^0.4.
|
|
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.
|
|
120
|
-
"@gobing-ai/ts-runtime": "^0.4.
|
|
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",
|
package/src/application-node.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
9
|
-
* (
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
/**
|
|
30
|
-
export interface
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 {
|
|
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',
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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(
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/job-queue/types.ts
CHANGED
|
@@ -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
|
|
62
|
-
* `queue.consumer.
|
|
63
|
-
* `queue.job.
|
|
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
|
}
|