@ultimat3/jobs 22.14.0 → 23.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 +129 -87
- package/README.md +329 -19
- package/package.json +6 -6
- package/src/concurrency.ts +144 -0
- package/src/counters.ts +105 -0
- package/src/describe.ts +29 -0
- package/src/driver-memory-operator.ts +297 -0
- package/src/driver-memory.ts +79 -37
- package/src/driver-nats.ts +2 -2
- package/src/driver-pg-ddl.ts +71 -0
- package/src/driver-pg-jobs-sql.ts +71 -8
- package/src/driver-pg-operator-sql.ts +303 -0
- package/src/driver-pg-operator.ts +300 -0
- package/src/driver-pg-outbox-sql.ts +110 -0
- package/src/driver-pg-rows.ts +31 -0
- package/src/driver-pg-settle-sql.ts +71 -0
- package/src/driver-pg-sql.ts +80 -129
- package/src/driver-pg-wake-sql.ts +76 -0
- package/src/driver-pg.ts +74 -34
- package/src/driver-redis.ts +2 -2
- package/src/driver.ts +96 -34
- package/src/enqueue-signal.ts +50 -0
- package/src/errors-concurrency.ts +91 -0
- package/src/errors-operator.ts +94 -0
- package/src/errors.ts +13 -17
- package/src/events-pg.ts +38 -28
- package/src/events.ts +25 -0
- package/src/execute.ts +160 -81
- package/src/export.ts +23 -4
- package/src/heartbeat.ts +9 -3
- package/src/idle-backoff.ts +74 -0
- package/src/index.ts +105 -12
- package/src/inspect-operator.ts +49 -0
- package/src/inspect.ts +42 -2
- package/src/introspection.ts +262 -0
- package/src/job.ts +78 -15
- package/src/leases.ts +49 -9
- package/src/metrics.ts +6 -1
- package/src/outbox-pg.ts +3 -0
- package/src/outbox-relay.ts +114 -39
- package/src/outbox.ts +29 -6
- package/src/progress.ts +78 -0
- package/src/queue-wake.ts +220 -0
- package/src/redact-input.ts +26 -0
- package/src/renewal-timer.ts +28 -10
- package/src/retry-classification.ts +13 -1
- package/src/retry.ts +10 -2
- package/src/run-deadline.ts +57 -0
- package/src/scheduler-leader.ts +26 -0
- package/src/scheduler-occurrences.ts +76 -0
- package/src/scheduler-pg.ts +55 -2
- package/src/scheduler-state.ts +66 -0
- package/src/scheduler.ts +143 -103
- package/src/settled.ts +161 -0
- package/src/worker-admit.ts +163 -0
- package/src/worker-fleet-slots.ts +107 -38
- package/src/worker-hand-back.ts +6 -1
- package/src/worker-key-busy.ts +79 -0
- package/src/worker-loop.ts +145 -0
- package/src/worker-registry.ts +103 -0
- package/src/worker-run.ts +9 -0
- package/src/worker-types.ts +23 -0
- package/src/worker.ts +103 -134
package/src/counters.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The per-job history counters: which bucket a settle lands in, what it adds, and how buckets
|
|
2
|
+
// age from one tier to the next. The arithmetic lives here ONCE — the memory store below runs it
|
|
3
|
+
// directly and the pg statements (`driver-pg-operator-sql.ts`) spell the same floor in SQL, so a
|
|
4
|
+
// count read under `x dev` and one read in production are the same number for the same settles.
|
|
5
|
+
|
|
6
|
+
import type { NackOptions } from './driver';
|
|
7
|
+
import { nackState } from './driver';
|
|
8
|
+
import type { CounterBucketMs, CounterOutcome, CounterTotals, JobCounter } from './introspection';
|
|
9
|
+
import { COUNTER_BUCKET_MS, COUNTER_TIERS } from './introspection';
|
|
10
|
+
|
|
11
|
+
/** The instant a bucket of `bucketMs` containing `atMs` opens. */
|
|
12
|
+
export const counterBucketStart = (atMs: number, bucketMs: number): number =>
|
|
13
|
+
Math.floor(atMs / bucketMs) * bucketMs;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a nack adds, or nothing. A shed, a suspension and a drained attempt are handed back
|
|
17
|
+
* UNCOUNTED and are not history: counting them would make every `step.sleep` a failure.
|
|
18
|
+
*/
|
|
19
|
+
export function nackOutcome(options: NackOptions): CounterOutcome | undefined {
|
|
20
|
+
const state = nackState(options);
|
|
21
|
+
if (state === 'dead') return 'dead';
|
|
22
|
+
if (state === 'failed') return 'failed';
|
|
23
|
+
return state === 'ready' && options.countsAsAttempt !== false ? 'retried' : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Counts = Omit<JobCounter, 'job' | 'bucketStart' | 'bucketMs'>;
|
|
27
|
+
|
|
28
|
+
const ZERO: Counts = Object.freeze({ done: 0, retried: 0, failed: 0, dead: 0, durationMs: 0 });
|
|
29
|
+
|
|
30
|
+
const sum = (a: Counts, b: Counts): Counts => ({
|
|
31
|
+
done: a.done + b.done,
|
|
32
|
+
retried: a.retried + b.retried,
|
|
33
|
+
failed: a.failed + b.failed,
|
|
34
|
+
dead: a.dead + b.dead,
|
|
35
|
+
durationMs: a.durationMs + b.durationMs,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
39
|
+
|
|
40
|
+
export interface MemoryCounters {
|
|
41
|
+
add(job: string, outcome: CounterOutcome, durationMs: number, atMs: number): void;
|
|
42
|
+
list(query: { readonly job: string; readonly sinceMs: number }): readonly JobCounter[];
|
|
43
|
+
totals(sinceMs: number): readonly CounterTotals[];
|
|
44
|
+
rollup(atMs: number): number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The memory driver's counters. Same buckets, same tiers, same fold as `x_job_counters`. */
|
|
48
|
+
export function createMemoryCounters(): MemoryCounters {
|
|
49
|
+
const buckets = new Map<string, JobCounter>();
|
|
50
|
+
const keyOf = (job: string, bucketMs: number, bucketStart: number): string =>
|
|
51
|
+
`${bucketMs}\u0000${bucketStart}\u0000${job}`;
|
|
52
|
+
|
|
53
|
+
const merge = (
|
|
54
|
+
job: string,
|
|
55
|
+
bucketMs: CounterBucketMs,
|
|
56
|
+
bucketStart: number,
|
|
57
|
+
add: Counts,
|
|
58
|
+
): void => {
|
|
59
|
+
const key = keyOf(job, bucketMs, bucketStart);
|
|
60
|
+
const current = buckets.get(key);
|
|
61
|
+
buckets.set(key, { job, bucketMs, bucketStart, ...sum(current ?? ZERO, add) });
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
add(job, outcome, durationMs, atMs) {
|
|
66
|
+
const start = counterBucketStart(atMs, COUNTER_BUCKET_MS);
|
|
67
|
+
merge(job, COUNTER_BUCKET_MS, start, { ...ZERO, [outcome]: 1, durationMs });
|
|
68
|
+
},
|
|
69
|
+
list({ job, sinceMs }) {
|
|
70
|
+
return [...buckets.values()]
|
|
71
|
+
.filter((bucket) => bucket.job === job && bucket.bucketStart >= sinceMs)
|
|
72
|
+
.sort((a, b) => a.bucketStart - b.bucketStart || a.bucketMs - b.bucketMs);
|
|
73
|
+
},
|
|
74
|
+
totals(sinceMs) {
|
|
75
|
+
const byJob = new Map<string, Counts>();
|
|
76
|
+
for (const bucket of buckets.values()) {
|
|
77
|
+
if (bucket.bucketStart < sinceMs) continue;
|
|
78
|
+
byJob.set(bucket.job, sum(byJob.get(bucket.job) ?? ZERO, bucket));
|
|
79
|
+
}
|
|
80
|
+
return [...byJob.entries()]
|
|
81
|
+
.sort(([a], [b]) => byName(a, b))
|
|
82
|
+
.map(([job, counts]) => ({ job, ...sum(ZERO, counts) }));
|
|
83
|
+
},
|
|
84
|
+
rollup(atMs) {
|
|
85
|
+
let moved = 0;
|
|
86
|
+
for (const [index, tier] of COUNTER_TIERS.entries()) {
|
|
87
|
+
const next = COUNTER_TIERS[index + 1];
|
|
88
|
+
for (const [key, bucket] of [...buckets]) {
|
|
89
|
+
if (bucket.bucketMs !== tier.bucketMs || bucket.bucketStart >= atMs - tier.keepMs)
|
|
90
|
+
continue;
|
|
91
|
+
buckets.delete(key);
|
|
92
|
+
moved += 1;
|
|
93
|
+
if (next === undefined) continue;
|
|
94
|
+
merge(
|
|
95
|
+
bucket.job,
|
|
96
|
+
next.bucketMs,
|
|
97
|
+
counterBucketStart(bucket.bucketStart, next.bucketMs),
|
|
98
|
+
bucket,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return moved;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/describe.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// clock- or iteration-order-dependent in this shape is a spurious change on every build.
|
|
4
4
|
|
|
5
5
|
import { toJsonSchema } from '@ultimat3/schema';
|
|
6
|
+
import type { WhenBusy } from './concurrency';
|
|
6
7
|
import type { BackoffStrategy, RetryPolicy } from './retry';
|
|
7
8
|
import { DEFAULT_RETRY } from './retry';
|
|
8
9
|
|
|
@@ -22,6 +23,21 @@ export interface JobDescriptor {
|
|
|
22
23
|
* is app data, so a descriptor carrying it would put customer ids in `x.manifest.json`.
|
|
23
24
|
*/
|
|
24
25
|
readonly idempotent: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* The declared fleet-wide cap, or `null` for none. `keyed: true` says `limit` holds per
|
|
28
|
+
* `concurrency.key(input)` rather than for the whole job, and `whenBusy` is what a claim over
|
|
29
|
+
* it does — `null` for a plain number, which always waits. The KEY never crosses, for
|
|
30
|
+
* `idempotent`'s reason: it is computed from an input. `x jobs show <id>` reports one run's.
|
|
31
|
+
*/
|
|
32
|
+
readonly concurrency: JobConcurrencyDescriptor | null;
|
|
33
|
+
/** Whether the job declares an `onSettled` hook — what is told how each run ended. */
|
|
34
|
+
readonly onSettled: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface JobConcurrencyDescriptor {
|
|
38
|
+
readonly limit: number;
|
|
39
|
+
readonly keyed: boolean;
|
|
40
|
+
readonly whenBusy: WhenBusy | null;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
/**
|
|
@@ -40,6 +56,10 @@ export interface DescribableJob {
|
|
|
40
56
|
* which is the exact wrong answer the `/_x` jobs panel used to give for every job.
|
|
41
57
|
*/
|
|
42
58
|
readonly idempotencyKeyFor: unknown;
|
|
59
|
+
/** `JobHandle.concurrency` and `.whenBusy` — the cap as `job()` resolved it. */
|
|
60
|
+
readonly concurrency: number | undefined;
|
|
61
|
+
readonly whenBusy: WhenBusy | undefined;
|
|
62
|
+
readonly declaresOnSettled: boolean;
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
export function describeJob(handle: DescribableJob): JobDescriptor {
|
|
@@ -55,6 +75,15 @@ export function describeJob(handle: DescribableJob): JobDescriptor {
|
|
|
55
75
|
// not statically knowable. `inspect(name)` reports the steps an actual run recorded.
|
|
56
76
|
steps: [],
|
|
57
77
|
idempotent: typeof handle.idempotencyKeyFor === 'function',
|
|
78
|
+
concurrency:
|
|
79
|
+
handle.concurrency === undefined
|
|
80
|
+
? null
|
|
81
|
+
: {
|
|
82
|
+
limit: handle.concurrency,
|
|
83
|
+
keyed: handle.whenBusy !== undefined,
|
|
84
|
+
whenBusy: handle.whenBusy ?? null,
|
|
85
|
+
},
|
|
86
|
+
onSettled: handle.declaresOnSettled,
|
|
58
87
|
};
|
|
59
88
|
}
|
|
60
89
|
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// The memory driver's operator surface: paging, bulk, pause, promote, the worker registry,
|
|
2
|
+
// progress and counters, over the SAME map the claim loop reads. Apart from `driver-memory.ts`
|
|
3
|
+
// because that file is the six-method queue; this is everything `JobIntrospection` added, written
|
|
4
|
+
// to answer exactly as the pg statements in `driver-pg-operator-sql.ts` do.
|
|
5
|
+
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import { assert, finiteOption } from '@ultimat3/core';
|
|
8
|
+
import { nowMs } from './clock';
|
|
9
|
+
import type { MemoryCounters } from './counters';
|
|
10
|
+
import { createMemoryCounters } from './counters';
|
|
11
|
+
import type { JobRecord } from './driver';
|
|
12
|
+
import { REQUEUEABLE_STATES } from './driver';
|
|
13
|
+
import { signalEnqueued } from './enqueue-signal';
|
|
14
|
+
import { JobNotRemovableError } from './errors-operator';
|
|
15
|
+
import type {
|
|
16
|
+
BulkFilter,
|
|
17
|
+
BulkResult,
|
|
18
|
+
JobFilter,
|
|
19
|
+
JobIntrospection,
|
|
20
|
+
PausedName,
|
|
21
|
+
TaskFire,
|
|
22
|
+
WorkerRecord,
|
|
23
|
+
} from './introspection';
|
|
24
|
+
import {
|
|
25
|
+
MAX_BULK_ROWS,
|
|
26
|
+
MAX_TASK_FIRES,
|
|
27
|
+
MAX_WORKER_IN_FLIGHT,
|
|
28
|
+
PROMOTABLE_STATES,
|
|
29
|
+
pageLimit,
|
|
30
|
+
parseJobCursor,
|
|
31
|
+
} from './introspection';
|
|
32
|
+
import type { StepStore } from './steps';
|
|
33
|
+
|
|
34
|
+
export interface MemoryQueueState {
|
|
35
|
+
readonly jobs: Map<string, JobRecord>;
|
|
36
|
+
readonly steps: StepStore;
|
|
37
|
+
readonly clock: Clock;
|
|
38
|
+
/** The LIVE row holding this record's `(name, tenant, key)`, other than itself. */
|
|
39
|
+
liveHolder(record: JobRecord): JobRecord | undefined;
|
|
40
|
+
/** Rewrite a row and release its claim — the driver's own `settle`. */
|
|
41
|
+
settle(id: string, patch: Partial<JobRecord>): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type MemoryOperatorMembers = Omit<
|
|
45
|
+
JobIntrospection,
|
|
46
|
+
'job' | 'deadLetters' | 'requeue' | 'cancel'
|
|
47
|
+
>;
|
|
48
|
+
|
|
49
|
+
export interface MemoryOperator {
|
|
50
|
+
readonly members: MemoryOperatorMembers;
|
|
51
|
+
readonly counters: MemoryCounters;
|
|
52
|
+
/** The claim scan's question: is this queue held back? */
|
|
53
|
+
isQueuePaused(queue: string): boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Newest first by `(createdAt, id)` — the keyset `SQL_JOB_LIST` orders and seeks by. */
|
|
57
|
+
const newestFirst = (a: JobRecord, b: JobRecord): number =>
|
|
58
|
+
b.createdAt - a.createdAt || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0);
|
|
59
|
+
|
|
60
|
+
const inBulk = (record: JobRecord, filter: BulkFilter): boolean =>
|
|
61
|
+
record.state === filter.state &&
|
|
62
|
+
(filter.queue === undefined || record.queue === filter.queue) &&
|
|
63
|
+
(filter.name === undefined || record.name === filter.name) &&
|
|
64
|
+
(filter.tenantId === undefined || record.tenantId === filter.tenantId);
|
|
65
|
+
|
|
66
|
+
type Keyset = Pick<JobRecord, 'createdAt' | 'id'>;
|
|
67
|
+
|
|
68
|
+
/** `a` strictly newer than `b` by `(createdAt, id)`, in the keyset's own order. */
|
|
69
|
+
const newerThan = (a: Keyset, b: Keyset): boolean =>
|
|
70
|
+
a.createdAt > b.createdAt || (a.createdAt === b.createdAt && a.id > b.id);
|
|
71
|
+
|
|
72
|
+
const byName = (a: PausedName, b: PausedName): number =>
|
|
73
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
74
|
+
|
|
75
|
+
function pauseSet(clock: Clock): {
|
|
76
|
+
pause(name: string): void;
|
|
77
|
+
resume(name: string): void;
|
|
78
|
+
has(name: string): boolean;
|
|
79
|
+
list(): readonly PausedName[];
|
|
80
|
+
} {
|
|
81
|
+
const paused = new Map<string, number>();
|
|
82
|
+
return {
|
|
83
|
+
// The FIRST pause is the one recorded, as `on conflict do nothing` keeps it in pg.
|
|
84
|
+
pause: (name) => {
|
|
85
|
+
if (!paused.has(name)) paused.set(name, nowMs(clock));
|
|
86
|
+
},
|
|
87
|
+
resume: (name) => {
|
|
88
|
+
paused.delete(name);
|
|
89
|
+
},
|
|
90
|
+
has: (name) => paused.has(name),
|
|
91
|
+
list: () => [...paused].map(([name, pausedAt]) => ({ name, pausedAt })).sort(byName),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createMemoryOperator(state: MemoryQueueState): MemoryOperator {
|
|
96
|
+
const { jobs, clock } = state;
|
|
97
|
+
const counters = createMemoryCounters();
|
|
98
|
+
const queues = pauseSet(clock);
|
|
99
|
+
const tasks = pauseSet(clock);
|
|
100
|
+
const workers = new Map<string, WorkerRecord & { readonly expiresAt: number }>();
|
|
101
|
+
const fires = new Map<string, TaskFire>();
|
|
102
|
+
|
|
103
|
+
// `eligible` is the verb's own predicate, applied BEFORE the bound as the pg statement's `where`
|
|
104
|
+
// is applied before its `limit`: rows the verb would skip never spend the bound.
|
|
105
|
+
const bulk = async (
|
|
106
|
+
filter: BulkFilter,
|
|
107
|
+
apply: (record: JobRecord) => Promise<boolean> | boolean,
|
|
108
|
+
eligible: (record: JobRecord) => boolean = () => true,
|
|
109
|
+
): Promise<BulkResult> => {
|
|
110
|
+
let affected = 0;
|
|
111
|
+
const matching = [...jobs.values()].filter(
|
|
112
|
+
(record) => inBulk(record, filter) && eligible(record),
|
|
113
|
+
);
|
|
114
|
+
for (const record of matching.sort(newestFirst).reverse().slice(0, MAX_BULK_ROWS)) {
|
|
115
|
+
if (await apply(record)) affected += 1;
|
|
116
|
+
}
|
|
117
|
+
const remaining = [...jobs.values()].filter((record) => inBulk(record, filter)).length;
|
|
118
|
+
return { affected, remaining };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const members: MemoryOperatorMembers = {
|
|
122
|
+
// `async`, so a refused bound REJECTS here exactly as it does on the pg driver.
|
|
123
|
+
async list(filter: JobFilter = {}) {
|
|
124
|
+
const limit = pageLimit('the memory driver list', filter.limit);
|
|
125
|
+
assert(
|
|
126
|
+
filter.after === undefined || filter.before === undefined,
|
|
127
|
+
'list was asked for a page both after and before a cursor',
|
|
128
|
+
'pass one of after: jobCursor(lastRow) or before: jobCursor(firstRow)',
|
|
129
|
+
);
|
|
130
|
+
const after =
|
|
131
|
+
filter.after === undefined
|
|
132
|
+
? undefined
|
|
133
|
+
: parseJobCursor(filter.after, 'the memory driver list');
|
|
134
|
+
const before =
|
|
135
|
+
filter.before === undefined
|
|
136
|
+
? undefined
|
|
137
|
+
: parseJobCursor(filter.before, 'the memory driver list');
|
|
138
|
+
const { idPrefix, createdFrom, createdTo, tenantId } = filter;
|
|
139
|
+
const matching = [...jobs.values()]
|
|
140
|
+
.filter((record) => filter.queue === undefined || record.queue === filter.queue)
|
|
141
|
+
.filter((record) => filter.name === undefined || record.name === filter.name)
|
|
142
|
+
.filter((record) => filter.state === undefined || record.state === filter.state)
|
|
143
|
+
.filter((record) => tenantId === undefined || record.tenantId === tenantId)
|
|
144
|
+
.filter((record) => idPrefix === undefined || record.id.startsWith(idPrefix))
|
|
145
|
+
.filter((record) => createdFrom === undefined || record.createdAt >= createdFrom)
|
|
146
|
+
.filter((record) => createdTo === undefined || record.createdAt < createdTo)
|
|
147
|
+
.filter((record) => after === undefined || newerThan(after, record))
|
|
148
|
+
.filter((record) => before === undefined || newerThan(record, before))
|
|
149
|
+
.sort(newestFirst);
|
|
150
|
+
// Backwards: the rows NEAREST the cursor, which are the oldest of the newer ones.
|
|
151
|
+
return before === undefined
|
|
152
|
+
? matching.slice(0, limit)
|
|
153
|
+
: matching.slice(Math.max(0, matching.length - limit));
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async remove(jobId) {
|
|
157
|
+
const record = jobs.get(jobId);
|
|
158
|
+
if (record === undefined) return undefined;
|
|
159
|
+
if (record.state === 'running') throw new JobNotRemovableError({ jobId });
|
|
160
|
+
jobs.delete(jobId);
|
|
161
|
+
await state.steps.clear(record.runId);
|
|
162
|
+
return record;
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async requeueMany(filter) {
|
|
166
|
+
assert(
|
|
167
|
+
REQUEUEABLE_STATES.has(filter.state),
|
|
168
|
+
`requeueMany was asked for ${filter.state} jobs, and only a finished job can be requeued`,
|
|
169
|
+
"pass state: 'dead', 'failed', 'cancelled' or 'done' to requeueMany",
|
|
170
|
+
);
|
|
171
|
+
const result = await bulk(filter, (record) => {
|
|
172
|
+
// A key a live job holds stays where it is — the single requeue's `X_JOB_DUPLICATE`,
|
|
173
|
+
// answered as "not this one" so one conflict does not fail a thousand rows.
|
|
174
|
+
if (state.liveHolder(record) !== undefined) return false;
|
|
175
|
+
state.settle(record.id, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
// The pg driver's `SQL_WAKE`, in-process: an operator's repair starts now.
|
|
179
|
+
if (result.affected > 0) signalEnqueued(filter.queue);
|
|
180
|
+
return result;
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async promoteMany(filter) {
|
|
184
|
+
assert(
|
|
185
|
+
PROMOTABLE_STATES.has(filter.state),
|
|
186
|
+
`promoteMany was asked for ${filter.state} jobs, and only a job waiting on its run time can be promoted`,
|
|
187
|
+
"pass state: 'delayed' or 'ready' to promoteMany",
|
|
188
|
+
);
|
|
189
|
+
const at = nowMs(clock);
|
|
190
|
+
const result = await bulk(
|
|
191
|
+
filter,
|
|
192
|
+
(record) => {
|
|
193
|
+
jobs.set(record.id, { ...record, state: 'ready', runAt: at, updatedAt: at });
|
|
194
|
+
return true;
|
|
195
|
+
},
|
|
196
|
+
(record) => record.runAt > at,
|
|
197
|
+
);
|
|
198
|
+
if (result.affected > 0) signalEnqueued(filter.queue);
|
|
199
|
+
// Rows of the filter still waiting — a due `ready` row has nothing left to promote.
|
|
200
|
+
const waiting = [...jobs.values()].filter(
|
|
201
|
+
(record) => inBulk(record, filter) && record.runAt > at,
|
|
202
|
+
).length;
|
|
203
|
+
return { affected: result.affected, remaining: waiting };
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
async removeMany(filter) {
|
|
207
|
+
if (filter.state === 'running') throw new JobNotRemovableError({});
|
|
208
|
+
return bulk(filter, async (record) => {
|
|
209
|
+
jobs.delete(record.id);
|
|
210
|
+
await state.steps.clear(record.runId);
|
|
211
|
+
return true;
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
promote(jobId) {
|
|
216
|
+
const record = jobs.get(jobId);
|
|
217
|
+
const at = nowMs(clock);
|
|
218
|
+
const waiting =
|
|
219
|
+
record !== undefined &&
|
|
220
|
+
(record.state === 'ready' || record.state === 'delayed') &&
|
|
221
|
+
record.runAt > at;
|
|
222
|
+
if (record === undefined || !waiting) return Promise.resolve(undefined);
|
|
223
|
+
const next: JobRecord = { ...record, state: 'ready', runAt: at, updatedAt: at };
|
|
224
|
+
jobs.set(jobId, next);
|
|
225
|
+
signalEnqueued(next.queue);
|
|
226
|
+
return Promise.resolve(next);
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
pauseQueue: (queue) => Promise.resolve(queues.pause(queue)),
|
|
230
|
+
resumeQueue(queue) {
|
|
231
|
+
queues.resume(queue);
|
|
232
|
+
signalEnqueued(queue);
|
|
233
|
+
return Promise.resolve();
|
|
234
|
+
},
|
|
235
|
+
pausedQueues: () => Promise.resolve(queues.list()),
|
|
236
|
+
pauseTask: (task) => Promise.resolve(tasks.pause(task)),
|
|
237
|
+
resumeTask: (task) => Promise.resolve(tasks.resume(task)),
|
|
238
|
+
pausedTasks: () => Promise.resolve(tasks.list()),
|
|
239
|
+
|
|
240
|
+
recordTaskFire({ task, occurrenceMs }) {
|
|
241
|
+
fires.set(task, { task, occurrenceMs, firedAt: nowMs(clock) });
|
|
242
|
+
return Promise.resolve();
|
|
243
|
+
},
|
|
244
|
+
taskFires: () =>
|
|
245
|
+
Promise.resolve(
|
|
246
|
+
[...fires.values()]
|
|
247
|
+
// Code units, as `collate "C"` orders them in `SQL_TASK_FIRES`.
|
|
248
|
+
.sort((a, b) => (a.task < b.task ? -1 : a.task > b.task ? 1 : 0))
|
|
249
|
+
.slice(0, MAX_TASK_FIRES),
|
|
250
|
+
),
|
|
251
|
+
|
|
252
|
+
announceWorker(worker, ttlMs) {
|
|
253
|
+
const at = nowMs(clock);
|
|
254
|
+
workers.set(worker.id, {
|
|
255
|
+
...worker,
|
|
256
|
+
queues: [...worker.queues],
|
|
257
|
+
inFlight: worker.inFlight.slice(0, MAX_WORKER_IN_FLIGHT),
|
|
258
|
+
heartbeatAt: at,
|
|
259
|
+
expiresAt: at + finiteOption('the memory worker registry', 'ttlMs', ttlMs),
|
|
260
|
+
});
|
|
261
|
+
return Promise.resolve();
|
|
262
|
+
},
|
|
263
|
+
forgetWorker(workerId) {
|
|
264
|
+
workers.delete(workerId);
|
|
265
|
+
return Promise.resolve();
|
|
266
|
+
},
|
|
267
|
+
workers() {
|
|
268
|
+
const at = nowMs(clock);
|
|
269
|
+
// Expired by reading, never by cleanup: a killed worker announces nothing and is gone.
|
|
270
|
+
for (const [id, worker] of workers) if (worker.expiresAt <= at) workers.delete(id);
|
|
271
|
+
return Promise.resolve(
|
|
272
|
+
[...workers.values()]
|
|
273
|
+
.map(({ expiresAt: _expiresAt, ...worker }) => worker)
|
|
274
|
+
.sort((a, b) => a.startedAt - b.startedAt || (a.id < b.id ? -1 : 1)),
|
|
275
|
+
);
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
recordProgress(jobId, by, progress) {
|
|
279
|
+
const record = jobs.get(jobId);
|
|
280
|
+
// The settle's fence: progress from a claim the row no longer carries is dropped.
|
|
281
|
+
if (
|
|
282
|
+
record?.state === 'running' &&
|
|
283
|
+
record.claimedBy === by.workerId &&
|
|
284
|
+
record.claim === by.claim
|
|
285
|
+
) {
|
|
286
|
+
jobs.set(jobId, { ...record, progress });
|
|
287
|
+
}
|
|
288
|
+
return Promise.resolve();
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
counters: (query) => Promise.resolve(counters.list(query)),
|
|
292
|
+
counterTotals: (sinceMs) => Promise.resolve(counters.totals(sinceMs)),
|
|
293
|
+
rollupCounters: () => Promise.resolve(counters.rollup(nowMs(clock))),
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return { members, counters, isQueuePaused: (queue) => queues.has(queue) };
|
|
297
|
+
}
|
package/src/driver-memory.ts
CHANGED
|
@@ -7,22 +7,33 @@ import { assert, finiteCount, systemClock, uuid } from '@ultimat3/core';
|
|
|
7
7
|
import type { BackfillLedger } from './backfill-ledger';
|
|
8
8
|
import { createMemoryBackfillLedger } from './backfill-ledger';
|
|
9
9
|
import { nowMs } from './clock';
|
|
10
|
+
import { nackOutcome } from './counters';
|
|
10
11
|
import type {
|
|
12
|
+
AckOptions,
|
|
11
13
|
ClaimedJob,
|
|
14
|
+
ClaimIdentity,
|
|
12
15
|
ClaimOptions,
|
|
13
16
|
EnqueueRequest,
|
|
14
17
|
EnqueueResult,
|
|
15
18
|
HeartbeatOptions,
|
|
16
19
|
JobDriver,
|
|
17
|
-
JobFilter,
|
|
18
|
-
JobIntrospection,
|
|
19
20
|
JobRecord,
|
|
20
21
|
NackOptions,
|
|
21
22
|
QueueStats,
|
|
22
23
|
} from './driver';
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
assertClaimBounds,
|
|
26
|
+
assertClaimQueues,
|
|
27
|
+
DEFAULT_QUEUE,
|
|
28
|
+
nackState,
|
|
29
|
+
REQUEUEABLE_STATES,
|
|
30
|
+
} from './driver';
|
|
31
|
+
import { createMemoryOperator } from './driver-memory-operator';
|
|
32
|
+
import { signalEnqueued } from './enqueue-signal';
|
|
24
33
|
import { JobDuplicateError } from './errors';
|
|
25
34
|
import { JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
|
|
35
|
+
import type { JobIntrospection } from './introspection';
|
|
36
|
+
import { MAX_ERROR_STACK_LENGTH } from './introspection';
|
|
26
37
|
import type { LeaseStore } from './leases';
|
|
27
38
|
import { createMemoryLeaseStore } from './leases';
|
|
28
39
|
import type { StepStore } from './steps';
|
|
@@ -100,25 +111,32 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
100
111
|
jobs.set(id, { ...released, ...patch, updatedAt: nowMs(clock) });
|
|
101
112
|
};
|
|
102
113
|
|
|
114
|
+
const operator = createMemoryOperator({
|
|
115
|
+
jobs,
|
|
116
|
+
steps,
|
|
117
|
+
clock,
|
|
118
|
+
settle,
|
|
119
|
+
liveHolder: (record) => {
|
|
120
|
+
const holder = liveByKey(record.name, record.idempotencyKey, record.tenantId);
|
|
121
|
+
return holder === undefined || holder.id === record.id ? undefined : holder;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** The row, when this worker still holds it `running` — the fence both settles share. */
|
|
126
|
+
const heldBy = (jobId: string, by: ClaimIdentity): JobRecord | undefined => {
|
|
127
|
+
const record = jobs.get(jobId);
|
|
128
|
+
return record?.state === 'running' &&
|
|
129
|
+
record.claimedBy === by.workerId &&
|
|
130
|
+
record.claim === by.claim
|
|
131
|
+
? record
|
|
132
|
+
: undefined;
|
|
133
|
+
};
|
|
134
|
+
|
|
103
135
|
const introspect: JobIntrospection = {
|
|
104
136
|
job(jobId) {
|
|
105
137
|
return Promise.resolve(jobs.get(jobId));
|
|
106
138
|
},
|
|
107
|
-
|
|
108
|
-
// pg driver, and a synchronous throw out of a method typed `Promise<…>` is a second answer to
|
|
109
|
-
// one question.
|
|
110
|
-
async list(filter: JobFilter = {}) {
|
|
111
|
-
const rows = [...jobs.values()]
|
|
112
|
-
.filter((record) => filter.queue === undefined || record.queue === filter.queue)
|
|
113
|
-
.filter((record) => filter.name === undefined || record.name === filter.name)
|
|
114
|
-
.filter((record) => filter.state === undefined || record.state === filter.state)
|
|
115
|
-
// NEWEST first, as `createPgDriver`'s `order by created_at desc` is. Ascending here meant
|
|
116
|
-
// `x jobs ls` answered one thing against `x dev` and the opposite in production — and,
|
|
117
|
-
// because the limit is applied after the sort, a default page of the hundred OLDEST rows.
|
|
118
|
-
.sort((a, b) => b.createdAt - a.createdAt)
|
|
119
|
-
.slice(0, finiteCount('the memory driver list', 'limit', filter.limit ?? 100));
|
|
120
|
-
return rows;
|
|
121
|
-
},
|
|
139
|
+
...operator.members,
|
|
122
140
|
async deadLetters(limit = 100) {
|
|
123
141
|
const rows = [...jobs.values()]
|
|
124
142
|
.filter((record) => record.state === 'dead')
|
|
@@ -158,6 +176,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
158
176
|
}
|
|
159
177
|
// `settle` releases the claim — `SQL_JOB_REQUEUE` writes `claimed_by = null` too.
|
|
160
178
|
settle(jobId, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
|
|
179
|
+
signalEnqueued(record.queue);
|
|
161
180
|
const next = jobs.get(jobId);
|
|
162
181
|
return next ?? record;
|
|
163
182
|
},
|
|
@@ -190,6 +209,12 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
190
209
|
// `Promise<…>` is a second answer to one question — caught by different code, and an
|
|
191
210
|
// unhandled exception rather than a settled promise wherever the caller holds the promise.
|
|
192
211
|
async enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
|
|
212
|
+
// A caller-allocated id that already names a row is that row's publish, repeated: the same
|
|
213
|
+
// job, whatever state it has reached — `SQL_ENQUEUE`'s `not exists` is the pg half.
|
|
214
|
+
const published = request.id === undefined ? undefined : jobs.get(request.id);
|
|
215
|
+
if (published !== undefined) {
|
|
216
|
+
return { id: published.id, runId: published.runId, deduped: true };
|
|
217
|
+
}
|
|
193
218
|
const existing = liveByKey(request.name, request.idempotencyKey, request.tenantId);
|
|
194
219
|
if (existing !== undefined) {
|
|
195
220
|
if (request.onConflict === 'error') {
|
|
@@ -205,10 +230,13 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
205
230
|
const at = nowMs(clock);
|
|
206
231
|
const runAt = request.runAt ?? at;
|
|
207
232
|
const record: JobRecord = {
|
|
208
|
-
id: uuid(),
|
|
233
|
+
id: request.id ?? uuid(),
|
|
209
234
|
name: request.name,
|
|
210
235
|
queue: request.queue || DEFAULT_QUEUE,
|
|
211
|
-
|
|
236
|
+
// Through JSON, exactly as the pg driver binds it (`JSON.stringify(request.input ?? null)`):
|
|
237
|
+
// a live reference kept a `Date`, an `undefined` member and a non-enumerable property the
|
|
238
|
+
// queue in production never stores, so a job that read one passed here and failed there.
|
|
239
|
+
input: JSON.parse(JSON.stringify(request.input ?? null)) as unknown,
|
|
212
240
|
idempotencyKey: request.idempotencyKey,
|
|
213
241
|
runId: request.runId ?? uuid(),
|
|
214
242
|
attempt: 0,
|
|
@@ -235,6 +263,8 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
235
263
|
const wanted = new Set(claimOptions.queues);
|
|
236
264
|
const claimable = [...jobs.values()]
|
|
237
265
|
.filter((record) => wanted.has(record.queue))
|
|
266
|
+
// A paused queue is never claimed — `SQL_CLAIM`'s `not exists` over `x_job_pauses`.
|
|
267
|
+
.filter((record) => !operator.isQueuePaused(record.queue))
|
|
238
268
|
.filter((record) => {
|
|
239
269
|
if (record.runAt > at) return false;
|
|
240
270
|
if (record.state === 'ready' || record.state === 'delayed') return true;
|
|
@@ -252,6 +282,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
252
282
|
state: 'running',
|
|
253
283
|
attempt: record.attempt + 1,
|
|
254
284
|
claimedBy: claimOptions.workerId,
|
|
285
|
+
claim: (record.claim ?? 0) + 1,
|
|
255
286
|
claimedAt: at,
|
|
256
287
|
visibleAt: at + claimOptions.visibilityTimeoutMs,
|
|
257
288
|
updatedAt: at,
|
|
@@ -262,29 +293,29 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
262
293
|
return Promise.resolve(out);
|
|
263
294
|
},
|
|
264
295
|
|
|
265
|
-
// Both settlements are FENCED on `running
|
|
266
|
-
// whose job was cancelled — or whose lease lapsed and whose job another
|
|
267
|
-
// would otherwise overwrite a row it no longer owns.
|
|
268
|
-
|
|
269
|
-
|
|
296
|
+
// Both settlements are FENCED on `running` AND on the claimer, as `SQL_ACK`/`SQL_NACK` are: an
|
|
297
|
+
// ack from a worker whose job was cancelled — or whose lease lapsed and whose job another
|
|
298
|
+
// worker re-claimed — would otherwise overwrite a row it no longer owns. The counter moves in
|
|
299
|
+
// the same step as the row, so the two cannot disagree.
|
|
300
|
+
ack(jobId: string, by: AckOptions): Promise<boolean> {
|
|
301
|
+
const record = heldBy(jobId, by);
|
|
302
|
+
if (record === undefined) return Promise.resolve(false);
|
|
270
303
|
settle(jobId, { state: 'done' });
|
|
271
|
-
|
|
304
|
+
if (by.counted !== false) {
|
|
305
|
+
operator.counters.add(record.name, 'done', by.durationMs ?? 0, nowMs(clock));
|
|
306
|
+
}
|
|
307
|
+
return Promise.resolve(true);
|
|
272
308
|
},
|
|
273
309
|
|
|
274
|
-
nack(jobId: string, nackOptions: NackOptions): Promise<
|
|
275
|
-
const record =
|
|
276
|
-
if (record === undefined
|
|
310
|
+
nack(jobId: string, nackOptions: NackOptions): Promise<boolean> {
|
|
311
|
+
const record = heldBy(jobId, nackOptions);
|
|
312
|
+
if (record === undefined) return Promise.resolve(false);
|
|
277
313
|
const at = nowMs(clock);
|
|
278
314
|
const counts = nackOptions.countsAsAttempt !== false;
|
|
279
315
|
const patch: Partial<JobRecord> = {
|
|
280
316
|
// `park`, never `counts`: parking is what leaves the ready bucket, and burning an attempt
|
|
281
317
|
// is a separate fact. A shed sets neither and stays `ready`, which is what it is.
|
|
282
|
-
state:
|
|
283
|
-
nackOptions.deadLetter === true
|
|
284
|
-
? 'dead'
|
|
285
|
-
: nackOptions.park === true
|
|
286
|
-
? 'suspended'
|
|
287
|
-
: 'ready',
|
|
318
|
+
state: nackState(nackOptions),
|
|
288
319
|
runAt: at + nackOptions.delayMs,
|
|
289
320
|
// A suspension must not burn an attempt, or a 3-day sleep dead-letters the run. Floored
|
|
290
321
|
// where `SQL_NACK` floors it (`greatest(attempt - 1, 0)`): the fence above is what keeps
|
|
@@ -292,9 +323,16 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
292
323
|
// being read as the only one.
|
|
293
324
|
attempt: counts ? record.attempt : Math.max(0, record.attempt - 1),
|
|
294
325
|
...(nackOptions.error === undefined ? {} : { lastError: nackOptions.error }),
|
|
326
|
+
...(nackOptions.stack === undefined
|
|
327
|
+
? {}
|
|
328
|
+
: { lastErrorStack: nackOptions.stack.slice(0, MAX_ERROR_STACK_LENGTH) }),
|
|
295
329
|
};
|
|
296
330
|
settle(jobId, patch);
|
|
297
|
-
|
|
331
|
+
const outcome = nackOutcome(nackOptions);
|
|
332
|
+
if (outcome !== undefined) {
|
|
333
|
+
operator.counters.add(record.name, outcome, nackOptions.durationMs ?? 0, at);
|
|
334
|
+
}
|
|
335
|
+
return Promise.resolve(true);
|
|
298
336
|
},
|
|
299
337
|
|
|
300
338
|
heartbeat(jobId: string, heartbeatOptions: HeartbeatOptions): Promise<boolean> {
|
|
@@ -304,7 +342,9 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
304
342
|
if (
|
|
305
343
|
record === undefined ||
|
|
306
344
|
record.state !== 'running' ||
|
|
307
|
-
(heartbeatOptions.workerId !== undefined &&
|
|
345
|
+
(heartbeatOptions.workerId !== undefined &&
|
|
346
|
+
record.claimedBy !== heartbeatOptions.workerId) ||
|
|
347
|
+
(heartbeatOptions.claim !== undefined && record.claim !== heartbeatOptions.claim)
|
|
308
348
|
) {
|
|
309
349
|
return Promise.resolve(false);
|
|
310
350
|
}
|
|
@@ -322,6 +362,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
322
362
|
delayed: 0,
|
|
323
363
|
running: 0,
|
|
324
364
|
suspended: 0,
|
|
365
|
+
failed: 0,
|
|
325
366
|
dead: 0,
|
|
326
367
|
oldestReadyMs: 0,
|
|
327
368
|
};
|
|
@@ -337,6 +378,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
337
378
|
} else if (waiting) next.delayed += 1;
|
|
338
379
|
else if (record.state === 'running') next.running += 1;
|
|
339
380
|
else if (record.state === 'suspended') next.suspended += 1;
|
|
381
|
+
else if (record.state === 'failed') next.failed += 1;
|
|
340
382
|
else if (record.state === 'dead') next.dead += 1;
|
|
341
383
|
byQueue.set(record.queue, next);
|
|
342
384
|
}
|