@ultimat3/jobs 23.0.0 → 24.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/CLAUDE.md +51 -52
- package/README.md +34 -5
- package/package.json +6 -6
- package/src/backfill-ledger.ts +24 -2
- package/src/backfill-pass.ts +22 -5
- package/src/claim-exhausted.ts +76 -0
- package/src/driver-memory-operator.ts +31 -9
- package/src/driver-memory.ts +68 -23
- package/src/driver-nats.ts +5 -2
- package/src/driver-pg-jobs-sql.ts +27 -19
- package/src/driver-pg-operator-sql.ts +32 -12
- package/src/driver-pg-operator.ts +19 -3
- package/src/driver-pg-rows.ts +3 -3
- package/src/driver-pg-sql.ts +98 -29
- package/src/driver-pg.ts +86 -81
- package/src/driver-redis.ts +5 -2
- package/src/driver.ts +37 -1
- package/src/errors-operator.ts +12 -4
- package/src/errors-requeue.ts +21 -3
- package/src/errors.ts +5 -13
- package/src/events-pg.ts +8 -18
- package/src/events.ts +20 -9
- package/src/execute.ts +1 -0
- package/src/index.ts +10 -4
- package/src/inspect-operator.ts +3 -3
- package/src/inspect.ts +4 -3
- package/src/introspection.ts +23 -11
- package/src/job.ts +5 -6
- package/src/limits.ts +33 -7
- package/src/outbox-pg.ts +1 -2
- package/src/outbox.ts +37 -9
- package/src/purge.ts +1 -1
- package/src/queue-wake.ts +1 -2
- package/src/scheduler-pg.ts +3 -2
- package/src/scheduler-state.ts +8 -1
- package/src/scheduler.ts +9 -10
- package/src/steps-suspension.ts +46 -0
- package/src/steps-timeout.ts +33 -0
- package/src/steps.ts +55 -81
- package/src/worker-admit.ts +7 -3
- package/src/worker-fleet-slots.ts +78 -44
- package/src/worker-key-busy.ts +38 -10
- package/src/worker-loop.ts +35 -11
- package/src/worker-registry.ts +16 -1
- package/src/worker.ts +52 -9
package/src/inspect-operator.ts
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
// know whether the job is gone or was never theirs to remove. Apart from `inspect.ts`, which is
|
|
4
4
|
// the read side.
|
|
5
5
|
|
|
6
|
+
import { NotImplementedError } from '@ultimat3/core';
|
|
6
7
|
import type { JobDriver, JobRecord } from './driver';
|
|
7
|
-
import { JobsNotImplementedError } from './errors';
|
|
8
8
|
import { JobNotPromotableError } from './errors-operator';
|
|
9
9
|
import type { JobIntrospection, PausedName } from './introspection';
|
|
10
10
|
|
|
11
11
|
function operatorOf(driver: JobDriver): JobIntrospection {
|
|
12
12
|
if (driver.introspect === undefined) {
|
|
13
|
-
throw new
|
|
14
|
-
|
|
13
|
+
throw new NotImplementedError({
|
|
14
|
+
cause: `the operator surface of the "${driver.name}" jobs driver is not implemented: the driver has no introspect`,
|
|
15
15
|
fix: 'call setJobDriver(createPgDriver()) at boot — the pg driver implements introspect — then: x jobs ls --json',
|
|
16
16
|
});
|
|
17
17
|
}
|
package/src/inspect.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// object so `x jobs ... --json` and the MCP tool share one shape — an agent debugging a stuck
|
|
3
3
|
// queue reads exactly what the dashboard renders.
|
|
4
4
|
|
|
5
|
+
import { NotImplementedError } from '@ultimat3/core';
|
|
5
6
|
import type { BackfillProgress } from './backfill-inspect';
|
|
6
7
|
import { backfillForRun } from './backfill-inspect';
|
|
7
8
|
import type { WhenBusy } from './concurrency';
|
|
8
9
|
import type { JobDriver, JobRecord, QueueStats } from './driver';
|
|
9
|
-
import { CancelUnsupportedError, JobNotCancellableError
|
|
10
|
+
import { CancelUnsupportedError, JobNotCancellableError } from './errors';
|
|
10
11
|
import type { JobFilter, JobProgress } from './introspection';
|
|
11
12
|
import type { AnyJobHandle } from './job';
|
|
12
13
|
import { registeredJobs } from './job';
|
|
@@ -111,8 +112,8 @@ const iso = (ms: number | undefined): string | null =>
|
|
|
111
112
|
|
|
112
113
|
function requireIntrospection(driver: JobDriver): NonNullable<JobDriver['introspect']> {
|
|
113
114
|
if (driver.introspect === undefined) {
|
|
114
|
-
throw new
|
|
115
|
-
|
|
115
|
+
throw new NotImplementedError({
|
|
116
|
+
cause: `introspection for the "${driver.name}" jobs driver is not implemented: the driver has no introspect`,
|
|
116
117
|
fix: 'call setJobDriver(createPgDriver()) at boot — only the pg driver implements introspect — then: x jobs ls --json',
|
|
117
118
|
});
|
|
118
119
|
}
|
package/src/introspection.ts
CHANGED
|
@@ -68,7 +68,12 @@ export interface JobFilter {
|
|
|
68
68
|
readonly tenantId?: string;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* `<createdAt ms>:<job id>`, and the id is a UUID in its one canonical shape. The group accepted
|
|
73
|
+
* any 1-64 letters, digits and hyphens, which the memory driver compared as text and the pg
|
|
74
|
+
* statement CAST: `123:not-a-uuid` was an empty page here and a raw `22P02` there.
|
|
75
|
+
*/
|
|
76
|
+
const CURSOR = /^(\d{1,16}):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
72
77
|
|
|
73
78
|
/** The cursor a page's LAST row yields. A page shorter than its limit is the last page. */
|
|
74
79
|
export function jobCursor(record: Pick<JobRecord, 'createdAt' | 'id'>): string {
|
|
@@ -76,11 +81,12 @@ export function jobCursor(record: Pick<JobRecord, 'createdAt' | 'id'>): string {
|
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
export function parseJobCursor(
|
|
79
|
-
|
|
84
|
+
raw: string,
|
|
80
85
|
subject = 'the job list',
|
|
86
|
+
cursor: 'after' | 'before' = 'after',
|
|
81
87
|
): { readonly createdAt: number; readonly id: string } {
|
|
82
|
-
const match = CURSOR.exec(
|
|
83
|
-
if (match === null) throw new JobPageInvalidError({ subject, maxPage: MAX_JOB_PAGE });
|
|
88
|
+
const match = CURSOR.exec(raw);
|
|
89
|
+
if (match === null) throw new JobPageInvalidError({ subject, maxPage: MAX_JOB_PAGE, cursor });
|
|
84
90
|
return { createdAt: Number(match[1]), id: match[2] ?? '' };
|
|
85
91
|
}
|
|
86
92
|
|
|
@@ -108,7 +114,11 @@ export const PROMOTABLE_STATES: ReadonlySet<JobState> = new Set<JobState>(['read
|
|
|
108
114
|
export interface BulkResult {
|
|
109
115
|
/** Rows this call changed — at most `MAX_BULK_ROWS`. */
|
|
110
116
|
readonly affected: number;
|
|
111
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Rows the SAME call would still act on afterwards — so "call again until it is zero" ends. A
|
|
119
|
+
* row the verb leaves where it is (a requeue whose key a live job holds, a promote of a row
|
|
120
|
+
* already due) is not counted: it will be skipped on every later call too.
|
|
121
|
+
*/
|
|
112
122
|
readonly remaining: number;
|
|
113
123
|
}
|
|
114
124
|
|
|
@@ -183,16 +193,17 @@ export interface JobIntrospection {
|
|
|
183
193
|
list(filter?: JobFilter): Promise<readonly JobRecord[]>;
|
|
184
194
|
deadLetters(limit?: number): Promise<readonly JobRecord[]>;
|
|
185
195
|
/**
|
|
186
|
-
* Re-queue a finished job (`REQUEUEABLE_STATES`); a live one is `X_JOB_NOT_REQUEUEABLE`,
|
|
187
|
-
*
|
|
188
|
-
* started after it.
|
|
196
|
+
* Re-queue a finished job (`REQUEUEABLE_STATES`); a live one is `X_JOB_NOT_REQUEUEABLE`, a key a
|
|
197
|
+
* live job holds is `X_JOB_DUPLICATE`, and an id nobody queued is `X_JOB_NOT_FOUND`. `fromStep`
|
|
198
|
+
* drops that step and every step that started after it, with the row's own move.
|
|
189
199
|
*/
|
|
190
200
|
requeue(jobId: string, options?: { readonly fromStep?: string }): Promise<JobRecord>;
|
|
191
201
|
/**
|
|
192
202
|
* Stop a job from outside. Terminal for a queued row immediately; a RUNNING one stops at its
|
|
193
203
|
* next heartbeat, which no longer matches its own row and cancels the attempt. Answers
|
|
194
|
-
* `undefined` for a job id it does not hold or one that
|
|
195
|
-
*
|
|
204
|
+
* `undefined` for a job id it does not hold or one that is not LIVE (`LIVE_STATES`) — a done,
|
|
205
|
+
* failed, dead or already cancelled row is left exactly as it ended. Optional, as it shipped: a
|
|
206
|
+
* driver may have no way to address a single running row.
|
|
196
207
|
*/
|
|
197
208
|
cancel?(jobId: string, reason?: string): Promise<JobRecord | undefined>;
|
|
198
209
|
/**
|
|
@@ -203,7 +214,8 @@ export interface JobIntrospection {
|
|
|
203
214
|
remove(jobId: string): Promise<JobRecord | undefined>;
|
|
204
215
|
/**
|
|
205
216
|
* Re-queue up to `MAX_BULK_ROWS` finished jobs. `filter.state` must be a requeueable state. A
|
|
206
|
-
* row whose key a live job holds is left where it is and counted in `remaining
|
|
217
|
+
* row whose key a live job holds is left where it is and is NOT counted in `remaining`; of
|
|
218
|
+
* several finished rows sharing one key, the oldest is the one requeued.
|
|
207
219
|
*/
|
|
208
220
|
requeueMany(filter: BulkFilter): Promise<BulkResult>;
|
|
209
221
|
/** Delete up to `MAX_BULK_ROWS` jobs. `filter.state` may be any state but `running`. */
|
package/src/job.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// deletes that class of bug: there is no way to define a job that cannot be deduped.
|
|
9
9
|
|
|
10
10
|
import type { Ctx } from '@ultimat3/core';
|
|
11
|
-
import { assert } from '@ultimat3/core';
|
|
11
|
+
import { assert, finiteCount } from '@ultimat3/core';
|
|
12
12
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
13
13
|
import { parse } from '@ultimat3/schema';
|
|
14
14
|
import type { DurationInput } from './clock';
|
|
@@ -266,11 +266,10 @@ export function job<I, R = unknown>(definition: JobDefinition<I, R>): JobHandle<
|
|
|
266
266
|
throw new JobDeclarationInvalidError({ job: name, missing });
|
|
267
267
|
}
|
|
268
268
|
assertJobTenant(name, definition.tenant);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
);
|
|
269
|
+
// A COUNT, so whole and finite as well as at least 1: `>= 1` alone admitted `Infinity` and
|
|
270
|
+
// `1.5`, which the memory driver ran and Postgres refused at the first enqueue (`$7::int`).
|
|
271
|
+
// Zero is not "never retries" — that is `attempts: 1` — it is a job that never executes at all.
|
|
272
|
+
finiteCount(`job "${name}"`, 'retry.attempts', definition.retry.attempts, 1);
|
|
274
273
|
// `concurrency: 0` is not "no cap" — it is a fleet slot table that grants nothing, and the job
|
|
275
274
|
// is permanently unrunnable with no log line. Refused where it is written, plain or keyed.
|
|
276
275
|
const concurrency = resolveConcurrency(name, definition.concurrency);
|
package/src/limits.ts
CHANGED
|
@@ -44,11 +44,21 @@ export interface LimitKey {
|
|
|
44
44
|
readonly tenantId?: string;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Returned on success. Handed back exactly once, by whichever of the two is called first — the
|
|
49
|
+
* other, and any repeat, is a no-op, so a double hand-back cannot leak a slot or pop two stamps.
|
|
50
|
+
*/
|
|
48
51
|
export interface Lease {
|
|
49
52
|
readonly queue: string;
|
|
50
53
|
readonly tenantId: string;
|
|
54
|
+
/** The run ENDED: the slot is free, and the start stays counted against `ratePerTenant`. */
|
|
51
55
|
release(): void;
|
|
56
|
+
/**
|
|
57
|
+
* The run never STARTED — shed over a fleet cap, refused by its key: the slot is free and the
|
|
58
|
+
* rate stamp `tryAcquire` wrote goes with it. `ratePerTenant` is starts per window, and a job
|
|
59
|
+
* handed back unrun every poll interval otherwise spent its tenant's whole window on sheds.
|
|
60
|
+
*/
|
|
61
|
+
abandon(): void;
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
export type LimitReason = 'per-tenant' | 'per-queue' | 'global' | 'rate';
|
|
@@ -261,16 +271,32 @@ export function createLimiter(
|
|
|
261
271
|
maybeSweep(at);
|
|
262
272
|
|
|
263
273
|
let released = false;
|
|
274
|
+
/** `false` when this lease was already handed back. */
|
|
275
|
+
const free = (): boolean => {
|
|
276
|
+
if (released) return false;
|
|
277
|
+
released = true;
|
|
278
|
+
global = Math.max(0, global - 1);
|
|
279
|
+
bump(byQueue, key.queue, -1);
|
|
280
|
+
bump(byTenant, tenant, -1);
|
|
281
|
+
bump(byQueueTenant, refusalKey, -1);
|
|
282
|
+
return true;
|
|
283
|
+
};
|
|
264
284
|
return {
|
|
265
285
|
queue: key.queue,
|
|
266
286
|
tenantId: tenant,
|
|
267
287
|
release() {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
288
|
+
free();
|
|
289
|
+
},
|
|
290
|
+
abandon() {
|
|
291
|
+
if (!free() || config.ratePerTenant === undefined) return;
|
|
292
|
+
// ONE stamp of this lease's own instant: two leases taken in the same millisecond wrote
|
|
293
|
+
// equal stamps, and either is this one. Absent when a sweep already dropped the window.
|
|
294
|
+
const stamps = starts.get(tenant);
|
|
295
|
+
const index = stamps?.lastIndexOf(at) ?? -1;
|
|
296
|
+
if (stamps === undefined || index === -1) return;
|
|
297
|
+
const rest = stamps.filter((_stamp, position) => position !== index);
|
|
298
|
+
if (rest.length === 0) starts.delete(tenant);
|
|
299
|
+
else starts.set(tenant, rest);
|
|
274
300
|
},
|
|
275
301
|
};
|
|
276
302
|
},
|
package/src/outbox-pg.ts
CHANGED
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
// `Tx` on" is a question only boot can answer. Boot has `currentTx()` — a `DbTx` IS a client on
|
|
10
10
|
// the transaction's connection — so the wiring is one line there and no tier crossing here.
|
|
11
11
|
|
|
12
|
-
import type { Clock } from '@ultimat3/core';
|
|
12
|
+
import type { Clock, PgExecutor } from '@ultimat3/core';
|
|
13
13
|
import { uuid } from '@ultimat3/core';
|
|
14
14
|
import type { Tx } from '@ultimat3/entity';
|
|
15
15
|
import { nowMs } from './clock';
|
|
16
|
-
import type { PgExecutor } from './driver-pg';
|
|
17
16
|
import {
|
|
18
17
|
SQL_OUTBOX_CLAIM,
|
|
19
18
|
SQL_OUTBOX_MARK_PUBLISHED,
|
package/src/outbox.ts
CHANGED
|
@@ -24,7 +24,13 @@
|
|
|
24
24
|
// test and `x dev` must enqueue with nothing wired — but it is a fallback, not the guarantee.
|
|
25
25
|
|
|
26
26
|
import type { Clock } from '@ultimat3/core';
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
currentSpanContext,
|
|
29
|
+
describeValue,
|
|
30
|
+
traceparent,
|
|
31
|
+
UltimateError,
|
|
32
|
+
uuid,
|
|
33
|
+
} from '@ultimat3/core';
|
|
28
34
|
import type { Tx } from '@ultimat3/entity';
|
|
29
35
|
import { nowMs } from './clock';
|
|
30
36
|
import type { EnqueueResult, JobDriver } from './driver';
|
|
@@ -238,6 +244,27 @@ function ambientTraceparent(): string | undefined {
|
|
|
238
244
|
return traceparent(context);
|
|
239
245
|
}
|
|
240
246
|
|
|
247
|
+
/** What Postgres prints for a `uuid`, and so the only spelling both drivers store identically. */
|
|
248
|
+
const RUN_ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `EnqueueOptions.runId`, refused HERE when it is not a uuid — the one screen both enqueue paths
|
|
252
|
+
* read. `run_id` is a `uuid` column: the memory driver took any string, Postgres raised a raw
|
|
253
|
+
* `22P02`, and on the staged path that was the caller's whole transaction failing at the outbox
|
|
254
|
+
* insert. Any version, lowercase: an uppercase one would come back from Postgres re-spelled.
|
|
255
|
+
*/
|
|
256
|
+
function namedRunId(job: string, runId: unknown): string | undefined {
|
|
257
|
+
if (runId === undefined) return undefined;
|
|
258
|
+
if (typeof runId === 'string' && RUN_ID_SHAPE.test(runId)) return runId;
|
|
259
|
+
throw new UltimateError({
|
|
260
|
+
code: 'X_ID_INVALID',
|
|
261
|
+
// `describeValue`, never the value: what arrives here wrong is as often a key as a typo.
|
|
262
|
+
cause: `enqueue of job "${job}" was given runId ${describeValue(runId)}, and a run id is a lowercase uuid (8-4-4-4-12 hex) — the queue stores it in a uuid column`,
|
|
263
|
+
fix: "pass { runId: uuid() } to enqueue(), with import { uuid } from '@ultimat3/core'",
|
|
264
|
+
meta: { job, option: 'runId' },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
241
268
|
export interface OutboxDeps {
|
|
242
269
|
readonly store: OutboxStore;
|
|
243
270
|
readonly driver: JobDriver;
|
|
@@ -247,18 +274,19 @@ export interface OutboxDeps {
|
|
|
247
274
|
}
|
|
248
275
|
|
|
249
276
|
/** Stage a job inside `tx`. Returns the row that the relay will publish after commit. */
|
|
250
|
-
export function enqueueInTx<I>(
|
|
277
|
+
export async function enqueueInTx<I>(
|
|
251
278
|
deps: OutboxDeps,
|
|
252
279
|
tx: Tx,
|
|
253
280
|
handle: JobHandle<I>,
|
|
254
281
|
input: I,
|
|
255
282
|
options: EnqueueOptions = {},
|
|
256
283
|
): Promise<OutboxRecord> {
|
|
284
|
+
const runId = namedRunId(handle.name, options.runId);
|
|
257
285
|
const at = nowMs(deps.clock);
|
|
258
286
|
const trace = options.traceparent ?? ambientTraceparent();
|
|
259
287
|
const record: OutboxRecord = {
|
|
260
288
|
id: uuid(),
|
|
261
|
-
runId:
|
|
289
|
+
runId: runId ?? uuid(),
|
|
262
290
|
job: handle.name,
|
|
263
291
|
queue: options.queue ?? handle.queue ?? DEFAULT_QUEUE,
|
|
264
292
|
input,
|
|
@@ -272,11 +300,10 @@ export function enqueueInTx<I>(
|
|
|
272
300
|
...(trace === undefined ? {} : { traceparent: trace }),
|
|
273
301
|
...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
|
|
274
302
|
};
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
});
|
|
303
|
+
await deps.store.stage(tx, record);
|
|
304
|
+
// Wakes a relay in THIS process that has backed off; a relay elsewhere polls on its own.
|
|
305
|
+
signalStaged();
|
|
306
|
+
return record;
|
|
280
307
|
}
|
|
281
308
|
|
|
282
309
|
export interface JobsFacade {
|
|
@@ -304,6 +331,7 @@ export function createJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
|
|
|
304
331
|
if (deps.mode === 'required' && options.outbox !== false) {
|
|
305
332
|
throw new OutboxNoTxError({ job: handle.name });
|
|
306
333
|
}
|
|
334
|
+
const runId = namedRunId(handle.name, options.runId);
|
|
307
335
|
const trace = options.traceparent ?? ambientTraceparent();
|
|
308
336
|
const result = await deps.driver.enqueue({
|
|
309
337
|
name: handle.name,
|
|
@@ -312,7 +340,7 @@ export function createJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
|
|
|
312
340
|
idempotencyKey: handle.idempotencyKeyFor(input),
|
|
313
341
|
maxAttempts: handle.retry.attempts,
|
|
314
342
|
runAt: options.runAt ?? nowMs(deps.clock),
|
|
315
|
-
...(
|
|
343
|
+
...(runId === undefined ? {} : { runId }),
|
|
316
344
|
...(options.tenantId === undefined ? {} : { tenantId: options.tenantId }),
|
|
317
345
|
...(trace === undefined ? {} : { traceparent: trace }),
|
|
318
346
|
...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
|
package/src/purge.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface PurgeTarget {
|
|
|
33
33
|
/**
|
|
34
34
|
* What this sweep is called in its durable step, its log line and its report. A table name is
|
|
35
35
|
* the natural spelling (`x_rate_limit`); a target that clears a SET of tables names their common
|
|
36
|
-
* prefix (`x_auth`, for `
|
|
36
|
+
* prefix (`x_auth`, for `x_auth_lockouts`). Unique within one definition —
|
|
37
37
|
* the name is the step key, and two steps under one name is `X_STEP_DUPLICATE` mid-run.
|
|
38
38
|
*/
|
|
39
39
|
readonly name: string;
|
package/src/queue-wake.ts
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
// the poll keeps running underneath, and this file's other job is to say whether the wake is
|
|
6
6
|
// PROVEN: the loops raise their idle ceiling only while it is.
|
|
7
7
|
|
|
8
|
-
import type { Random } from '@ultimat3/core';
|
|
8
|
+
import type { PgExecutor, Random } from '@ultimat3/core';
|
|
9
9
|
import { backoffDelay, finiteOption, logger, renderThrowable } from '@ultimat3/core';
|
|
10
|
-
import type { PgExecutor } from './driver-pg';
|
|
11
10
|
import { JOBS_WAKE_CHANNEL, OUTBOX_WAKE_CHANNEL, SQL_WAKE } from './driver-pg-wake-sql';
|
|
12
11
|
import { setWakeLive, signalEnqueued, signalStaged } from './enqueue-signal';
|
|
13
12
|
|
package/src/scheduler-pg.ts
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
// both failures are silent — a redeployed pod arms to tomorrow and never reports the 03:00 run the
|
|
4
4
|
// pod it replaced dropped, and two pods in a rolling update both dispatch every task.
|
|
5
5
|
|
|
6
|
-
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import type { Clock, PgExecutor } from '@ultimat3/core';
|
|
7
7
|
import { finiteOption, uuid } from '@ultimat3/core';
|
|
8
8
|
import { nowMs } from './clock';
|
|
9
9
|
import { DEFAULT_QUEUE } from './driver';
|
|
10
|
-
import type { PgExecutor } from './driver-pg';
|
|
11
10
|
import { SQL_SCHEDULER_FIRE } from './driver-pg-operator-sql';
|
|
12
11
|
import {
|
|
13
12
|
SQL_LEADER_ACQUIRE,
|
|
@@ -62,6 +61,8 @@ export function pgSchedulerState(executor: PgExecutor): SchedulerState {
|
|
|
62
61
|
max_attempts: request.maxAttempts,
|
|
63
62
|
})),
|
|
64
63
|
),
|
|
64
|
+
// Where the watermark lands when that is past the occurrence (`run-once`): ONE statement.
|
|
65
|
+
fire.watermarkMs ?? null,
|
|
65
66
|
]);
|
|
66
67
|
if (Number(rows[0]?.fired ?? 0) === 0) return undefined;
|
|
67
68
|
const queued = new Map(
|
package/src/scheduler-state.ts
CHANGED
|
@@ -9,6 +9,13 @@ import type { EnqueueRequest, EnqueueResult, JobDriver } from './driver';
|
|
|
9
9
|
export interface ScheduledFire {
|
|
10
10
|
readonly task: string;
|
|
11
11
|
readonly occurrenceMs: number;
|
|
12
|
+
/**
|
|
13
|
+
* Where the watermark LANDS, when that is past the occurrence: `run-once` runs the earliest
|
|
14
|
+
* missed occurrence and drops the rest, and dropping is moving the watermark past them — in the
|
|
15
|
+
* same write, or a crash between the two fires a second "one catch-up". The fence is still the
|
|
16
|
+
* occurrence. Omitted, the watermark lands on the occurrence.
|
|
17
|
+
*/
|
|
18
|
+
readonly watermarkMs?: number;
|
|
12
19
|
readonly jobs: readonly EnqueueRequest[];
|
|
13
20
|
}
|
|
14
21
|
|
|
@@ -44,7 +51,7 @@ export async function fireThroughDriver(
|
|
|
44
51
|
if (last !== undefined && last >= fire.occurrenceMs) return undefined;
|
|
45
52
|
const results: EnqueueResult[] = [];
|
|
46
53
|
for (const request of fire.jobs) results.push(await driver.enqueue(request));
|
|
47
|
-
await state.markFired(fire.task, fire.occurrenceMs);
|
|
54
|
+
await state.markFired(fire.task, fire.watermarkMs ?? fire.occurrenceMs);
|
|
48
55
|
// What an operator reads as "last fired". After the watermark, so a record never names an
|
|
49
56
|
// occurrence a crash would fire again.
|
|
50
57
|
await driver.introspect?.recordTaskFire({ task: fire.task, occurrenceMs: fire.occurrenceMs });
|
package/src/scheduler.ts
CHANGED
|
@@ -144,6 +144,7 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
144
144
|
handle: TaskHandle,
|
|
145
145
|
occurrenceMs: number,
|
|
146
146
|
catchUp: boolean,
|
|
147
|
+
watermarkMs?: number,
|
|
147
148
|
): Promise<DispatchedOccurrence | undefined> => {
|
|
148
149
|
// The occurrence, not `at`: a catch-up dispatch runs long after the instant it fires for,
|
|
149
150
|
// and the payload has to describe the occurrence the email/report claims to be about.
|
|
@@ -166,6 +167,7 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
166
167
|
const results = await schedulerState.fire(options.driver, {
|
|
167
168
|
task: handle.name,
|
|
168
169
|
occurrenceMs,
|
|
170
|
+
...(watermarkMs === undefined ? {} : { watermarkMs }),
|
|
169
171
|
jobs: requests,
|
|
170
172
|
});
|
|
171
173
|
if (results === undefined) {
|
|
@@ -323,16 +325,13 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
323
325
|
if (handle.catchUp === 'run-once') {
|
|
324
326
|
const first = due[0];
|
|
325
327
|
if (first !== undefined) {
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
// definition, and this policy fires none of them.
|
|
334
|
-
if (first !== at) await schedulerState.markFired(handle.name, at);
|
|
335
|
-
if (ran) remember(handle, Math.max(first, at));
|
|
328
|
+
// "One catch-up" means the rest are DROPPED, and dropping an occurrence is moving the
|
|
329
|
+
// watermark past it: left on the occurrence that RAN, the next round found 2..n still
|
|
330
|
+
// due and fired one per tick — 24 nightly digests a second apart after a day down.
|
|
331
|
+
// `at` rather than the last element of `due`, which `maxCatchUp` truncates. It rides
|
|
332
|
+
// the FIRE (`watermarkMs`): a `markFired` behind it was a second write, and a scheduler
|
|
333
|
+
// that died between the two handed its successor another catch-up.
|
|
334
|
+
if (fired(await dispatch(handle, first, due.length > 1, at))) remember(handle, at);
|
|
336
335
|
}
|
|
337
336
|
continue;
|
|
338
337
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A run that unwinds on purpose: the `StepSuspension` a `sleep` or `waitForEvent` throws, and the
|
|
2
|
+
// marker a timed-out wait persists. Split from `steps.ts` at the file-size ceiling — that file is
|
|
3
|
+
// the runner; this is the control flow the worker reads off what the runner threw.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Control flow, not a failure: it must never be logged as an error or counted as an attempt.
|
|
7
|
+
* Branded by string rather than `instanceof` so two copies of this module still agree.
|
|
8
|
+
*/
|
|
9
|
+
export class StepSuspension extends Error {
|
|
10
|
+
static readonly brand = 'ultimate.jobs.suspension';
|
|
11
|
+
readonly brand: string = StepSuspension.brand;
|
|
12
|
+
readonly step: string;
|
|
13
|
+
readonly resumeAt: number;
|
|
14
|
+
readonly reason: 'sleep' | 'event';
|
|
15
|
+
|
|
16
|
+
constructor(input: { step: string; resumeAt: number; reason: 'sleep' | 'event' }) {
|
|
17
|
+
super(`step "${input.step}" suspended until ${new Date(input.resumeAt).toISOString()}`);
|
|
18
|
+
this.name = 'StepSuspension';
|
|
19
|
+
this.step = input.step;
|
|
20
|
+
this.resumeAt = input.resumeAt;
|
|
21
|
+
this.reason = input.reason;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a timed-out `waitForEvent` persists, and maps back to `undefined` on replay. `undefined`
|
|
27
|
+
* itself cannot survive a store — the pg driver writes `JSON.stringify(output ?? null)` — so the
|
|
28
|
+
* timeout replayed as `null` and `evt === undefined` read as "an event arrived". A namespaced key
|
|
29
|
+
* rather than a bare `{ timedOut: true }`, so no event payload can be mistaken for the marker.
|
|
30
|
+
*/
|
|
31
|
+
export const WAIT_TIMED_OUT = Object.freeze<Record<string, true>>({
|
|
32
|
+
'~ultimate.waitTimedOut': true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export function isWaitTimedOut(output: unknown): boolean {
|
|
36
|
+
return (
|
|
37
|
+
typeof output === 'object' &&
|
|
38
|
+
output !== null &&
|
|
39
|
+
Object.keys(output).length === 1 &&
|
|
40
|
+
(output as Record<string, unknown>)['~ultimate.waitTimedOut'] === true
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isStepSuspension(error: unknown): error is StepSuspension {
|
|
45
|
+
return error instanceof Error && (error as { brand?: unknown }).brand === StepSuspension.brand;
|
|
46
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A step's own ceiling: the race between a step body and `stepTimeout`. Split from `steps.ts` at
|
|
2
|
+
// the file-size ceiling — that file is the runner, and this is the one timer it arms.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A step's own ceiling. It ABORTS before it rejects, in that order: `run()` fails the step on this
|
|
6
|
+
* rejection and the job retries, so a body still holding a socket open past the deadline would be
|
|
7
|
+
* racing the attempt that replaced it. Cancelling first is the only thing that can stop it.
|
|
8
|
+
*/
|
|
9
|
+
export function withStepTimeout<T>(
|
|
10
|
+
work: Promise<T> | T,
|
|
11
|
+
timeoutMs: number | undefined,
|
|
12
|
+
deadline: AbortController,
|
|
13
|
+
error: () => Error,
|
|
14
|
+
): Promise<T> {
|
|
15
|
+
if (timeoutMs === undefined || timeoutMs <= 0) return Promise.resolve(work);
|
|
16
|
+
return new Promise<T>((resolve, reject) => {
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
const failure = error();
|
|
19
|
+
deadline.abort(failure);
|
|
20
|
+
reject(failure);
|
|
21
|
+
}, timeoutMs);
|
|
22
|
+
Promise.resolve(work).then(
|
|
23
|
+
(value) => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
resolve(value);
|
|
26
|
+
},
|
|
27
|
+
(cause) => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
reject(cause);
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
}
|