@ultimat3/jobs 29.3.2 → 30.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 +68 -89
- package/README.md +304 -10
- package/package.json +18 -6
- package/src/backfill-definition.ts +184 -0
- package/src/backfill-errors.ts +18 -2
- package/src/backfill-pass.ts +43 -40
- package/src/backfill-pending.ts +40 -17
- package/src/backfill-reader.ts +94 -0
- package/src/backfill-registry.ts +5 -0
- package/src/backfill-scope.ts +1 -2
- package/src/backfill-source.ts +113 -0
- package/src/backfill.ts +54 -135
- package/src/consumer-errors.ts +42 -0
- package/src/consumer-guard.ts +19 -0
- package/src/consumer-loop.ts +171 -0
- package/src/consumer-registry.ts +103 -0
- package/src/consumer-types.ts +81 -0
- package/src/consumer.ts +147 -0
- package/src/driver-memory-operator.ts +25 -2
- package/src/driver-memory.ts +35 -1
- package/src/driver-pg-claim-sql.ts +128 -0
- package/src/driver-pg-ddl.ts +11 -2
- package/src/driver-pg-demand-sql.ts +27 -0
- package/src/driver-pg-enqueue-many.ts +153 -0
- package/src/driver-pg-event-wake-sql.ts +51 -0
- package/src/driver-pg-operator-sql.ts +34 -6
- package/src/driver-pg-operator.ts +36 -2
- package/src/driver-pg-outbox-sql.ts +34 -0
- package/src/driver-pg-sql.ts +24 -91
- package/src/driver-pg.ts +17 -1
- package/src/driver-redis-refusal.ts +25 -0
- package/src/driver-redis-scripts.ts +79 -30
- package/src/driver-redis.ts +70 -42
- package/src/driver.ts +38 -1
- package/src/enqueue-record.ts +137 -0
- package/src/error-codes.ts +233 -0
- package/src/errors-concurrency.ts +2 -2
- package/src/errors-declaration.ts +15 -2
- package/src/errors-operator.ts +1 -1
- package/src/errors-requeue.ts +1 -1
- package/src/errors-saga.ts +87 -0
- package/src/errors-steps.ts +35 -0
- package/src/errors-tenant.ts +1 -1
- package/src/errors.ts +25 -165
- package/src/events-pg.ts +20 -8
- package/src/events.ts +14 -3
- package/src/execute-ctx.ts +51 -0
- package/src/execute-unwind.ts +191 -0
- package/src/execute.ts +50 -51
- package/src/export-errors.ts +1 -1
- package/src/failure-row.ts +43 -0
- package/src/import-errors.ts +102 -0
- package/src/import-format.ts +232 -0
- package/src/import-pass.ts +255 -0
- package/src/import-source.ts +49 -0
- package/src/import.ts +175 -0
- package/src/index.ts +163 -7
- package/src/inspect-operator.ts +24 -3
- package/src/inspect.ts +17 -4
- package/src/introspection.ts +24 -1
- package/src/job-enqueue-many.ts +108 -0
- package/src/job-types.ts +229 -0
- package/src/job.ts +19 -208
- package/src/metrics.ts +10 -0
- package/src/outbox-pg.ts +35 -0
- package/src/outbox-relay.ts +58 -19
- package/src/outbox.ts +65 -73
- package/src/purge-entities.ts +92 -0
- package/src/purge.ts +10 -2
- package/src/queue-wake.ts +1 -1
- package/src/refresh-view.ts +76 -0
- package/src/retry-classification.ts +27 -8
- package/src/retry.ts +26 -0
- package/src/rollup-errors.ts +27 -0
- package/src/rollup.ts +190 -0
- package/src/run-deadline.ts +11 -7
- package/src/scheduler.ts +2 -1
- package/src/settled.ts +56 -15
- package/src/step-resolve.ts +37 -0
- package/src/steps-api.ts +111 -0
- package/src/steps-core.ts +26 -0
- package/src/steps-output.ts +142 -0
- package/src/steps-store.ts +68 -0
- package/src/steps-suspension.ts +51 -0
- package/src/steps-timeout.ts +10 -5
- package/src/steps-unwind.ts +94 -0
- package/src/steps-wait.ts +163 -0
- package/src/steps.ts +174 -210
- package/src/subject-erase-pass.ts +134 -0
- package/src/subject-errors.ts +48 -0
- package/src/subject-export-pass.ts +228 -0
- package/src/subject-jobs.ts +121 -0
- package/src/subject-plan.ts +287 -0
- package/src/subject-select.ts +106 -0
- package/src/subject.ts +119 -0
- package/src/tenant.ts +69 -1
- package/src/type-pins.ts +90 -0
- package/src/webhook-errors.ts +1 -1
- package/src/webhook-receiver-errors.ts +59 -0
- package/src/webhook-receiver-route.ts +127 -0
- package/src/webhook-receiver-types.ts +97 -0
- package/src/webhook-receiver.ts +132 -0
- package/src/webhook-target.ts +20 -43
- package/src/webhook.ts +7 -4
- package/src/worker-queue-demand.ts +57 -0
- package/src/worker-queue-depth.ts +15 -3
- package/src/worker-registry.ts +4 -1
- package/src/worker-types.ts +2 -0
- package/src/worker.ts +2 -2
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// What `consumer()` declared, read back off a plain `JobHandle` — the `origin` WeakMap shape
|
|
2
|
+
// `backfill-registry.ts` uses, and derived from `registeredJobs()` rather than a second registry.
|
|
3
|
+
// Also the one projection of a consumer's run for `x jobs show`: key, last batch at, lag.
|
|
4
|
+
|
|
5
|
+
import type { JobProgress } from './introspection';
|
|
6
|
+
import type { AnyJobHandle } from './job';
|
|
7
|
+
import { isJobHandle, registeredJobs } from './job';
|
|
8
|
+
|
|
9
|
+
/** Everything `consumer()` knows that a `JobHandle` has no field for. */
|
|
10
|
+
export interface ConsumerOrigin {
|
|
11
|
+
readonly supervisor: string;
|
|
12
|
+
readonly arm: string;
|
|
13
|
+
readonly cron: string;
|
|
14
|
+
readonly batch: number;
|
|
15
|
+
readonly lags: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One declaration as `--json` and the manifest report it. Plain JSON. */
|
|
19
|
+
export interface ConsumerDeclaration {
|
|
20
|
+
readonly kind: 'consumer';
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly queue: string;
|
|
23
|
+
/** The task that re-arms every key, and the job its tick enqueues. */
|
|
24
|
+
readonly supervisor: string;
|
|
25
|
+
readonly arm: string;
|
|
26
|
+
/** The supervisor's cron, in UTC: a re-arm interval has no wall-clock meaning. */
|
|
27
|
+
readonly rearm: string;
|
|
28
|
+
readonly batch: number;
|
|
29
|
+
/** Whether `lag` was declared, so a reader knows a `lag: null` is "not declared". */
|
|
30
|
+
readonly lags: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One consumer run, as `x jobs show <id> --json` reports it under `consumer`. */
|
|
34
|
+
export interface ConsumerRow {
|
|
35
|
+
readonly key: string | null;
|
|
36
|
+
/** When the run last reported a committed batch; `null` before its first. */
|
|
37
|
+
readonly lastBatchAt: string | null;
|
|
38
|
+
/** `null` when no `lag` is declared or no batch has committed yet. */
|
|
39
|
+
readonly lag: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const origin = new WeakMap<object, ConsumerOrigin>();
|
|
43
|
+
|
|
44
|
+
/** Called by `consumer()` and nothing else — not exported from `src/index.ts`. */
|
|
45
|
+
export function stampConsumer(handle: AnyJobHandle, source: ConsumerOrigin): void {
|
|
46
|
+
origin.set(handle, source);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isConsumer(value: unknown): value is AnyJobHandle {
|
|
50
|
+
return isJobHandle(value) && origin.has(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reads `handle.name` live: registration rebinds that property in place. */
|
|
54
|
+
export function consumerDeclarationOf(handle: AnyJobHandle): ConsumerDeclaration | undefined {
|
|
55
|
+
const source = origin.get(handle);
|
|
56
|
+
if (source === undefined) return undefined;
|
|
57
|
+
return {
|
|
58
|
+
kind: 'consumer',
|
|
59
|
+
name: handle.name,
|
|
60
|
+
queue: handle.queue,
|
|
61
|
+
supervisor: source.supervisor,
|
|
62
|
+
arm: source.arm,
|
|
63
|
+
rearm: source.cron,
|
|
64
|
+
batch: source.batch,
|
|
65
|
+
lags: source.lags,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every consumer this process declared, in `registeredJobs()`' code-unit order. */
|
|
70
|
+
export function registeredConsumers(): readonly ConsumerDeclaration[] {
|
|
71
|
+
const declarations: ConsumerDeclaration[] = [];
|
|
72
|
+
for (const handle of registeredJobs()) {
|
|
73
|
+
const declaration = consumerDeclarationOf(handle);
|
|
74
|
+
if (declaration !== undefined) declarations.push(declaration);
|
|
75
|
+
}
|
|
76
|
+
return declarations;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const keyOf = (input: unknown): string | null => {
|
|
80
|
+
if (typeof input !== 'object' || input === null) return null;
|
|
81
|
+
const key = (input as { readonly key?: unknown }).key;
|
|
82
|
+
return typeof key === 'string' ? key : null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The consumer row for one run, or `null` when `name` is no declared consumer. The loop reports
|
|
87
|
+
* `progress(items, items + lag)`, so the lag is `total - done` — and only when one is declared.
|
|
88
|
+
*/
|
|
89
|
+
export function consumerRow(run: {
|
|
90
|
+
readonly name: string;
|
|
91
|
+
readonly input: unknown;
|
|
92
|
+
readonly progress: JobProgress | null;
|
|
93
|
+
}): ConsumerRow | null {
|
|
94
|
+
const handle = registeredJobs().find((candidate) => candidate.name === run.name);
|
|
95
|
+
const source = handle === undefined ? undefined : origin.get(handle);
|
|
96
|
+
if (source === undefined) return null;
|
|
97
|
+
const progress = run.progress;
|
|
98
|
+
return {
|
|
99
|
+
key: keyOf(run.input),
|
|
100
|
+
lastBatchAt: progress === null ? null : new Date(progress.at).toISOString(),
|
|
101
|
+
lag: !source.lags || progress === null ? null : progress.total - progress.done,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The `consumer()` declaration's shape and what it hands back. Apart from `consumer.ts` so the
|
|
2
|
+
// factory, the turn loop (`consumer-loop.ts`) and the registry read one set of types.
|
|
3
|
+
|
|
4
|
+
import type { Ctx } from '@ultimat3/core';
|
|
5
|
+
import type { DurationInput } from './clock';
|
|
6
|
+
import type { JobHandle } from './job';
|
|
7
|
+
import type { RetryPolicy } from './retry';
|
|
8
|
+
import type { TaskHandle } from './task';
|
|
9
|
+
import type { JobTenant } from './tenant';
|
|
10
|
+
|
|
11
|
+
/** One run per key: the payload is the key, and nothing else a queue row could drift on. */
|
|
12
|
+
export interface ConsumerInput {
|
|
13
|
+
readonly key: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The app's own row, read and written through this seam — the reasoning `webhook-ledger.ts`
|
|
18
|
+
* states: the app owns the table. `save` runs INSIDE the batch's transaction, so a batch and the
|
|
19
|
+
* cursor past it commit together or not at all.
|
|
20
|
+
*/
|
|
21
|
+
export interface ConsumerCursor<C> {
|
|
22
|
+
/** Where `key` stopped. `null` before its first committed batch. */
|
|
23
|
+
load(key: string): Promise<C | null> | C | null;
|
|
24
|
+
save(key: string, next: C): Promise<void> | void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ConsumerBatch<Item> {
|
|
28
|
+
readonly items: readonly Item[];
|
|
29
|
+
readonly key: string;
|
|
30
|
+
readonly ctx: Ctx;
|
|
31
|
+
/** The run's cancellation — a drain aborts it. Hand it to whatever the body calls. */
|
|
32
|
+
readonly signal: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ConsumerDefinition<Item, C> {
|
|
36
|
+
/** REQUIRED: a durable queue key, the idempotency key's prefix and the supervisor's name. */
|
|
37
|
+
readonly name: string;
|
|
38
|
+
/** REQUIRED, as on `job()`: the org a key's run acts under, from the key — or `'none'`. */
|
|
39
|
+
readonly tenant: JobTenant<ConsumerInput>;
|
|
40
|
+
/**
|
|
41
|
+
* What should be running: one key, or one per account. Asked by the supervisor's arm run under
|
|
42
|
+
* `tenant: 'none'`, so a tenant-scoped read here needs the app's own `crossTenant(reason, fn)`.
|
|
43
|
+
*/
|
|
44
|
+
keys(args: { readonly ctx: Ctx }): Promise<readonly string[]> | readonly string[];
|
|
45
|
+
/** The stream, from `cursor`. Any broker is the app's dependency, behind this one seam. */
|
|
46
|
+
open(args: {
|
|
47
|
+
readonly key: string;
|
|
48
|
+
readonly cursor: C | null;
|
|
49
|
+
readonly ctx: Ctx;
|
|
50
|
+
readonly signal: AbortSignal;
|
|
51
|
+
}): AsyncIterable<Item>;
|
|
52
|
+
/**
|
|
53
|
+
* One batch, inside a transaction, answering the cursor past it — which the framework hands to
|
|
54
|
+
* `cursor.save` in the SAME transaction. At least once: a drained or failed batch is delivered
|
|
55
|
+
* again, so the effect must be idempotent per item (item ids are the app's).
|
|
56
|
+
*/
|
|
57
|
+
handle(batch: ConsumerBatch<Item>): Promise<C> | C;
|
|
58
|
+
readonly cursor: ConsumerCursor<C>;
|
|
59
|
+
/** Items per transaction, at most. Defaults to `DEFAULT_CONSUMER_BATCH`. */
|
|
60
|
+
readonly batch?: number;
|
|
61
|
+
readonly queue?: string;
|
|
62
|
+
readonly retry?: RetryPolicy;
|
|
63
|
+
/** How often the supervisor re-arms every key: whole minutes dividing an hour. Default `'1m'`. */
|
|
64
|
+
readonly rearm?: DurationInput;
|
|
65
|
+
/** How far behind `key` is, in the app's own unit. Reported on the run's progress. */
|
|
66
|
+
lag?(args: { readonly key: string; readonly cursor: C | null }): Promise<number> | number;
|
|
67
|
+
/** Refused (`X_CONSUMER_INVALID`): a consumer runs until its stream ends or a drain stops it. */
|
|
68
|
+
readonly timeout?: never;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What one run reports when its stream ends. Bounded, so `x jobs show` can print it. */
|
|
72
|
+
export interface ConsumerReport {
|
|
73
|
+
readonly key: string;
|
|
74
|
+
readonly batches: number;
|
|
75
|
+
readonly items: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ConsumerHandle extends JobHandle<ConsumerInput> {
|
|
79
|
+
/** The task re-arming every key at `rearm`. Registered at declaration under `<name>.supervisor`. */
|
|
80
|
+
readonly supervisor: TaskHandle;
|
|
81
|
+
}
|
package/src/consumer.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// `consumer()` — reading a queue, a stream or a vendor's cursor as a `job` plus a `.supervisor`
|
|
2
|
+
// `task`: a factory over `job()`, never a ninth primitive. It assembles what a hand-rolled one must
|
|
3
|
+
// remember and has no build error for — one body per key, a key per key, no timeout, a supervisor,
|
|
4
|
+
// the drain, a cursor committed with its batch. The turn itself is `consumer-loop.ts`.
|
|
5
|
+
|
|
6
|
+
import { t } from '@ultimat3/schema';
|
|
7
|
+
import { finiteDurationMs } from './clock';
|
|
8
|
+
import { ConsumerInvalidError } from './consumer-errors';
|
|
9
|
+
import { asConsumer, refuseSelfEnqueue } from './consumer-guard';
|
|
10
|
+
import { consumerRun, DEFAULT_CONSUMER_BATCH } from './consumer-loop';
|
|
11
|
+
import { stampConsumer } from './consumer-registry';
|
|
12
|
+
import type { ConsumerDefinition, ConsumerHandle, ConsumerInput } from './consumer-types';
|
|
13
|
+
import type { EnqueueResult } from './driver';
|
|
14
|
+
import type { JobHandle } from './job';
|
|
15
|
+
import { job } from './job';
|
|
16
|
+
import type { EnqueueManyOptions } from './job-enqueue-many';
|
|
17
|
+
import type { EnqueueOptions } from './outbox';
|
|
18
|
+
import { DEFAULT_RETRY } from './retry';
|
|
19
|
+
import { task } from './task';
|
|
20
|
+
|
|
21
|
+
const MINUTE_MS = 60_000;
|
|
22
|
+
|
|
23
|
+
/** `'1m'` → `* * * * *`; whole minutes dividing an hour, so every tick is evenly spaced. */
|
|
24
|
+
function rearmCron(name: string, rearm: string | number): string {
|
|
25
|
+
const ms = finiteDurationMs(rearm, `consumer "${name}"`, 'rearm');
|
|
26
|
+
const minutes = ms / MINUTE_MS;
|
|
27
|
+
if (!Number.isInteger(minutes) || minutes < 1 || 60 % minutes !== 0) {
|
|
28
|
+
throw new ConsumerInvalidError({
|
|
29
|
+
consumer: name,
|
|
30
|
+
problem: `declares rearm ${String(rearm)}, and the supervisor's cron ticks in whole minutes that divide an hour (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60)`,
|
|
31
|
+
fix: `rearm: '1m' on consumer({ name: "${name}" })`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (minutes === 60) return '0 * * * *';
|
|
35
|
+
return minutes === 1 ? '* * * * *' : `*/${minutes} * * * *`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every refusal a declaration can earn, before anything is registered under its name. */
|
|
39
|
+
function assertDeclaration<Item, C>(definition: ConsumerDefinition<Item, C>): number {
|
|
40
|
+
const name = definition.name;
|
|
41
|
+
const cursor: unknown = definition.cursor;
|
|
42
|
+
const seam = (cursor ?? {}) as { readonly load?: unknown; readonly save?: unknown };
|
|
43
|
+
if (typeof seam.load !== 'function' || typeof seam.save !== 'function') {
|
|
44
|
+
throw new ConsumerInvalidError({
|
|
45
|
+
consumer: name,
|
|
46
|
+
problem:
|
|
47
|
+
'declares no cursor.load and cursor.save, so a restart would re-read its stream from the top and no batch could commit its position',
|
|
48
|
+
fix: `cursor: { load: (key) => repo.cursorOf(key), save: (key, next) => repo.saveCursor(key, next) } on consumer({ name: "${name}" })`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if ((definition as { readonly timeout?: unknown }).timeout !== undefined) {
|
|
52
|
+
throw new ConsumerInvalidError({
|
|
53
|
+
consumer: name,
|
|
54
|
+
problem:
|
|
55
|
+
'declares a timeout, which kills a long-lived stream mid-batch on a schedule nobody chose; a run ends when its stream does, or when a drain stops it',
|
|
56
|
+
fix: `consumer({ name: "${name}", ...definition }) with the timeout field deleted`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
for (const field of ['keys', 'open', 'handle'] as const) {
|
|
60
|
+
if (typeof definition[field] !== 'function') {
|
|
61
|
+
throw new ConsumerInvalidError({
|
|
62
|
+
consumer: name,
|
|
63
|
+
problem: `declares no ${field}()`,
|
|
64
|
+
fix: `${field}: (args) => … on consumer({ name: "${name}" })`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const size = definition.batch ?? DEFAULT_CONSUMER_BATCH;
|
|
69
|
+
if (!Number.isSafeInteger(size) || size < 1) {
|
|
70
|
+
throw new ConsumerInvalidError({
|
|
71
|
+
consumer: name,
|
|
72
|
+
problem: `declares batch ${String(size)}, and a batch is a whole number of items, at least one`,
|
|
73
|
+
fix: `consumer({ name: "${name}", batch: ${DEFAULT_CONSUMER_BATCH} })`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return size;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function consumer<Item, C>(definition: ConsumerDefinition<Item, C>): ConsumerHandle {
|
|
80
|
+
const size = assertDeclaration(definition);
|
|
81
|
+
const name = definition.name;
|
|
82
|
+
const cron = rearmCron(name, definition.rearm ?? '1m');
|
|
83
|
+
const queue = definition.queue === undefined ? {} : { queue: definition.queue };
|
|
84
|
+
|
|
85
|
+
const handle: JobHandle<ConsumerInput> = job<ConsumerInput>({
|
|
86
|
+
name,
|
|
87
|
+
input: t.object({ key: t.string.min(1) }),
|
|
88
|
+
tenant: definition.tenant,
|
|
89
|
+
// A key is live while its row is queued or running, so a re-arm of a running key is deduped:
|
|
90
|
+
// the supervisor is a no-op while a run is live and a restart once it is not.
|
|
91
|
+
idempotencyKey: ({ key }) => `consumer:${name}:${key}`,
|
|
92
|
+
// The backstop the key alone cannot be: two rows of one key never run at once.
|
|
93
|
+
concurrency: { key: ({ key }) => key, limit: 1, whenBusy: 'wait' },
|
|
94
|
+
retry: definition.retry ?? DEFAULT_RETRY,
|
|
95
|
+
...queue,
|
|
96
|
+
run: (args) => asConsumer(name, () => consumerRun({ definition, size }, args)),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// The un-guarded enqueues, kept for the arm run below: it is a different job, and the only
|
|
100
|
+
// thing in the framework that enqueues a consumer.
|
|
101
|
+
const enqueue = handle.enqueue.bind(handle);
|
|
102
|
+
const enqueueMany = handle.enqueueMany.bind(handle);
|
|
103
|
+
// Guarded IN PLACE, never a wrapper object: only the object `job()` built passes `isJobHandle`.
|
|
104
|
+
Object.assign(handle, {
|
|
105
|
+
async enqueue(input: ConsumerInput, options?: EnqueueOptions): Promise<EnqueueResult> {
|
|
106
|
+
refuseSelfEnqueue(handle.name);
|
|
107
|
+
return enqueue(input, options);
|
|
108
|
+
},
|
|
109
|
+
async enqueueMany(inputs: readonly ConsumerInput[], options?: EnqueueManyOptions) {
|
|
110
|
+
refuseSelfEnqueue(handle.name);
|
|
111
|
+
return enqueueMany(inputs, options);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// `keys` may read the database, and a task's entries are synchronous — so the tick enqueues
|
|
116
|
+
// this one arm run, which asks `keys` and enqueues every key it answers.
|
|
117
|
+
const arm = job<Record<string, never>>({
|
|
118
|
+
name: `${name}.arm`,
|
|
119
|
+
input: t.object({}),
|
|
120
|
+
tenant: 'none',
|
|
121
|
+
idempotencyKey: () => `consumer:${name}:arm`,
|
|
122
|
+
retry: DEFAULT_RETRY,
|
|
123
|
+
...queue,
|
|
124
|
+
async run({ ctx, step }) {
|
|
125
|
+
const keys = await definition.keys({ ctx });
|
|
126
|
+
// A step: an arm killed after its enqueue and before its ack replays the result rather than
|
|
127
|
+
// enqueuing the keys again behind a consumer that already finished.
|
|
128
|
+
const results = await step.run('start-keys', () => enqueueMany(keys.map((key) => ({ key }))));
|
|
129
|
+
return { keys: keys.length, started: results.filter((result) => !result.deduped).length };
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const supervisor = task({
|
|
133
|
+
name: `${name}.supervisor`,
|
|
134
|
+
cron,
|
|
135
|
+
tz: 'UTC',
|
|
136
|
+
enqueue: () => [[arm, {}]],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
stampConsumer(handle, {
|
|
140
|
+
supervisor: supervisor.name,
|
|
141
|
+
arm: arm.name,
|
|
142
|
+
cron,
|
|
143
|
+
batch: size,
|
|
144
|
+
lags: definition.lag !== undefined,
|
|
145
|
+
});
|
|
146
|
+
return Object.assign(handle, { supervisor });
|
|
147
|
+
}
|
|
@@ -29,7 +29,8 @@ import {
|
|
|
29
29
|
pageLimit,
|
|
30
30
|
parseJobCursor,
|
|
31
31
|
} from './introspection';
|
|
32
|
-
import
|
|
32
|
+
import { assertResolvable, resolvedRecord } from './step-resolve';
|
|
33
|
+
import type { StepRecord, StepStore } from './steps';
|
|
33
34
|
|
|
34
35
|
export interface MemoryQueueState {
|
|
35
36
|
readonly jobs: Map<string, JobRecord>;
|
|
@@ -51,6 +52,8 @@ export interface MemoryOperator {
|
|
|
51
52
|
readonly counters: MemoryCounters;
|
|
52
53
|
/** The claim scan's question: is this queue held back? */
|
|
53
54
|
isQueuePaused(queue: string): boolean;
|
|
55
|
+
/** Does a live registry row say this worker drains? `SQL_QUEUE_DEMAND`'s `not exists`. */
|
|
56
|
+
isWorkerDraining(workerId: string): boolean;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** Newest first by `(createdAt, id)` — the keyset `SQL_JOB_LIST` orders and seeks by. */
|
|
@@ -153,6 +156,18 @@ export function memoryOperator(state: MemoryQueueState): MemoryOperator {
|
|
|
153
156
|
: matching.slice(Math.max(0, matching.length - limit));
|
|
154
157
|
},
|
|
155
158
|
|
|
159
|
+
async resolveStep(jobId, step, resolution) {
|
|
160
|
+
const before = jobs.get(jobId);
|
|
161
|
+
const record = before === undefined ? undefined : await state.steps.get(before.runId, step);
|
|
162
|
+
// Read again after the await, as `SQL_STEP_RESOLVE` reads the row it writes under: a claim
|
|
163
|
+
// that landed in between makes the job a worker's.
|
|
164
|
+
const job = jobs.get(jobId);
|
|
165
|
+
assertResolvable('memory', jobId, step, job, record);
|
|
166
|
+
const resolved = resolvedRecord(record as StepRecord, resolution, nowMs(clock));
|
|
167
|
+
await state.steps.put(resolved);
|
|
168
|
+
return resolved;
|
|
169
|
+
},
|
|
170
|
+
|
|
156
171
|
async remove(jobId) {
|
|
157
172
|
const record = jobs.get(jobId);
|
|
158
173
|
if (record === undefined) return undefined;
|
|
@@ -320,5 +335,13 @@ export function memoryOperator(state: MemoryQueueState): MemoryOperator {
|
|
|
320
335
|
rollupCounters: () => Promise.resolve(counters.rollup(nowMs(clock))),
|
|
321
336
|
};
|
|
322
337
|
|
|
323
|
-
return {
|
|
338
|
+
return {
|
|
339
|
+
members,
|
|
340
|
+
counters,
|
|
341
|
+
isQueuePaused: (queue) => queues.has(queue),
|
|
342
|
+
isWorkerDraining: (workerId) => {
|
|
343
|
+
const worker = workers.get(workerId);
|
|
344
|
+
return worker?.drainingAt !== undefined && worker.expiresAt > nowMs(clock);
|
|
345
|
+
},
|
|
346
|
+
};
|
|
324
347
|
}
|
package/src/driver-memory.ts
CHANGED
|
@@ -19,11 +19,13 @@ import type {
|
|
|
19
19
|
JobDriver,
|
|
20
20
|
JobRecord,
|
|
21
21
|
NackOptions,
|
|
22
|
+
QueueDemand,
|
|
22
23
|
QueueStats,
|
|
23
24
|
} from './driver';
|
|
24
25
|
import {
|
|
25
26
|
assertClaimBounds,
|
|
26
27
|
assertClaimQueues,
|
|
28
|
+
assertEnqueueMany,
|
|
27
29
|
DEFAULT_QUEUE,
|
|
28
30
|
LEASE_LAPSED_FINAL_ATTEMPT,
|
|
29
31
|
LIVE_STATES,
|
|
@@ -219,7 +221,7 @@ export function memoryJobDriver(options: MemoryJobDriverOptions = {}): MemoryJob
|
|
|
219
221
|
},
|
|
220
222
|
};
|
|
221
223
|
|
|
222
|
-
|
|
224
|
+
const driver: MemoryJobDriver = {
|
|
223
225
|
name: 'memory',
|
|
224
226
|
steps,
|
|
225
227
|
backfills,
|
|
@@ -275,6 +277,15 @@ export function memoryJobDriver(options: MemoryJobDriverOptions = {}): MemoryJob
|
|
|
275
277
|
return { id: record.id, runId: record.runId, deduped: false };
|
|
276
278
|
},
|
|
277
279
|
|
|
280
|
+
// A loop of this driver's own `enqueue`, which IS its semantics: in order, so a second request
|
|
281
|
+
// under a key the first just took is `deduped`, as one `SQL_ENQUEUE_MANY` statement answers.
|
|
282
|
+
async enqueueMany(requests: readonly EnqueueRequest[]): Promise<readonly EnqueueResult[]> {
|
|
283
|
+
assertEnqueueMany('memory', requests);
|
|
284
|
+
const results: EnqueueResult[] = [];
|
|
285
|
+
for (const request of requests) results.push(await driver.enqueue(request));
|
|
286
|
+
return results;
|
|
287
|
+
},
|
|
288
|
+
|
|
278
289
|
// `async`, so an empty queue list REJECTS here exactly as it does on the pg driver: a
|
|
279
290
|
// synchronous throw out of a method typed `Promise<…>` is a different answer to the same
|
|
280
291
|
// question, which is the class of divergence this pair is checked for.
|
|
@@ -431,9 +442,32 @@ export function memoryJobDriver(options: MemoryJobDriverOptions = {}): MemoryJob
|
|
|
431
442
|
);
|
|
432
443
|
},
|
|
433
444
|
|
|
445
|
+
// `SQL_QUEUE_DEMAND` over these rows. A `running` row is never "due": it counts on its claimer.
|
|
446
|
+
demand(): Promise<readonly QueueDemand[]> {
|
|
447
|
+
const at = nowMs(clock);
|
|
448
|
+
const byQueue = new Map<string, number>();
|
|
449
|
+
for (const record of jobs.values()) {
|
|
450
|
+
const held =
|
|
451
|
+
record.state === 'running' && !operator.isWorkerDraining(record.claimedBy ?? '');
|
|
452
|
+
const due =
|
|
453
|
+
(record.state === 'ready' ||
|
|
454
|
+
record.state === 'delayed' ||
|
|
455
|
+
record.state === 'suspended') &&
|
|
456
|
+
record.runAt <= at &&
|
|
457
|
+
!operator.isQueuePaused(record.queue);
|
|
458
|
+
if (held || due) byQueue.set(record.queue, (byQueue.get(record.queue) ?? 0) + 1);
|
|
459
|
+
}
|
|
460
|
+
return Promise.resolve(
|
|
461
|
+
[...byQueue]
|
|
462
|
+
.map(([queue, demand]) => ({ queue, demand }))
|
|
463
|
+
.sort((a, b) => (a.queue < b.queue ? -1 : a.queue > b.queue ? 1 : 0)),
|
|
464
|
+
);
|
|
465
|
+
},
|
|
466
|
+
|
|
434
467
|
close(): Promise<void> {
|
|
435
468
|
jobs.clear();
|
|
436
469
|
return Promise.resolve();
|
|
437
470
|
},
|
|
438
471
|
};
|
|
472
|
+
return driver;
|
|
439
473
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The claim: the one statement every worker loop runs, and the only one whose cost is a question of
|
|
2
|
+
// how far behind the queue is. Apart from `driver-pg-sql.ts`, which re-exports it, because the plan
|
|
3
|
+
// it depends on takes a page to explain. `driver-pg-claim.live.test.ts` holds that plan to account.
|
|
4
|
+
|
|
5
|
+
import { LEASE_LAPSED_FINAL_ATTEMPT } from './driver';
|
|
6
|
+
import { COUNTER_BUCKET_MS } from './introspection';
|
|
7
|
+
|
|
8
|
+
/** What both arms of the claim return: a `JobRow`, read off the updated row `j`. */
|
|
9
|
+
const CLAIM_ROW_COLUMNS = `j.id, j.name, j.queue, j.input, j.idempotency_key, j.run_id, j.attempt,
|
|
10
|
+
j.max_attempts, j.state, j.tenant_id, j.last_error, j.claimed_by, j.claims,
|
|
11
|
+
j.traceparent, j.enqueued_by,
|
|
12
|
+
(extract(epoch from j.run_at) * 1000)::bigint as run_at,
|
|
13
|
+
(extract(epoch from j.visible_at) * 1000)::bigint as visible_at,
|
|
14
|
+
(extract(epoch from j.created_at) * 1000)::bigint as created_at,
|
|
15
|
+
(extract(epoch from j.updated_at) * 1000)::bigint as updated_at`;
|
|
16
|
+
|
|
17
|
+
const CLAIM_ROW_NAMES = `id, name, queue, input, idempotency_key, run_id, attempt, max_attempts,
|
|
18
|
+
state, tenant_id, last_error, claimed_by, claims, traceparent, enqueued_by, run_at,
|
|
19
|
+
visible_at, created_at, updated_at`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The claim. SKIP LOCKED is the whole design: without it, worker 2 blocks on worker 1's row
|
|
23
|
+
* lock and throughput collapses to one worker. `visible_at` in the predicate reclaims leases
|
|
24
|
+
* abandoned by a crashed worker. `cancelled` is absent from every branch by construction — a
|
|
25
|
+
* cancelled row is terminal, so it is never handed to a worker again.
|
|
26
|
+
*
|
|
27
|
+
* TWO arms over one pick. A `running` row is picked only with a lapsed lease, and one that lapsed
|
|
28
|
+
* on its FINAL attempt (`attempt >= max_attempts` — `isFinalAttempt`, `retry.ts`) is `buried`:
|
|
29
|
+
* settled `dead` here, counted, and answered with its new state so the driver reports it instead
|
|
30
|
+
* of handing it out. Re-claimed, it was an attempt past `max_attempts` every lease, forever — a
|
|
31
|
+
* job that kills its worker took one per visibility timeout and never reached the dead letters.
|
|
32
|
+
* `claimed_by` is cleared with the lease, so the body that stalled rather than died cannot settle
|
|
33
|
+
* the row it lost. A buried row spends a slot of `$2`: the pass after it claims what it displaced.
|
|
34
|
+
* `$5` names the jobs declaring `retry.deadLetter: false`: their rows are buried `failed`.
|
|
35
|
+
*
|
|
36
|
+
* `picked` SEEKS, so a claim costs the same at any backlog. One `state in (...) or running` test
|
|
37
|
+
* over `queue = any(...)` cannot be served in `run_at` order by any index, so it read and sorted
|
|
38
|
+
* every due row, and a queue slowed down exactly when it was behind. Hence two ordered index scans
|
|
39
|
+
* per open queue, due rows and lapsed leases, then an outer sort of at most `2 x limit x queues`
|
|
40
|
+
* rows. The due scan's `state in` is `x_jobs_claim_idx`'s predicate word for word, or the planner
|
|
41
|
+
* cannot prove the index applies; the lapsed scan reads `x_jobs_depth_idx`. Measured 2026-11-02,
|
|
42
|
+
* Postgres 17, 1M `done` rows, a backlog of 100k, shared box (read the ratios): one queue
|
|
43
|
+
* 88-122 ms against 0.8 ms; three queues 130-189 ms against 0.7-0.9 ms; a noop `jobWorker` on 8
|
|
44
|
+
* slots 42-55 jobs/s against 443-493. At a backlog of 1,000 the two are level.
|
|
45
|
+
*
|
|
46
|
+
* Rows a scan locked and the outer `limit` dropped stay locked until the TRANSACTION ends, not the
|
|
47
|
+
* statement. That is correct only because a claim autocommits (`exec()` in `driver-pg.ts` is the
|
|
48
|
+
* pool), so the two coincide; a concurrent claimer may skip them for one poll, which `skip locked`
|
|
49
|
+
* already permits. A claim inside a long transaction would hold them for its whole life.
|
|
50
|
+
*
|
|
51
|
+
* `distinct` because a queue named twice is two laterals over one queue, and a row this statement
|
|
52
|
+
* already locked is not skipped: the repeat would spend `limit` on rows it had already picked.
|
|
53
|
+
*/
|
|
54
|
+
export const SQL_CLAIM = `
|
|
55
|
+
with open as (
|
|
56
|
+
select distinct q.queue
|
|
57
|
+
from unnest($1::text[]) as q(queue)
|
|
58
|
+
where not exists (
|
|
59
|
+
select 1 from x_job_pauses p where p.kind = 'queue' and p.name = q.queue
|
|
60
|
+
)
|
|
61
|
+
), picked as (
|
|
62
|
+
select id, exhausted
|
|
63
|
+
from (
|
|
64
|
+
select due.id, due.run_at, due.exhausted
|
|
65
|
+
from open
|
|
66
|
+
cross join lateral (
|
|
67
|
+
select id, run_at, false as exhausted
|
|
68
|
+
from x_jobs
|
|
69
|
+
where queue = open.queue
|
|
70
|
+
and state in ('ready', 'delayed', 'suspended')
|
|
71
|
+
and run_at <= now()
|
|
72
|
+
order by run_at
|
|
73
|
+
limit $2
|
|
74
|
+
for update skip locked
|
|
75
|
+
) due
|
|
76
|
+
union all
|
|
77
|
+
select lapsed.id, lapsed.run_at, lapsed.exhausted
|
|
78
|
+
from open
|
|
79
|
+
cross join lateral (
|
|
80
|
+
select id, run_at, attempt >= max_attempts as exhausted
|
|
81
|
+
from x_jobs
|
|
82
|
+
where queue = open.queue
|
|
83
|
+
and state = 'running' and visible_at <= now()
|
|
84
|
+
and run_at <= now()
|
|
85
|
+
order by run_at
|
|
86
|
+
limit $2
|
|
87
|
+
for update skip locked
|
|
88
|
+
) lapsed
|
|
89
|
+
) candidates
|
|
90
|
+
order by run_at
|
|
91
|
+
limit $2
|
|
92
|
+
), buried as (
|
|
93
|
+
update x_jobs j
|
|
94
|
+
set state = case when j.name = any($5::text[]) then 'failed' else 'dead' end,
|
|
95
|
+
visible_at = null,
|
|
96
|
+
claimed_by = null,
|
|
97
|
+
last_error = '${LEASE_LAPSED_FINAL_ATTEMPT}',
|
|
98
|
+
last_error_stack = null,
|
|
99
|
+
updated_at = now()
|
|
100
|
+
from picked p
|
|
101
|
+
where j.id = p.id and p.exhausted
|
|
102
|
+
returning ${CLAIM_ROW_COLUMNS}
|
|
103
|
+
), counted as (
|
|
104
|
+
insert into x_job_counters (job, bucket_ms, bucket_start, dead, failed, duration_ms)
|
|
105
|
+
select name, ${COUNTER_BUCKET_MS}, date_trunc('minute', now()),
|
|
106
|
+
(count(*) filter (where state = 'dead'))::int,
|
|
107
|
+
(count(*) filter (where state = 'failed'))::int, 0
|
|
108
|
+
from buried
|
|
109
|
+
group by name
|
|
110
|
+
on conflict (job, bucket_ms, bucket_start) do update
|
|
111
|
+
set dead = x_job_counters.dead + excluded.dead,
|
|
112
|
+
failed = x_job_counters.failed + excluded.failed
|
|
113
|
+
), claimed as (
|
|
114
|
+
update x_jobs j
|
|
115
|
+
set state = 'running',
|
|
116
|
+
attempt = j.attempt + 1,
|
|
117
|
+
claims = j.claims + 1,
|
|
118
|
+
claimed_by = $3,
|
|
119
|
+
visible_at = now() + ($4::bigint * interval '1 millisecond'),
|
|
120
|
+
updated_at = now()
|
|
121
|
+
from picked p
|
|
122
|
+
where j.id = p.id and not p.exhausted
|
|
123
|
+
returning ${CLAIM_ROW_COLUMNS}
|
|
124
|
+
)
|
|
125
|
+
select ${CLAIM_ROW_NAMES} from claimed
|
|
126
|
+
union all
|
|
127
|
+
select ${CLAIM_ROW_NAMES} from buried
|
|
128
|
+
`.trim();
|
package/src/driver-pg-ddl.ts
CHANGED
|
@@ -194,6 +194,11 @@ create table if not exists x_job_events (
|
|
|
194
194
|
create index if not exists x_job_events_lookup_idx
|
|
195
195
|
on x_job_events (name, published_at);
|
|
196
196
|
|
|
197
|
+
-- The steps parked on an event, which is what a waking publish reads. Partial, so it holds only
|
|
198
|
+
-- the open waits: without it each publish is a scan of every step ever written.
|
|
199
|
+
create index if not exists x_job_steps_waiting_idx
|
|
200
|
+
on x_job_steps (event) where status = 'waiting';
|
|
201
|
+
|
|
197
202
|
-- What the body last reported through progress, as one value: done, total, note and the instant.
|
|
198
203
|
-- A column and not a table, because it is read with the row and dies with it. Added by alter
|
|
199
204
|
-- because x_jobs shipped without it.
|
|
@@ -257,11 +262,15 @@ create table if not exists x_job_workers (
|
|
|
257
262
|
);
|
|
258
263
|
|
|
259
264
|
-- When the worker began draining (a retire, a SIGTERM, a stop) - set by its first draining
|
|
260
|
-
-- announce and kept by every later one, null while it serves. A queue-
|
|
261
|
-
-- jobs such a worker still holds out of its count (
|
|
265
|
+
-- announce and kept by every later one, null while it serves. A queue-demand autoscaler leaves the
|
|
266
|
+
-- jobs such a worker still holds out of its count (SQL_SCALER_DEMAND): its replacement cannot take
|
|
262
267
|
-- them. Added by alter because x_job_workers shipped without it.
|
|
263
268
|
alter table x_job_workers add column if not exists draining_at timestamptz;
|
|
264
269
|
|
|
270
|
+
-- The build the worker runs, the id x-ultimate-build answers with. Null on a row a worker from
|
|
271
|
+
-- before this column wrote. x deploy verify counts a queue as served only by a row of this build.
|
|
272
|
+
alter table x_job_workers add column if not exists build text;
|
|
273
|
+
|
|
265
274
|
-- Per-job history, in buckets. A settle adds to the one-minute bucket in the SAME statement that
|
|
266
275
|
-- moves the row, and the scheduler leader folds old buckets into wider ones, so the key count per
|
|
267
276
|
-- job name is fixed whatever the throughput.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// `queue_demand` on Postgres: which `x_jobs` rows a serving worker of the fleet must hold, and that
|
|
2
|
+
// count per queue. One predicate, because `SQL_SCALER_DEMAND` (a scaler reading Postgres itself) and
|
|
3
|
+
// the gauge answer one question. Only Postgres knows who drains, so no sum of gauges can replace it.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A `running` row whose claimer's live registry row says `draining_at` is out: that worker claims
|
|
7
|
+
* nothing more and finishes the job itself, so no pod a scaler adds could take it. One adopter
|
|
8
|
+
* counted them and averaged 1.84 pods where one carried the load. A claimer with no live row
|
|
9
|
+
* (killed, lapsed) stays in: the queue hands that job back and somebody must pick it up.
|
|
10
|
+
*/
|
|
11
|
+
export const QUEUE_DEMAND_ROWS = `((j.state = 'running'
|
|
12
|
+
and not exists (select 1 from x_job_workers w
|
|
13
|
+
where w.id = j.claimed_by
|
|
14
|
+
and w.draining_at is not null
|
|
15
|
+
and w.expires_at > now()))
|
|
16
|
+
or (j.state in ('ready', 'delayed', 'suspended') and j.run_at <= now()
|
|
17
|
+
and not exists (select 1 from x_job_pauses p
|
|
18
|
+
where p.kind = 'queue' and p.name = j.queue)))`;
|
|
19
|
+
|
|
20
|
+
/** Grouped, every 15 s from a worker with work about: an idle one never asks (`worker-queue-demand.ts`). */
|
|
21
|
+
export const SQL_QUEUE_DEMAND = `
|
|
22
|
+
select j.queue, count(*)::int as demand
|
|
23
|
+
from x_jobs j
|
|
24
|
+
where ${QUEUE_DEMAND_ROWS}
|
|
25
|
+
group by j.queue
|
|
26
|
+
order by j.queue collate "C"
|
|
27
|
+
`.trim();
|