@ultimat3/jobs 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/package.json +38 -0
- package/src/clock.d.ts +7 -0
- package/src/clock.d.ts.map +1 -0
- package/src/clock.js +21 -0
- package/src/clock.js.map +1 -0
- package/src/clock.ts +23 -0
- package/src/describe.ts +61 -0
- package/src/driver-memory.d.ts +9 -0
- package/src/driver-memory.d.ts.map +1 -0
- package/src/driver-memory.js +189 -0
- package/src/driver-memory.js.map +1 -0
- package/src/driver-memory.ts +216 -0
- package/src/driver-nats.d.ts +7 -0
- package/src/driver-nats.d.ts.map +1 -0
- package/src/driver-nats.js +51 -0
- package/src/driver-nats.js.map +1 -0
- package/src/driver-nats.ts +73 -0
- package/src/driver-pg-sql.d.ts +19 -0
- package/src/driver-pg-sql.d.ts.map +1 -0
- package/src/driver-pg-sql.js +160 -0
- package/src/driver-pg-sql.js.map +1 -0
- package/src/driver-pg-sql.ts +170 -0
- package/src/driver-pg.d.ts +17 -0
- package/src/driver-pg.d.ts.map +1 -0
- package/src/driver-pg.js +246 -0
- package/src/driver-pg.js.map +1 -0
- package/src/driver-pg.ts +356 -0
- package/src/driver-redis.d.ts +7 -0
- package/src/driver-redis.d.ts.map +1 -0
- package/src/driver-redis.js +54 -0
- package/src/driver-redis.js.map +1 -0
- package/src/driver-redis.ts +76 -0
- package/src/driver.d.ts +114 -0
- package/src/driver.d.ts.map +1 -0
- package/src/driver.js +14 -0
- package/src/driver.js.map +1 -0
- package/src/driver.ts +143 -0
- package/src/errors.d.ts +63 -0
- package/src/errors.d.ts.map +1 -0
- package/src/errors.js +105 -0
- package/src/errors.js.map +1 -0
- package/src/errors.ts +165 -0
- package/src/events.d.ts +35 -0
- package/src/events.d.ts.map +1 -0
- package/src/events.js +92 -0
- package/src/events.js.map +1 -0
- package/src/events.ts +134 -0
- package/src/index.d.ts +32 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/index.ts +172 -0
- package/src/inspect.d.ts +82 -0
- package/src/inspect.d.ts.map +1 -0
- package/src/inspect.js +113 -0
- package/src/inspect.js.map +1 -0
- package/src/inspect.ts +213 -0
- package/src/job.d.ts +71 -0
- package/src/job.d.ts.map +1 -0
- package/src/job.js +99 -0
- package/src/job.js.map +1 -0
- package/src/job.ts +261 -0
- package/src/limits.d.ts +47 -0
- package/src/limits.d.ts.map +1 -0
- package/src/limits.js +0 -0
- package/src/limits.js.map +1 -0
- package/src/limits.ts +0 -0
- package/src/outbox.d.ts +81 -0
- package/src/outbox.d.ts.map +1 -0
- package/src/outbox.js +202 -0
- package/src/outbox.js.map +1 -0
- package/src/outbox.ts +336 -0
- package/src/register.ts +40 -0
- package/src/retry.d.ts +40 -0
- package/src/retry.d.ts.map +1 -0
- package/src/retry.js +59 -0
- package/src/retry.js.map +1 -0
- package/src/retry.ts +90 -0
- package/src/scheduler.d.ts +79 -0
- package/src/scheduler.d.ts.map +1 -0
- package/src/scheduler.js +183 -0
- package/src/scheduler.js.map +1 -0
- package/src/scheduler.ts +417 -0
- package/src/steps.d.ts +86 -0
- package/src/steps.d.ts.map +1 -0
- package/src/steps.js +227 -0
- package/src/steps.js.map +1 -0
- package/src/steps.ts +339 -0
- package/src/worker.d.ts +68 -0
- package/src/worker.d.ts.map +1 -0
- package/src/worker.js +273 -0
- package/src/worker.js.map +1 -0
- package/src/worker.ts +356 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// In-process driver for `x dev` and tests: same semantics as pg (visibility timeout,
|
|
2
|
+
// idempotency dedupe, dead-letter) with zero infrastructure, so a test suite exercises the
|
|
3
|
+
// real claim/ack/nack paths rather than a mock that always succeeds.
|
|
4
|
+
|
|
5
|
+
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import { assert, systemClock, uuid } from '@ultimat3/core';
|
|
7
|
+
import { nowMs } from './clock';
|
|
8
|
+
import type {
|
|
9
|
+
ClaimedJob,
|
|
10
|
+
ClaimOptions,
|
|
11
|
+
EnqueueRequest,
|
|
12
|
+
EnqueueResult,
|
|
13
|
+
JobDriver,
|
|
14
|
+
JobFilter,
|
|
15
|
+
JobIntrospection,
|
|
16
|
+
JobRecord,
|
|
17
|
+
NackOptions,
|
|
18
|
+
QueueStats,
|
|
19
|
+
} from './driver';
|
|
20
|
+
import { DEFAULT_QUEUE } from './driver';
|
|
21
|
+
import { JobDuplicateError } from './errors';
|
|
22
|
+
import type { StepStore } from './steps';
|
|
23
|
+
import { createMemoryStepStore } from './steps';
|
|
24
|
+
|
|
25
|
+
export interface MemoryDriverOptions {
|
|
26
|
+
readonly clock?: Clock;
|
|
27
|
+
readonly steps?: StepStore;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
|
|
31
|
+
|
|
32
|
+
export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver {
|
|
33
|
+
const clock = options.clock ?? systemClock;
|
|
34
|
+
const steps = options.steps ?? createMemoryStepStore();
|
|
35
|
+
const jobs = new Map<string, JobRecord>();
|
|
36
|
+
|
|
37
|
+
const liveByKey = (key: string): JobRecord | undefined => {
|
|
38
|
+
for (const record of jobs.values()) {
|
|
39
|
+
if (record.idempotencyKey === key && LIVE_STATES.has(record.state)) return record;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const update = (id: string, patch: Partial<JobRecord>): void => {
|
|
45
|
+
const existing = jobs.get(id);
|
|
46
|
+
if (existing === undefined) return;
|
|
47
|
+
jobs.set(id, { ...existing, ...patch, updatedAt: nowMs(clock) });
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const introspect: JobIntrospection = {
|
|
51
|
+
job(jobId) {
|
|
52
|
+
return Promise.resolve(jobs.get(jobId));
|
|
53
|
+
},
|
|
54
|
+
list(filter: JobFilter = {}) {
|
|
55
|
+
const rows = [...jobs.values()]
|
|
56
|
+
.filter((record) => filter.queue === undefined || record.queue === filter.queue)
|
|
57
|
+
.filter((record) => filter.name === undefined || record.name === filter.name)
|
|
58
|
+
.filter((record) => filter.state === undefined || record.state === filter.state)
|
|
59
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
60
|
+
.slice(0, filter.limit ?? 100);
|
|
61
|
+
return Promise.resolve(rows);
|
|
62
|
+
},
|
|
63
|
+
deadLetters(limit = 100) {
|
|
64
|
+
const rows = [...jobs.values()]
|
|
65
|
+
.filter((record) => record.state === 'dead')
|
|
66
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
67
|
+
.slice(0, limit);
|
|
68
|
+
return Promise.resolve(rows);
|
|
69
|
+
},
|
|
70
|
+
async requeue(jobId, requeueOptions) {
|
|
71
|
+
const existing = jobs.get(jobId);
|
|
72
|
+
assert(
|
|
73
|
+
existing !== undefined,
|
|
74
|
+
`no job ${jobId} in the memory driver`,
|
|
75
|
+
'requeue a job id returned by enqueue() or stats() — the memory driver holds no state across processes, so an id from another run will not resolve',
|
|
76
|
+
);
|
|
77
|
+
const record = existing;
|
|
78
|
+
if (requeueOptions?.fromStep !== undefined) {
|
|
79
|
+
// Drop the target step so it re-executes; earlier steps stay memoized.
|
|
80
|
+
await steps.del(record.runId, requeueOptions.fromStep);
|
|
81
|
+
}
|
|
82
|
+
update(jobId, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
|
|
83
|
+
const next = jobs.get(jobId);
|
|
84
|
+
return next ?? record;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
name: 'memory',
|
|
90
|
+
steps,
|
|
91
|
+
introspect,
|
|
92
|
+
|
|
93
|
+
enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
|
|
94
|
+
const existing = liveByKey(request.idempotencyKey);
|
|
95
|
+
if (existing !== undefined) {
|
|
96
|
+
if (request.onConflict === 'error') {
|
|
97
|
+
throw new JobDuplicateError({
|
|
98
|
+
job: request.name,
|
|
99
|
+
idempotencyKey: request.idempotencyKey,
|
|
100
|
+
existingId: existing.id,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return Promise.resolve({ id: existing.id, runId: existing.runId, deduped: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const at = nowMs(clock);
|
|
107
|
+
const runAt = request.runAt ?? at;
|
|
108
|
+
const record: JobRecord = {
|
|
109
|
+
id: uuid(),
|
|
110
|
+
name: request.name,
|
|
111
|
+
queue: request.queue || DEFAULT_QUEUE,
|
|
112
|
+
input: request.input,
|
|
113
|
+
idempotencyKey: request.idempotencyKey,
|
|
114
|
+
runId: request.runId ?? uuid(),
|
|
115
|
+
attempt: 0,
|
|
116
|
+
maxAttempts: request.maxAttempts,
|
|
117
|
+
state: runAt > at ? 'delayed' : 'ready',
|
|
118
|
+
runAt,
|
|
119
|
+
createdAt: at,
|
|
120
|
+
updatedAt: at,
|
|
121
|
+
...(request.tenantId === undefined ? {} : { tenantId: request.tenantId }),
|
|
122
|
+
};
|
|
123
|
+
jobs.set(record.id, record);
|
|
124
|
+
return Promise.resolve({ id: record.id, runId: record.runId, deduped: false });
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
claim(claimOptions: ClaimOptions): Promise<readonly ClaimedJob[]> {
|
|
128
|
+
const at = nowMs(clock);
|
|
129
|
+
const wanted = new Set(claimOptions.queues);
|
|
130
|
+
const claimable = [...jobs.values()]
|
|
131
|
+
.filter((record) => wanted.size === 0 || wanted.has(record.queue))
|
|
132
|
+
.filter((record) => {
|
|
133
|
+
if (record.runAt > at) return false;
|
|
134
|
+
if (record.state === 'ready' || record.state === 'delayed') return true;
|
|
135
|
+
if (record.state === 'suspended') return true;
|
|
136
|
+
// Lease expiry: a worker that died without ack releases its job here.
|
|
137
|
+
return record.state === 'running' && (record.visibleAt ?? 0) <= at;
|
|
138
|
+
})
|
|
139
|
+
.sort((a, b) => a.runAt - b.runAt)
|
|
140
|
+
.slice(0, claimOptions.limit);
|
|
141
|
+
|
|
142
|
+
const out: ClaimedJob[] = [];
|
|
143
|
+
for (const record of claimable) {
|
|
144
|
+
const claimed: ClaimedJob = {
|
|
145
|
+
...record,
|
|
146
|
+
state: 'running',
|
|
147
|
+
attempt: record.attempt + 1,
|
|
148
|
+
claimedBy: claimOptions.workerId,
|
|
149
|
+
claimedAt: at,
|
|
150
|
+
visibleAt: at + claimOptions.visibilityTimeoutMs,
|
|
151
|
+
updatedAt: at,
|
|
152
|
+
};
|
|
153
|
+
jobs.set(record.id, claimed);
|
|
154
|
+
out.push(claimed);
|
|
155
|
+
}
|
|
156
|
+
return Promise.resolve(out);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
ack(jobId: string): Promise<void> {
|
|
160
|
+
update(jobId, { state: 'done' });
|
|
161
|
+
return Promise.resolve();
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
nack(jobId: string, nackOptions: NackOptions): Promise<void> {
|
|
165
|
+
const record = jobs.get(jobId);
|
|
166
|
+
if (record === undefined) return Promise.resolve();
|
|
167
|
+
const at = nowMs(clock);
|
|
168
|
+
const counts = nackOptions.countsAsAttempt !== false;
|
|
169
|
+
const patch: Partial<JobRecord> = {
|
|
170
|
+
state: nackOptions.deadLetter === true ? 'dead' : counts ? 'ready' : 'suspended',
|
|
171
|
+
runAt: at + nackOptions.delayMs,
|
|
172
|
+
// A suspension must not burn an attempt, or a 3-day sleep dead-letters the run.
|
|
173
|
+
attempt: counts ? record.attempt : record.attempt - 1,
|
|
174
|
+
...(nackOptions.error === undefined ? {} : { lastError: nackOptions.error }),
|
|
175
|
+
};
|
|
176
|
+
update(jobId, patch);
|
|
177
|
+
return Promise.resolve();
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
heartbeat(jobId: string, heartbeatOptions): Promise<void> {
|
|
181
|
+
update(jobId, { visibleAt: nowMs(clock) + heartbeatOptions.visibilityTimeoutMs });
|
|
182
|
+
return Promise.resolve();
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
stats(): Promise<readonly QueueStats[]> {
|
|
186
|
+
const at = nowMs(clock);
|
|
187
|
+
const byQueue = new Map<string, QueueStats>();
|
|
188
|
+
for (const record of jobs.values()) {
|
|
189
|
+
const current = byQueue.get(record.queue) ?? {
|
|
190
|
+
queue: record.queue,
|
|
191
|
+
ready: 0,
|
|
192
|
+
delayed: 0,
|
|
193
|
+
running: 0,
|
|
194
|
+
suspended: 0,
|
|
195
|
+
dead: 0,
|
|
196
|
+
oldestReadyMs: 0,
|
|
197
|
+
};
|
|
198
|
+
const next = { ...current };
|
|
199
|
+
if (record.state === 'ready' && record.runAt <= at) {
|
|
200
|
+
next.ready += 1;
|
|
201
|
+
next.oldestReadyMs = Math.max(next.oldestReadyMs, at - record.runAt);
|
|
202
|
+
} else if (record.state === 'ready' || record.state === 'delayed') next.delayed += 1;
|
|
203
|
+
else if (record.state === 'running') next.running += 1;
|
|
204
|
+
else if (record.state === 'suspended') next.suspended += 1;
|
|
205
|
+
else if (record.state === 'dead') next.dead += 1;
|
|
206
|
+
byQueue.set(record.queue, next);
|
|
207
|
+
}
|
|
208
|
+
return Promise.resolve([...byQueue.values()].sort((a, b) => a.queue.localeCompare(b.queue)));
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
close(): Promise<void> {
|
|
212
|
+
jobs.clear();
|
|
213
|
+
return Promise.resolve();
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { JobDriver } from './driver';
|
|
2
|
+
export interface NatsDriverOptions {
|
|
3
|
+
readonly servers?: readonly string[];
|
|
4
|
+
readonly streamPrefix?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function createNatsDriver(_options?: NatsDriverOptions): JobDriver;
|
|
7
|
+
//# sourceMappingURL=driver-nats.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"driver-nats.d.ts","sourceRoot":"","sources":["driver-nats.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAKV,SAAS,EAGV,MAAM,UAAU,CAAC;AA4BlB,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,GAAE,iBAAsB,GAAG,SAAS,CAuB5E"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// NATS driver — interface-complete, not implemented. The intended mapping, so the eventual
|
|
2
|
+
// implementation has no design decisions left: a JetStream work-queue stream per job queue,
|
|
3
|
+
// a durable pull consumer per worker pool (`fetch` == claim, `ack`/`nak` map 1:1),
|
|
4
|
+
// `ack_wait` as the visibility timeout, and a KV bucket for step records.
|
|
5
|
+
import { JobsNotImplementedError } from './errors';
|
|
6
|
+
const FIX = 'use driver: "pg" (default) or "memory" — see docs/jobs/drivers.md#nats';
|
|
7
|
+
const unavailable = (method) => {
|
|
8
|
+
throw new JobsNotImplementedError({ feature: `nats jobs driver (${method})`, fix: FIX });
|
|
9
|
+
};
|
|
10
|
+
const natsStepStore = () => ({
|
|
11
|
+
get(_runId, _name) {
|
|
12
|
+
return unavailable('steps.get');
|
|
13
|
+
},
|
|
14
|
+
put(_record) {
|
|
15
|
+
return unavailable('steps.put');
|
|
16
|
+
},
|
|
17
|
+
list(_runId) {
|
|
18
|
+
return unavailable('steps.list');
|
|
19
|
+
},
|
|
20
|
+
del(_runId, _name) {
|
|
21
|
+
return unavailable('steps.del');
|
|
22
|
+
},
|
|
23
|
+
clear(_runId) {
|
|
24
|
+
return unavailable('steps.clear');
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
export function createNatsDriver(_options = {}) {
|
|
28
|
+
return {
|
|
29
|
+
name: 'nats',
|
|
30
|
+
steps: natsStepStore(),
|
|
31
|
+
enqueue(_request) {
|
|
32
|
+
return unavailable('enqueue');
|
|
33
|
+
},
|
|
34
|
+
claim(_options) {
|
|
35
|
+
return unavailable('claim');
|
|
36
|
+
},
|
|
37
|
+
ack(_jobId) {
|
|
38
|
+
return unavailable('ack');
|
|
39
|
+
},
|
|
40
|
+
nack(_jobId, _options) {
|
|
41
|
+
return unavailable('nack');
|
|
42
|
+
},
|
|
43
|
+
heartbeat(_jobId, _options) {
|
|
44
|
+
return unavailable('heartbeat');
|
|
45
|
+
},
|
|
46
|
+
stats() {
|
|
47
|
+
return unavailable('stats');
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=driver-nats.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"driver-nats.js","sourceRoot":"","sources":["driver-nats.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAC3F,4FAA4F;AAC5F,mFAAmF;AACnF,0EAA0E;AAW1E,OAAO,EAAE,uBAAuB,EAAE,MAAM,UAAU,CAAC;AAGnD,MAAM,GAAG,GAAG,wEAAwE,CAAC;AAErF,MAAM,WAAW,GAAG,CAAC,MAAc,EAAS,EAAE;IAC5C,MAAM,IAAI,uBAAuB,CAAC,EAAE,OAAO,EAAE,qBAAqB,MAAM,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC;AAC3F,CAAC,CAAC;AAEF,MAAM,aAAa,GAAG,GAAc,EAAE,CAAC,CAAC;IACtC,GAAG,CAAC,MAAc,EAAE,KAAa;QAC/B,OAAO,WAAW,CAAC,WAAW,CAAC,CAAC;IAClC,CAAC;IACD,GAAG,CAAC,OAAmB;QACrB,OAAO,WAAW,CAAC,WAAW,CAAC,CAAC;IAClC,CAAC;IACD,IAAI,CAAC,MAAc;QACjB,OAAO,WAAW,CAAC,YAAY,CAAC,CAAC;IACnC,CAAC;IACD,GAAG,CAAC,MAAc,EAAE,KAAa;QAC/B,OAAO,WAAW,CAAC,WAAW,CAAC,CAAC;IAClC,CAAC;IACD,KAAK,CAAC,MAAc;QAClB,OAAO,WAAW,CAAC,aAAa,CAAC,CAAC;IACpC,CAAC;CACF,CAAC,CAAC;AAOH,MAAM,UAAU,gBAAgB,CAAC,QAAQ,GAAsB,EAAE;IAC/D,OAAO;QACL,IAAI,EAAE,MAAM;QACZ,KAAK,EAAE,aAAa,EAAE;QACtB,OAAO,CAAC,QAAwB;YAC9B,OAAO,WAAW,CAAC,SAAS,CAAC,CAAC;QAChC,CAAC;QACD,KAAK,CAAC,QAAsB;YAC1B,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;QAC9B,CAAC;QACD,GAAG,CAAC,MAAc;YAChB,OAAO,WAAW,CAAC,KAAK,CAAC,CAAC;QAC5B,CAAC;QACD,IAAI,CAAC,MAAc,EAAE,QAAqB;YACxC,OAAO,WAAW,CAAC,MAAM,CAAC,CAAC;QAC7B,CAAC;QACD,SAAS,CAAC,MAAc,EAAE,QAAkD;YAC1E,OAAO,WAAW,CAAC,WAAW,CAAC,CAAC;QAClC,CAAC;QACD,KAAK;YACH,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;QAC9B,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// NATS driver — interface-complete, not implemented. The intended mapping, so the eventual
|
|
2
|
+
// implementation has no design decisions left: a JetStream work-queue stream per job queue,
|
|
3
|
+
// a durable pull consumer per worker pool (`fetch` == claim, `ack`/`nak` map 1:1),
|
|
4
|
+
// `ack_wait` as the visibility timeout, and a KV bucket for step records.
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ClaimedJob,
|
|
8
|
+
ClaimOptions,
|
|
9
|
+
EnqueueRequest,
|
|
10
|
+
EnqueueResult,
|
|
11
|
+
JobDriver,
|
|
12
|
+
NackOptions,
|
|
13
|
+
QueueStats,
|
|
14
|
+
} from './driver';
|
|
15
|
+
import { JobsNotImplementedError } from './errors';
|
|
16
|
+
import type { StepRecord, StepStore } from './steps';
|
|
17
|
+
|
|
18
|
+
// Names the config edit that actually removes the stub, plus the runnable command for whatever
|
|
19
|
+
// is already queued. The nats driver lands in v2; there is no flag that turns this one on.
|
|
20
|
+
const FIX =
|
|
21
|
+
"set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs drain --to memory --json";
|
|
22
|
+
|
|
23
|
+
const unavailable = (method: string): never => {
|
|
24
|
+
throw new JobsNotImplementedError({ feature: `nats jobs driver (${method})`, fix: FIX });
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const natsStepStore = (): StepStore => ({
|
|
28
|
+
get(_runId: string, _name: string): Promise<StepRecord | undefined> {
|
|
29
|
+
return unavailable('steps.get');
|
|
30
|
+
},
|
|
31
|
+
put(_record: StepRecord): Promise<void> {
|
|
32
|
+
return unavailable('steps.put');
|
|
33
|
+
},
|
|
34
|
+
list(_runId: string): Promise<readonly StepRecord[]> {
|
|
35
|
+
return unavailable('steps.list');
|
|
36
|
+
},
|
|
37
|
+
del(_runId: string, _name: string): Promise<void> {
|
|
38
|
+
return unavailable('steps.del');
|
|
39
|
+
},
|
|
40
|
+
clear(_runId: string): Promise<void> {
|
|
41
|
+
return unavailable('steps.clear');
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export interface NatsDriverOptions {
|
|
46
|
+
readonly servers?: readonly string[];
|
|
47
|
+
readonly streamPrefix?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createNatsDriver(_options: NatsDriverOptions = {}): JobDriver {
|
|
51
|
+
return {
|
|
52
|
+
name: 'nats',
|
|
53
|
+
steps: natsStepStore(),
|
|
54
|
+
enqueue(_request: EnqueueRequest): Promise<EnqueueResult> {
|
|
55
|
+
return unavailable('enqueue');
|
|
56
|
+
},
|
|
57
|
+
claim(_options: ClaimOptions): Promise<readonly ClaimedJob[]> {
|
|
58
|
+
return unavailable('claim');
|
|
59
|
+
},
|
|
60
|
+
ack(_jobId: string): Promise<void> {
|
|
61
|
+
return unavailable('ack');
|
|
62
|
+
},
|
|
63
|
+
nack(_jobId: string, _options: NackOptions): Promise<void> {
|
|
64
|
+
return unavailable('nack');
|
|
65
|
+
},
|
|
66
|
+
heartbeat(_jobId: string, _options: { readonly visibilityTimeoutMs: number }): Promise<void> {
|
|
67
|
+
return unavailable('heartbeat');
|
|
68
|
+
},
|
|
69
|
+
stats(): Promise<readonly QueueStats[]> {
|
|
70
|
+
return unavailable('stats');
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const SQL_JOBS_TABLE: string;
|
|
2
|
+
export declare const SQL_ENQUEUE: string;
|
|
3
|
+
export declare const SQL_FIND_LIVE_BY_KEY: string;
|
|
4
|
+
/**
|
|
5
|
+
* The claim. SKIP LOCKED is the whole design: without it, worker 2 blocks on worker 1's row
|
|
6
|
+
* lock and throughput collapses to one worker. `visible_at` in the predicate reclaims leases
|
|
7
|
+
* abandoned by a crashed worker.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SQL_CLAIM: string;
|
|
10
|
+
export declare const SQL_ACK: string;
|
|
11
|
+
export declare const SQL_NACK: string;
|
|
12
|
+
export declare const SQL_HEARTBEAT: string;
|
|
13
|
+
export declare const SQL_STATS: string;
|
|
14
|
+
/** Scheduler leader election. Session-scoped, so a crashed node's lock releases itself. */
|
|
15
|
+
export declare const SQL_TRY_ADVISORY_LOCK = "select pg_try_advisory_lock($1) as locked";
|
|
16
|
+
export declare const SQL_ADVISORY_UNLOCK = "select pg_advisory_unlock($1) as unlocked";
|
|
17
|
+
export declare const SQL_STEP_GET: string;
|
|
18
|
+
export declare const SQL_STEP_PUT: string;
|
|
19
|
+
//# sourceMappingURL=driver-pg-sql.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"driver-pg-sql.d.ts","sourceRoot":"","sources":["driver-pg-sql.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,cAAc,QA4CnB,CAAC;AAET,eAAO,MAAM,WAAW,QAWhB,CAAC;AAET,eAAO,MAAM,oBAAoB,QAKzB,CAAC;AAET;;;;GAIG;AACH,eAAO,MAAM,SAAS,QA4Bd,CAAC;AAET,eAAO,MAAM,OAAO,QAIZ,CAAC;AAET,eAAO,MAAM,QAAQ,QAUb,CAAC;AAET,eAAO,MAAM,aAAa,QAIlB,CAAC;AAET,eAAO,MAAM,SAAS,QAYd,CAAC;AAET,2FAA2F;AAC3F,eAAO,MAAM,qBAAqB,8CAA8C,CAAC;AACjF,eAAO,MAAM,mBAAmB,8CAA8C,CAAC;AAE/E,eAAO,MAAM,YAAY,QAOjB,CAAC;AAET,eAAO,MAAM,YAAY,QAYjB,CAAC"}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Every statement the Postgres driver runs, spelled out as template strings on purpose: an
|
|
2
|
+
// agent debugging a stuck queue should be able to read, run and correct the exact statement
|
|
3
|
+
// it saw in a log, without reassembling it from a builder. Kept beside the driver rather than
|
|
4
|
+
// inside it so the driver file stays the control flow and this one stays the wire format.
|
|
5
|
+
export const SQL_JOBS_TABLE = `
|
|
6
|
+
create table if not exists x_jobs (
|
|
7
|
+
id uuid primary key,
|
|
8
|
+
name text not null,
|
|
9
|
+
queue text not null default 'default',
|
|
10
|
+
input jsonb not null,
|
|
11
|
+
idempotency_key text not null,
|
|
12
|
+
run_id uuid not null,
|
|
13
|
+
attempt int not null default 0,
|
|
14
|
+
max_attempts int not null default 3,
|
|
15
|
+
state text not null default 'ready',
|
|
16
|
+
run_at timestamptz not null default now(),
|
|
17
|
+
visible_at timestamptz,
|
|
18
|
+
claimed_by text,
|
|
19
|
+
last_error text,
|
|
20
|
+
tenant_id text,
|
|
21
|
+
created_at timestamptz not null default now(),
|
|
22
|
+
updated_at timestamptz not null default now()
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
-- Partial unique index: one LIVE job per idempotency key. Completed rows stay for history,
|
|
26
|
+
-- so re-running the same work tomorrow is allowed and re-delivering it today is not.
|
|
27
|
+
create unique index if not exists x_jobs_idempotency_live_idx
|
|
28
|
+
on x_jobs (idempotency_key)
|
|
29
|
+
where state in ('ready', 'delayed', 'running', 'suspended');
|
|
30
|
+
|
|
31
|
+
create index if not exists x_jobs_claim_idx
|
|
32
|
+
on x_jobs (queue, run_at)
|
|
33
|
+
where state in ('ready', 'delayed', 'suspended');
|
|
34
|
+
|
|
35
|
+
create table if not exists x_job_steps (
|
|
36
|
+
run_id uuid not null,
|
|
37
|
+
name text not null,
|
|
38
|
+
status text not null,
|
|
39
|
+
output jsonb,
|
|
40
|
+
started_at timestamptz not null default now(),
|
|
41
|
+
completed_at timestamptz,
|
|
42
|
+
wake_at timestamptz,
|
|
43
|
+
event text,
|
|
44
|
+
correlation_key text,
|
|
45
|
+
attempts int not null default 1,
|
|
46
|
+
error text,
|
|
47
|
+
primary key (run_id, name)
|
|
48
|
+
);
|
|
49
|
+
`.trim();
|
|
50
|
+
export const SQL_ENQUEUE = `
|
|
51
|
+
insert into x_jobs
|
|
52
|
+
(id, name, queue, input, idempotency_key, run_id, max_attempts, state, run_at, tenant_id)
|
|
53
|
+
values
|
|
54
|
+
($1, $2, $3, $4::jsonb, $5, $6, $7,
|
|
55
|
+
case when to_timestamp($8 / 1000.0) > now() then 'delayed' else 'ready' end,
|
|
56
|
+
to_timestamp($8 / 1000.0), $9)
|
|
57
|
+
on conflict (idempotency_key)
|
|
58
|
+
where state in ('ready', 'delayed', 'running', 'suspended')
|
|
59
|
+
do nothing
|
|
60
|
+
returning id, run_id
|
|
61
|
+
`.trim();
|
|
62
|
+
export const SQL_FIND_LIVE_BY_KEY = `
|
|
63
|
+
select id, run_id from x_jobs
|
|
64
|
+
where idempotency_key = $1
|
|
65
|
+
and state in ('ready', 'delayed', 'running', 'suspended')
|
|
66
|
+
limit 1
|
|
67
|
+
`.trim();
|
|
68
|
+
/**
|
|
69
|
+
* The claim. SKIP LOCKED is the whole design: without it, worker 2 blocks on worker 1's row
|
|
70
|
+
* lock and throughput collapses to one worker. `visible_at` in the predicate reclaims leases
|
|
71
|
+
* abandoned by a crashed worker.
|
|
72
|
+
*/
|
|
73
|
+
export const SQL_CLAIM = `
|
|
74
|
+
with claimed as (
|
|
75
|
+
select id
|
|
76
|
+
from x_jobs
|
|
77
|
+
where queue = any($1::text[])
|
|
78
|
+
and run_at <= now()
|
|
79
|
+
and (
|
|
80
|
+
state in ('ready', 'delayed', 'suspended')
|
|
81
|
+
or (state = 'running' and visible_at <= now())
|
|
82
|
+
)
|
|
83
|
+
order by run_at
|
|
84
|
+
limit $2
|
|
85
|
+
for update skip locked
|
|
86
|
+
)
|
|
87
|
+
update x_jobs j
|
|
88
|
+
set state = 'running',
|
|
89
|
+
attempt = j.attempt + 1,
|
|
90
|
+
claimed_by = $3,
|
|
91
|
+
visible_at = now() + ($4::bigint * interval '1 millisecond'),
|
|
92
|
+
updated_at = now()
|
|
93
|
+
from claimed c
|
|
94
|
+
where j.id = c.id
|
|
95
|
+
returning j.id, j.name, j.queue, j.input, j.idempotency_key, j.run_id, j.attempt,
|
|
96
|
+
j.max_attempts, j.state, j.tenant_id, j.last_error, j.claimed_by,
|
|
97
|
+
(extract(epoch from j.run_at) * 1000)::bigint as run_at,
|
|
98
|
+
(extract(epoch from j.visible_at) * 1000)::bigint as visible_at,
|
|
99
|
+
(extract(epoch from j.created_at) * 1000)::bigint as created_at,
|
|
100
|
+
(extract(epoch from j.updated_at) * 1000)::bigint as updated_at
|
|
101
|
+
`.trim();
|
|
102
|
+
export const SQL_ACK = `
|
|
103
|
+
update x_jobs
|
|
104
|
+
set state = 'done', visible_at = null, claimed_by = null, updated_at = now()
|
|
105
|
+
where id = $1
|
|
106
|
+
`.trim();
|
|
107
|
+
export const SQL_NACK = `
|
|
108
|
+
update x_jobs
|
|
109
|
+
set state = $2,
|
|
110
|
+
attempt = case when $3::boolean then attempt else greatest(attempt - 1, 0) end,
|
|
111
|
+
run_at = now() + ($4::bigint * interval '1 millisecond'),
|
|
112
|
+
visible_at = null,
|
|
113
|
+
claimed_by = null,
|
|
114
|
+
last_error = coalesce($5, last_error),
|
|
115
|
+
updated_at = now()
|
|
116
|
+
where id = $1
|
|
117
|
+
`.trim();
|
|
118
|
+
export const SQL_HEARTBEAT = `
|
|
119
|
+
update x_jobs
|
|
120
|
+
set visible_at = now() + ($2::bigint * interval '1 millisecond'), updated_at = now()
|
|
121
|
+
where id = $1 and state = 'running'
|
|
122
|
+
`.trim();
|
|
123
|
+
export const SQL_STATS = `
|
|
124
|
+
select queue,
|
|
125
|
+
count(*) filter (where state = 'ready' and run_at <= now()) as ready,
|
|
126
|
+
count(*) filter (where state = 'delayed' or run_at > now()) as delayed,
|
|
127
|
+
count(*) filter (where state = 'running') as running,
|
|
128
|
+
count(*) filter (where state = 'suspended') as suspended,
|
|
129
|
+
count(*) filter (where state = 'dead') as dead,
|
|
130
|
+
coalesce(max(extract(epoch from now() - run_at)) filter
|
|
131
|
+
(where state = 'ready' and run_at <= now()), 0) * 1000 as oldest_ready_ms
|
|
132
|
+
from x_jobs
|
|
133
|
+
group by queue
|
|
134
|
+
order by queue
|
|
135
|
+
`.trim();
|
|
136
|
+
/** Scheduler leader election. Session-scoped, so a crashed node's lock releases itself. */
|
|
137
|
+
export const SQL_TRY_ADVISORY_LOCK = 'select pg_try_advisory_lock($1) as locked';
|
|
138
|
+
export const SQL_ADVISORY_UNLOCK = 'select pg_advisory_unlock($1) as unlocked';
|
|
139
|
+
export const SQL_STEP_GET = `
|
|
140
|
+
select run_id, name, status, output, attempts, error,
|
|
141
|
+
(extract(epoch from started_at) * 1000)::bigint as started_at,
|
|
142
|
+
(extract(epoch from completed_at) * 1000)::bigint as completed_at,
|
|
143
|
+
(extract(epoch from wake_at) * 1000)::bigint as wake_at,
|
|
144
|
+
event, correlation_key
|
|
145
|
+
from x_job_steps where run_id = $1 and name = $2
|
|
146
|
+
`.trim();
|
|
147
|
+
export const SQL_STEP_PUT = `
|
|
148
|
+
insert into x_job_steps
|
|
149
|
+
(run_id, name, status, output, started_at, completed_at, wake_at, event,
|
|
150
|
+
correlation_key, attempts, error)
|
|
151
|
+
values ($1, $2, $3, $4::jsonb, to_timestamp($5 / 1000.0),
|
|
152
|
+
case when $6::bigint is null then null else to_timestamp($6 / 1000.0) end,
|
|
153
|
+
case when $7::bigint is null then null else to_timestamp($7 / 1000.0) end,
|
|
154
|
+
$8, $9, $10, $11)
|
|
155
|
+
on conflict (run_id, name) do update
|
|
156
|
+
set status = excluded.status, output = excluded.output,
|
|
157
|
+
completed_at = excluded.completed_at, wake_at = excluded.wake_at,
|
|
158
|
+
attempts = excluded.attempts, error = excluded.error
|
|
159
|
+
`.trim();
|
|
160
|
+
//# sourceMappingURL=driver-pg-sql.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"driver-pg-sql.js","sourceRoot":"","sources":["driver-pg-sql.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAC3F,4FAA4F;AAC5F,8FAA8F;AAC9F,0FAA0F;AAE1F,MAAM,CAAC,MAAM,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4C7B,CAAC,IAAI,EAAE,CAAC;AAET,MAAM,CAAC,MAAM,WAAW,GAAG;;;;;;;;;;;CAW1B,CAAC,IAAI,EAAE,CAAC;AAET,MAAM,CAAC,MAAM,oBAAoB,GAAG;;;;;CAKnC,CAAC,IAAI,EAAE,CAAC;AAET;;;;GAIG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BxB,CAAC,IAAI,EAAE,CAAC;AAET,MAAM,CAAC,MAAM,OAAO,GAAG;;;;CAItB,CAAC,IAAI,EAAE,CAAC;AAET,MAAM,CAAC,MAAM,QAAQ,GAAG;;;;;;;;;;CAUvB,CAAC,IAAI,EAAE,CAAC;AAET,MAAM,CAAC,MAAM,aAAa,GAAG;;;;CAI5B,CAAC,IAAI,EAAE,CAAC;AAET,MAAM,CAAC,MAAM,SAAS,GAAG;;;;;;;;;;;;CAYxB,CAAC,IAAI,EAAE,CAAC;AAET,2FAA2F;AAC3F,MAAM,CAAC,MAAM,qBAAqB,GAAG,2CAA2C,CAAC;AACjF,MAAM,CAAC,MAAM,mBAAmB,GAAG,2CAA2C,CAAC;AAE/E,MAAM,CAAC,MAAM,YAAY,GAAG;;;;;;;CAO3B,CAAC,IAAI,EAAE,CAAC;AAET,MAAM,CAAC,MAAM,YAAY,GAAG;;;;;;;;;;;;CAY3B,CAAC,IAAI,EAAE,CAAC"}
|