@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/driver-memory.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// real claim/ack/nack paths rather than a mock that always succeeds.
|
|
4
4
|
|
|
5
5
|
import type { Clock } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
6
|
+
import { 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';
|
|
@@ -25,17 +25,20 @@ import {
|
|
|
25
25
|
assertClaimBounds,
|
|
26
26
|
assertClaimQueues,
|
|
27
27
|
DEFAULT_QUEUE,
|
|
28
|
+
LEASE_LAPSED_FINAL_ATTEMPT,
|
|
29
|
+
LIVE_STATES,
|
|
28
30
|
nackState,
|
|
29
31
|
REQUEUEABLE_STATES,
|
|
30
32
|
} from './driver';
|
|
31
33
|
import { createMemoryOperator } from './driver-memory-operator';
|
|
32
34
|
import { signalEnqueued } from './enqueue-signal';
|
|
33
|
-
import { JobDuplicateError } from './errors';
|
|
34
|
-
import { JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
|
|
35
|
+
import { JobDuplicateError, LeaseLostError } from './errors';
|
|
36
|
+
import { JobNotFoundError, JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
|
|
35
37
|
import type { JobIntrospection } from './introspection';
|
|
36
38
|
import { MAX_ERROR_STACK_LENGTH } from './introspection';
|
|
37
39
|
import type { LeaseStore } from './leases';
|
|
38
40
|
import { createMemoryLeaseStore } from './leases';
|
|
41
|
+
import { isFinalAttempt } from './retry';
|
|
39
42
|
import type { StepStore } from './steps';
|
|
40
43
|
import { createMemoryStepStore } from './steps-memory';
|
|
41
44
|
|
|
@@ -48,8 +51,6 @@ export interface MemoryDriverOptions {
|
|
|
48
51
|
readonly leases?: LeaseStore;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
|
|
52
|
-
|
|
53
54
|
/**
|
|
54
55
|
* The in-memory driver's own type: `JobDriver` with `close` REQUIRED.
|
|
55
56
|
*
|
|
@@ -63,7 +64,21 @@ export type MemoryJobDriver = JobDriver & { close(): Promise<void> };
|
|
|
63
64
|
|
|
64
65
|
export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJobDriver {
|
|
65
66
|
const clock = options.clock ?? systemClock;
|
|
66
|
-
const
|
|
67
|
+
const stepStore = options.steps ?? createMemoryStepStore();
|
|
68
|
+
// `SQL_STEP_PUT`'s fence: a write made under a claim lands only while that claim holds the row.
|
|
69
|
+
const steps: StepStore = {
|
|
70
|
+
...stepStore,
|
|
71
|
+
put(record, by) {
|
|
72
|
+
const row = by === undefined ? undefined : jobs.get(by.jobId);
|
|
73
|
+
if (
|
|
74
|
+
by !== undefined &&
|
|
75
|
+
!(row?.state === 'running' && row.claimedBy === by.workerId && row.claim === by.claim)
|
|
76
|
+
) {
|
|
77
|
+
return Promise.reject(new LeaseLostError({ job: by.job, jobId: by.jobId }));
|
|
78
|
+
}
|
|
79
|
+
return stepStore.put(record);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
67
82
|
const backfills = options.backfills ?? createMemoryBackfillLedger(clock);
|
|
68
83
|
const leases =
|
|
69
84
|
options.leases ?? createMemoryLeaseStore(options.clock === undefined ? {} : { clock });
|
|
@@ -104,11 +119,19 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
104
119
|
* lease deadline — which `x jobs show` prints, and which is the very pair the claim scan's
|
|
105
120
|
* lease-expiry branch reads to decide a row was abandoned.
|
|
106
121
|
*/
|
|
107
|
-
const settle = (id: string, patch: Partial<JobRecord
|
|
122
|
+
const settle = (id: string, patch: Partial<JobRecord>, drop?: 'stack'): void => {
|
|
108
123
|
const existing = jobs.get(id);
|
|
109
124
|
if (existing === undefined) return;
|
|
110
125
|
const { visibleAt: _visibleAt, claimedBy: _claimedBy, ...released } = existing;
|
|
111
|
-
|
|
126
|
+
// `drop: 'stack'` is `SQL_NACK`'s `case when $5 is null then last_error_stack else $7 end`
|
|
127
|
+
// with no stack bound: a NEW failure replaces the old one's stack even with nothing — the key
|
|
128
|
+
// is removed, never set to `undefined`, as a null column is absent from a pg record.
|
|
129
|
+
const { lastErrorStack: _stack, ...unstacked } = released;
|
|
130
|
+
jobs.set(id, {
|
|
131
|
+
...(drop === 'stack' ? unstacked : released),
|
|
132
|
+
...patch,
|
|
133
|
+
updatedAt: nowMs(clock),
|
|
134
|
+
});
|
|
112
135
|
};
|
|
113
136
|
|
|
114
137
|
const operator = createMemoryOperator({
|
|
@@ -145,13 +168,9 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
145
168
|
return rows;
|
|
146
169
|
},
|
|
147
170
|
async requeue(jobId, requeueOptions) {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
`no job ${jobId} in the memory driver`,
|
|
152
|
-
'requeue a job id returned by enqueue() or stats() — the memory driver holds no state across processes, so an id from another run will not resolve',
|
|
153
|
-
);
|
|
154
|
-
const record = existing;
|
|
171
|
+
const record = jobs.get(jobId);
|
|
172
|
+
// The one answer both drivers give an id nobody queued — it was `X_INVARIANT` here.
|
|
173
|
+
if (record === undefined) throw new JobNotFoundError({ jobId, driver: 'memory' });
|
|
155
174
|
// The two refusals `SQL_JOB_REQUEUE` and `SQL_JOB_LIVE_HOLDER` give, before anything moves.
|
|
156
175
|
if (!REQUEUEABLE_STATES.has(record.state)) {
|
|
157
176
|
throw new JobNotRequeueableError({ jobId, state: record.state });
|
|
@@ -161,7 +180,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
161
180
|
throw requeueKeyTaken({ ...record, holderId: holder.id });
|
|
162
181
|
}
|
|
163
182
|
if (requeueOptions?.fromStep !== undefined) {
|
|
164
|
-
// The target step and every step that started AFTER it — `
|
|
183
|
+
// The target step and every step that started AFTER it — `SQL_JOB_REQUEUE`, whose header
|
|
165
184
|
// says why a tie is kept. Earlier steps stay memoized; a later one replaying the old run's
|
|
166
185
|
// result is what this prevents.
|
|
167
186
|
const all = await steps.list(record.runId);
|
|
@@ -182,9 +201,12 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
182
201
|
},
|
|
183
202
|
cancel(jobId, reason) {
|
|
184
203
|
const existing = jobs.get(jobId);
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
204
|
+
// LIVE rows only, mirroring `SQL_CANCEL`: a job that already finished has nothing to stop.
|
|
205
|
+
// The fence was `state !== 'done'`, which let a cancel REWRITE a dead letter — the record of
|
|
206
|
+
// a failure, and its `lastError`, became the record of an operator's typo.
|
|
207
|
+
if (existing === undefined || !LIVE_STATES.has(existing.state)) {
|
|
208
|
+
return Promise.resolve(undefined);
|
|
209
|
+
}
|
|
188
210
|
// `settle`, not `update`: a cancellation RELEASES the claim, and `SQL_CANCEL` writes
|
|
189
211
|
// `visible_at = null, claimed_by = null` with the state. Left stamped, a cancelled row named
|
|
190
212
|
// the worker still holding it and carried that attempt's lease deadline — the pair
|
|
@@ -276,7 +298,24 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
276
298
|
.slice(0, claimOptions.limit);
|
|
277
299
|
|
|
278
300
|
const out: ClaimedJob[] = [];
|
|
301
|
+
const buried: JobRecord[] = [];
|
|
279
302
|
for (const record of claimable) {
|
|
303
|
+
// `SQL_CLAIM`'s second arm. A `running` row reaches this loop only with a lapsed lease, and
|
|
304
|
+
// one that lapsed on its FINAL attempt has nothing left to be re-delivered for: handing
|
|
305
|
+
// it out was an attempt past `maxAttempts`, every lease, forever — a job that kills its
|
|
306
|
+
// worker took one per visibility timeout and never reached the dead-letter queue.
|
|
307
|
+
if (
|
|
308
|
+
record.state === 'running' &&
|
|
309
|
+
isFinalAttempt({ attempts: record.maxAttempts }, record.attempt)
|
|
310
|
+
) {
|
|
311
|
+
// `$5` of the statement: a job declaring `retry.deadLetter: false` is dropped.
|
|
312
|
+
const state = claimOptions.dropExhausted?.includes(record.name) ? 'failed' : 'dead';
|
|
313
|
+
settle(record.id, { state, lastError: LEASE_LAPSED_FINAL_ATTEMPT }, 'stack');
|
|
314
|
+
operator.counters.add(record.name, state, 0, at);
|
|
315
|
+
const dead = jobs.get(record.id);
|
|
316
|
+
if (dead !== undefined) buried.push(dead);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
280
319
|
const claimed: ClaimedJob = {
|
|
281
320
|
...record,
|
|
282
321
|
state: 'running',
|
|
@@ -290,7 +329,8 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
290
329
|
jobs.set(record.id, claimed);
|
|
291
330
|
out.push(claimed);
|
|
292
331
|
}
|
|
293
|
-
|
|
332
|
+
if (buried.length > 0) claimOptions.onExhausted?.(buried);
|
|
333
|
+
return out;
|
|
294
334
|
},
|
|
295
335
|
|
|
296
336
|
// Both settlements are FENCED on `running` AND on the claimer, as `SQL_ACK`/`SQL_NACK` are: an
|
|
@@ -323,11 +363,12 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
323
363
|
// being read as the only one.
|
|
324
364
|
attempt: counts ? record.attempt : Math.max(0, record.attempt - 1),
|
|
325
365
|
...(nackOptions.error === undefined ? {} : { lastError: nackOptions.error }),
|
|
326
|
-
|
|
366
|
+
// The stack belongs to the ERROR beside it, as `SQL_NACK` binds it: no error, no stack.
|
|
367
|
+
...(nackOptions.error === undefined || nackOptions.stack === undefined
|
|
327
368
|
? {}
|
|
328
369
|
: { lastErrorStack: nackOptions.stack.slice(0, MAX_ERROR_STACK_LENGTH) }),
|
|
329
370
|
};
|
|
330
|
-
settle(jobId, patch);
|
|
371
|
+
settle(jobId, patch, nackOptions.error === undefined ? undefined : 'stack');
|
|
331
372
|
const outcome = nackOutcome(nackOptions);
|
|
332
373
|
if (outcome !== undefined) {
|
|
333
374
|
operator.counters.add(record.name, outcome, nackOptions.durationMs ?? 0, at);
|
|
@@ -382,7 +423,11 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
382
423
|
else if (record.state === 'dead') next.dead += 1;
|
|
383
424
|
byQueue.set(record.queue, next);
|
|
384
425
|
}
|
|
385
|
-
|
|
426
|
+
// Code units, as `collate "C"` orders them in `SQL_STATS` — never `localeCompare`, which
|
|
427
|
+
// answers by the runtime's locale and so differently from the database on the same names.
|
|
428
|
+
return Promise.resolve(
|
|
429
|
+
[...byQueue.values()].sort((a, b) => (a.queue < b.queue ? -1 : a.queue > b.queue ? 1 : 0)),
|
|
430
|
+
);
|
|
386
431
|
},
|
|
387
432
|
|
|
388
433
|
close(): Promise<void> {
|
package/src/driver-nats.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// a durable pull consumer per worker pool (`fetch` == claim, `ack`/`nak` map 1:1),
|
|
4
4
|
// `ack_wait` as the visibility timeout, and a KV bucket for step records.
|
|
5
5
|
|
|
6
|
+
import { NotImplementedError } from '@ultimat3/core';
|
|
6
7
|
import type {
|
|
7
8
|
ClaimedJob,
|
|
8
9
|
ClaimOptions,
|
|
@@ -13,7 +14,6 @@ import type {
|
|
|
13
14
|
NackOptions,
|
|
14
15
|
QueueStats,
|
|
15
16
|
} from './driver';
|
|
16
|
-
import { JobsNotImplementedError } from './errors';
|
|
17
17
|
import type { StepRecord, StepStore } from './steps';
|
|
18
18
|
|
|
19
19
|
// Names the seam that actually replaces the stub, and NOTHING ELSE — the two other repairs this
|
|
@@ -34,7 +34,10 @@ const FIX =
|
|
|
34
34
|
'call setJobDriver(createPgDriver()) at boot instead of this driver; nothing needs moving first, because enqueue here refuses too, so no job was ever written to it';
|
|
35
35
|
|
|
36
36
|
const unavailable = (method: string): never => {
|
|
37
|
-
throw new
|
|
37
|
+
throw new NotImplementedError({
|
|
38
|
+
cause: `nats jobs driver (${method}) is not implemented`,
|
|
39
|
+
fix: FIX,
|
|
40
|
+
});
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
const natsStepStore = (): StepStore => ({
|
|
@@ -104,14 +104,35 @@ select ${JOB_ROW_COLUMNS}
|
|
|
104
104
|
* (`driver.ts`) is the same set; a row this matches nothing for was refused before it was sent,
|
|
105
105
|
* or raced to live in between. `woke` announces the row in the statement that made it claimable
|
|
106
106
|
* (`driver-pg-wake-sql.ts`): `x jobs retry` is a job starting now, not at an idle worker's poll.
|
|
107
|
+
*
|
|
108
|
+
* $2 is `fromStep`, or null — and the steps are dropped HERE, off the row this statement moved.
|
|
109
|
+
* They were a statement of their own, sent first: a requeue that then matched nothing (the row
|
|
110
|
+
* raced to live) had already deleted a running job's steps, and a crash between the two left a
|
|
111
|
+
* dead letter with its steps gone and no retry queued.
|
|
112
|
+
*
|
|
113
|
+
* "From that step onward": the step `fromStep` names and every step that started AFTER it.
|
|
114
|
+
* Deleting the named step alone left later steps replaying results computed from the old run.
|
|
115
|
+
* Strictly after, plus the step itself: a step sharing the target's millisecond is not provably
|
|
116
|
+
* later, and re-running an EARLIER step — the charge before the receipt — is the worse mistake.
|
|
107
117
|
*/
|
|
108
118
|
export const SQL_JOB_REQUEUE = `
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
with requeued as (
|
|
120
|
+
update x_jobs
|
|
121
|
+
set state = 'ready', attempt = 0, run_at = now(), updated_at = now(),
|
|
122
|
+
claimed_by = null, visible_at = null
|
|
123
|
+
where id = $1 and state in ('dead', 'cancelled', 'done', 'failed')
|
|
124
|
+
returning ${JOB_ROW_COLUMNS},
|
|
125
|
+
pg_notify('${JOBS_WAKE_CHANNEL}', queue)::text as woke
|
|
126
|
+
), dropped as (
|
|
127
|
+
delete from x_job_steps s
|
|
128
|
+
using requeued r
|
|
129
|
+
where $2::text is not null
|
|
130
|
+
and s.run_id = r.run_id
|
|
131
|
+
and (s.name = $2::text
|
|
132
|
+
or s.started_at > (select t.started_at from x_job_steps t
|
|
133
|
+
where t.run_id = r.run_id and t.name = $2::text))
|
|
134
|
+
)
|
|
135
|
+
select ${JOB_ROW_NAMES}, woke from requeued
|
|
115
136
|
`.trim();
|
|
116
137
|
|
|
117
138
|
/**
|
|
@@ -124,16 +145,3 @@ select id from x_jobs
|
|
|
124
145
|
and id <> $4 and state in ('ready', 'delayed', 'running', 'suspended')
|
|
125
146
|
limit 1
|
|
126
147
|
`.trim();
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* "From that step onward": the step `fromStep` names and every step that started AFTER it.
|
|
130
|
-
* Deleting the named step alone left later steps replaying results computed from the old run.
|
|
131
|
-
* Strictly after, plus the step itself: a step sharing the target's millisecond is not provably
|
|
132
|
-
* later, and re-running an EARLIER step — the charge before the receipt — is the worse mistake.
|
|
133
|
-
*/
|
|
134
|
-
export const SQL_STEPS_FROM = `
|
|
135
|
-
delete from x_job_steps
|
|
136
|
-
where run_id = $1
|
|
137
|
-
and (name = $2
|
|
138
|
-
or started_at > (select started_at from x_job_steps where run_id = $1 and name = $2))
|
|
139
|
-
`.trim();
|
|
@@ -15,15 +15,18 @@ const bulkMatch = (alias = ''): string =>
|
|
|
15
15
|
`${alias}state = $1 and ($2::text is null or ${alias}queue = $2) and ($3::text is null or ${alias}name = $3) and ($5::text is null or ${alias}tenant_id = $5)`;
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Re-queue up to $4 finished rows. `
|
|
19
|
-
* of
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
22
|
-
*
|
|
18
|
+
* Re-queue up to $4 finished rows. `eligible` is every row of the filter no LIVE job holds the key
|
|
19
|
+
* of — the single requeue's `X_JOB_DUPLICATE`, answered here as "not this row" — and `distinct on`
|
|
20
|
+
* the idempotency namespace picks one of them per key, because two dead rows of one key would both
|
|
21
|
+
* go `ready` and the second would break the live index.
|
|
22
|
+
*
|
|
23
|
+
* `remaining` is what a second call could still move, counted in the same statement off the same
|
|
24
|
+
* snapshot: the eligible rows whose key this call did not just take. A held row was counted too,
|
|
25
|
+
* so a caller told to "call again until it is zero" never stopped.
|
|
23
26
|
*/
|
|
24
27
|
export const SQL_JOB_REQUEUE_MANY = `
|
|
25
|
-
with
|
|
26
|
-
select
|
|
28
|
+
with eligible as (
|
|
29
|
+
select j.id, j.name, coalesce(j.tenant_id, '') as tenant, j.idempotency_key, j.created_at
|
|
27
30
|
from x_jobs j
|
|
28
31
|
where ${bulkMatch('j.')}
|
|
29
32
|
and not exists (
|
|
@@ -33,7 +36,10 @@ with candidates as (
|
|
|
33
36
|
and l.idempotency_key = j.idempotency_key
|
|
34
37
|
and l.state in ${LIVE}
|
|
35
38
|
)
|
|
36
|
-
|
|
39
|
+
), candidates as (
|
|
40
|
+
select distinct on (name, tenant, idempotency_key) id, name, tenant, idempotency_key
|
|
41
|
+
from eligible
|
|
42
|
+
order by name, tenant, idempotency_key, created_at
|
|
37
43
|
limit $4
|
|
38
44
|
), moved as (
|
|
39
45
|
update x_jobs
|
|
@@ -43,7 +49,13 @@ with candidates as (
|
|
|
43
49
|
returning id
|
|
44
50
|
)
|
|
45
51
|
select (select count(*) from moved)::int as affected,
|
|
46
|
-
(select count(*) from
|
|
52
|
+
(select count(*) from eligible e
|
|
53
|
+
where not exists (
|
|
54
|
+
select 1 from candidates c
|
|
55
|
+
join moved m on m.id = c.id
|
|
56
|
+
where c.name = e.name and c.tenant = e.tenant
|
|
57
|
+
and c.idempotency_key = e.idempotency_key
|
|
58
|
+
))::int as remaining
|
|
47
59
|
`.trim();
|
|
48
60
|
|
|
49
61
|
/** Delete up to $4 rows and their step records. `running` is refused before this is sent. */
|
|
@@ -267,17 +279,25 @@ select task_name,
|
|
|
267
279
|
* `woken` announces each queue that received a job, once, when the statement commits — read in
|
|
268
280
|
* the final select because a plain-select CTE nothing reads is never run.
|
|
269
281
|
*
|
|
270
|
-
*
|
|
282
|
+
* The fence is the OCCURRENCE and the watermark may land past it: `run-once` runs the earliest
|
|
283
|
+
* missed occurrence and drops the rest, and dropping is the watermark moving beyond them. That was
|
|
284
|
+
* a second statement behind this one, and a crash between the two fired a second "one catch-up".
|
|
285
|
+
* `greatest` ignores a null, so with no `$4` the watermark lands on the occurrence — and a `$4`
|
|
286
|
+
* behind the occurrence can never leave the watermark short of what just fired.
|
|
287
|
+
*
|
|
288
|
+
* $1 task, $2 occurrence (ms), $3 the jobs as a JSON array, $4 where the watermark lands (ms) or
|
|
289
|
+
* null for the occurrence itself.
|
|
271
290
|
*/
|
|
272
291
|
export const SQL_SCHEDULER_FIRE = `
|
|
273
292
|
with moved as (
|
|
274
293
|
insert into x_scheduler_state
|
|
275
294
|
(task_name, last_fired_at, updated_at, fired_occurrence_at, fired_at)
|
|
276
|
-
values ($1, to_timestamp($2::bigint
|
|
295
|
+
values ($1, to_timestamp(greatest($2::bigint, $4::bigint) / 1000.0), now(),
|
|
296
|
+
to_timestamp($2::bigint / 1000.0), now())
|
|
277
297
|
on conflict (task_name) do update
|
|
278
298
|
set last_fired_at = excluded.last_fired_at, updated_at = now(),
|
|
279
299
|
fired_occurrence_at = excluded.fired_occurrence_at, fired_at = excluded.fired_at
|
|
280
|
-
where x_scheduler_state.last_fired_at < excluded.
|
|
300
|
+
where x_scheduler_state.last_fired_at < excluded.fired_occurrence_at
|
|
281
301
|
returning task_name
|
|
282
302
|
), queued as (
|
|
283
303
|
insert into x_jobs
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// that file is the claim/settle driver and sits near its size ceiling; this is the read-and-repair
|
|
4
4
|
// half a dashboard and `x jobs` call.
|
|
5
5
|
|
|
6
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
6
7
|
import { assert, finiteOption } from '@ultimat3/core';
|
|
7
8
|
import { REQUEUEABLE_STATES } from './driver';
|
|
8
|
-
import type { PgExecutor } from './driver-pg';
|
|
9
9
|
import { SQL_JOB_LIST, SQL_JOB_LIST_BEFORE } from './driver-pg-jobs-sql';
|
|
10
10
|
import {
|
|
11
11
|
SQL_COUNTER_DROP,
|
|
@@ -126,7 +126,14 @@ export function pgOperator(
|
|
|
126
126
|
);
|
|
127
127
|
const limit = pageLimit('the pg driver list', filter.limit);
|
|
128
128
|
const raw = filter.before ?? filter.after;
|
|
129
|
-
const cursor =
|
|
129
|
+
const cursor =
|
|
130
|
+
raw === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: parseJobCursor(
|
|
133
|
+
raw,
|
|
134
|
+
'the pg driver list',
|
|
135
|
+
filter.before === undefined ? 'after' : 'before',
|
|
136
|
+
);
|
|
130
137
|
const rows = await exec().query<JobRow>(
|
|
131
138
|
filter.before === undefined ? SQL_JOB_LIST : SQL_JOB_LIST_BEFORE,
|
|
132
139
|
[
|
|
@@ -162,7 +169,16 @@ export function pgOperator(
|
|
|
162
169
|
`requeueMany was asked for ${filter.state} jobs, and only a finished job can be requeued`,
|
|
163
170
|
"pass state: 'dead', 'failed', 'cancelled' or 'done' to requeueMany",
|
|
164
171
|
);
|
|
165
|
-
|
|
172
|
+
// Its own read, not `bulk`: the statement answers `remaining` itself — the rows a second
|
|
173
|
+
// call could move — where `matching - affected` counted the held rows it never will.
|
|
174
|
+
const rows = await exec().query<{ affected: Count; remaining: Count }>(SQL_JOB_REQUEUE_MANY, [
|
|
175
|
+
filter.state,
|
|
176
|
+
filter.queue ?? null,
|
|
177
|
+
filter.name ?? null,
|
|
178
|
+
MAX_BULK_ROWS,
|
|
179
|
+
filter.tenantId ?? null,
|
|
180
|
+
]);
|
|
181
|
+
const result = { affected: num(rows[0]?.affected), remaining: num(rows[0]?.remaining) };
|
|
166
182
|
if (result.affected > 0) await wake(filter.queue);
|
|
167
183
|
return result;
|
|
168
184
|
},
|
package/src/driver-pg-rows.ts
CHANGED
|
@@ -94,7 +94,7 @@ export function toJobRecord(row: JobRow): JobRecord {
|
|
|
94
94
|
attempt: row.attempt,
|
|
95
95
|
maxAttempts: row.max_attempts,
|
|
96
96
|
state: statusIn<JobState>(JOB_STATES, isJobState, {
|
|
97
|
-
table: '
|
|
97
|
+
table: 'x_jobs',
|
|
98
98
|
column: 'state',
|
|
99
99
|
value: row.state,
|
|
100
100
|
}),
|
|
@@ -146,7 +146,7 @@ export function toStepRecord(row: StepRow): StepRecord {
|
|
|
146
146
|
runId: row.run_id,
|
|
147
147
|
name: row.name,
|
|
148
148
|
status: statusIn<StepStatus>(STEP_STATUSES, isStepStatus, {
|
|
149
|
-
table: '
|
|
149
|
+
table: 'x_job_steps',
|
|
150
150
|
column: 'status',
|
|
151
151
|
value: row.status,
|
|
152
152
|
}),
|
|
@@ -168,7 +168,7 @@ export function toBackfillRun(row: BackfillRow): BackfillRun {
|
|
|
168
168
|
name: row.name,
|
|
169
169
|
checksum: row.checksum,
|
|
170
170
|
status: statusIn(BACKFILL_STATUSES, isBackfillStatus, {
|
|
171
|
-
table: '
|
|
171
|
+
table: 'x_backfills',
|
|
172
172
|
column: 'status',
|
|
173
173
|
value: row.status,
|
|
174
174
|
}),
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -19,7 +19,6 @@ export {
|
|
|
19
19
|
SQL_JOB_LIST,
|
|
20
20
|
SQL_JOB_LIVE_HOLDER,
|
|
21
21
|
SQL_JOB_REQUEUE,
|
|
22
|
-
SQL_STEPS_FROM,
|
|
23
22
|
} from './driver-pg-jobs-sql';
|
|
24
23
|
|
|
25
24
|
export {
|
|
@@ -32,8 +31,23 @@ export {
|
|
|
32
31
|
} from './driver-pg-outbox-sql';
|
|
33
32
|
export { SQL_ACK, SQL_NACK } from './driver-pg-settle-sql';
|
|
34
33
|
|
|
34
|
+
import { LEASE_LAPSED_FINAL_ATTEMPT } from './driver';
|
|
35
35
|
import { JOB_ROW_COLUMNS } from './driver-pg-jobs-sql';
|
|
36
36
|
import { notifyJobReady } from './driver-pg-wake-sql';
|
|
37
|
+
import { COUNTER_BUCKET_MS } from './introspection';
|
|
38
|
+
|
|
39
|
+
/** What both arms of the claim return: a `JobRow`, read off the updated row `j`. */
|
|
40
|
+
const CLAIM_ROW_COLUMNS = `j.id, j.name, j.queue, j.input, j.idempotency_key, j.run_id, j.attempt,
|
|
41
|
+
j.max_attempts, j.state, j.tenant_id, j.last_error, j.claimed_by, j.claims,
|
|
42
|
+
j.traceparent, j.enqueued_by,
|
|
43
|
+
(extract(epoch from j.run_at) * 1000)::bigint as run_at,
|
|
44
|
+
(extract(epoch from j.visible_at) * 1000)::bigint as visible_at,
|
|
45
|
+
(extract(epoch from j.created_at) * 1000)::bigint as created_at,
|
|
46
|
+
(extract(epoch from j.updated_at) * 1000)::bigint as updated_at`;
|
|
47
|
+
|
|
48
|
+
const CLAIM_ROW_NAMES = `id, name, queue, input, idempotency_key, run_id, attempt, max_attempts,
|
|
49
|
+
state, tenant_id, last_error, claimed_by, claims, traceparent, enqueued_by, run_at,
|
|
50
|
+
visible_at, created_at, updated_at`;
|
|
37
51
|
|
|
38
52
|
/**
|
|
39
53
|
* The insert, and its wake: `woke` is the notification's own column, evaluated once per inserted
|
|
@@ -85,10 +99,19 @@ select id, run_id from x_jobs
|
|
|
85
99
|
* lock and throughput collapses to one worker. `visible_at` in the predicate reclaims leases
|
|
86
100
|
* abandoned by a crashed worker. `cancelled` is absent from every branch by construction — a
|
|
87
101
|
* cancelled row is terminal, so it is never handed to a worker again.
|
|
102
|
+
*
|
|
103
|
+
* TWO arms over one pick. A `running` row is picked only with a lapsed lease, and one that lapsed
|
|
104
|
+
* on its FINAL attempt (`attempt >= max_attempts` — `isFinalAttempt`, `retry.ts`) is `buried`:
|
|
105
|
+
* settled `dead` here, counted, and answered with its new state so the driver reports it instead
|
|
106
|
+
* of handing it out. Re-claimed, it was an attempt past `max_attempts` every lease, forever — a
|
|
107
|
+
* job that kills its worker took one per visibility timeout and never reached the dead letters.
|
|
108
|
+
* `claimed_by` is cleared with the lease, so the body that stalled rather than died cannot settle
|
|
109
|
+
* the row it lost. A buried row spends a slot of `$2`: the pass after it claims what it displaced.
|
|
110
|
+
* `$5` names the jobs declaring `retry.deadLetter: false`: their rows are buried `failed`.
|
|
88
111
|
*/
|
|
89
112
|
export const SQL_CLAIM = `
|
|
90
|
-
with
|
|
91
|
-
select id
|
|
113
|
+
with picked as (
|
|
114
|
+
select id, (state = 'running' and attempt >= max_attempts) as exhausted
|
|
92
115
|
from x_jobs
|
|
93
116
|
where queue = any($1::text[])
|
|
94
117
|
and run_at <= now()
|
|
@@ -102,35 +125,56 @@ with claimed as (
|
|
|
102
125
|
order by run_at
|
|
103
126
|
limit $2
|
|
104
127
|
for update skip locked
|
|
128
|
+
), buried as (
|
|
129
|
+
update x_jobs j
|
|
130
|
+
set state = case when j.name = any($5::text[]) then 'failed' else 'dead' end,
|
|
131
|
+
visible_at = null,
|
|
132
|
+
claimed_by = null,
|
|
133
|
+
last_error = '${LEASE_LAPSED_FINAL_ATTEMPT}',
|
|
134
|
+
last_error_stack = null,
|
|
135
|
+
updated_at = now()
|
|
136
|
+
from picked p
|
|
137
|
+
where j.id = p.id and p.exhausted
|
|
138
|
+
returning ${CLAIM_ROW_COLUMNS}
|
|
139
|
+
), counted as (
|
|
140
|
+
insert into x_job_counters (job, bucket_ms, bucket_start, dead, failed, duration_ms)
|
|
141
|
+
select name, ${COUNTER_BUCKET_MS}, date_trunc('minute', now()),
|
|
142
|
+
(count(*) filter (where state = 'dead'))::int,
|
|
143
|
+
(count(*) filter (where state = 'failed'))::int, 0
|
|
144
|
+
from buried
|
|
145
|
+
group by name
|
|
146
|
+
on conflict (job, bucket_ms, bucket_start) do update
|
|
147
|
+
set dead = x_job_counters.dead + excluded.dead,
|
|
148
|
+
failed = x_job_counters.failed + excluded.failed
|
|
149
|
+
), claimed as (
|
|
150
|
+
update x_jobs j
|
|
151
|
+
set state = 'running',
|
|
152
|
+
attempt = j.attempt + 1,
|
|
153
|
+
claims = j.claims + 1,
|
|
154
|
+
claimed_by = $3,
|
|
155
|
+
visible_at = now() + ($4::bigint * interval '1 millisecond'),
|
|
156
|
+
updated_at = now()
|
|
157
|
+
from picked p
|
|
158
|
+
where j.id = p.id and not p.exhausted
|
|
159
|
+
returning ${CLAIM_ROW_COLUMNS}
|
|
105
160
|
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
claims = j.claims + 1,
|
|
110
|
-
claimed_by = $3,
|
|
111
|
-
visible_at = now() + ($4::bigint * interval '1 millisecond'),
|
|
112
|
-
updated_at = now()
|
|
113
|
-
from claimed c
|
|
114
|
-
where j.id = c.id
|
|
115
|
-
returning j.id, j.name, j.queue, j.input, j.idempotency_key, j.run_id, j.attempt,
|
|
116
|
-
j.max_attempts, j.state, j.tenant_id, j.last_error, j.claimed_by, j.claims,
|
|
117
|
-
j.traceparent, j.enqueued_by,
|
|
118
|
-
(extract(epoch from j.run_at) * 1000)::bigint as run_at,
|
|
119
|
-
(extract(epoch from j.visible_at) * 1000)::bigint as visible_at,
|
|
120
|
-
(extract(epoch from j.created_at) * 1000)::bigint as created_at,
|
|
121
|
-
(extract(epoch from j.updated_at) * 1000)::bigint as updated_at
|
|
161
|
+
select ${CLAIM_ROW_NAMES} from claimed
|
|
162
|
+
union all
|
|
163
|
+
select ${CLAIM_ROW_NAMES} from buried
|
|
122
164
|
`.trim();
|
|
123
165
|
|
|
124
166
|
/**
|
|
125
|
-
* Stop a job from outside
|
|
126
|
-
*
|
|
127
|
-
*
|
|
167
|
+
* Stop a job from outside — a LIVE one. The runaway backfill this exists for is `running`; the
|
|
168
|
+
* worker holding it learns on its next heartbeat, which no longer matches `state = 'running'`.
|
|
169
|
+
* Fenced on the four live states and not on `state <> 'done'`, which let a cancel REWRITE a dead
|
|
170
|
+
* letter: `dead / "card declined"` became `cancelled / "oops wrong id"` and left the dead-letter
|
|
171
|
+
* queue. A finished row is how the job ended, and stays that.
|
|
128
172
|
*/
|
|
129
173
|
export const SQL_CANCEL = `
|
|
130
174
|
update x_jobs
|
|
131
175
|
set state = 'cancelled', visible_at = null, claimed_by = null,
|
|
132
176
|
last_error = coalesce($2, last_error), updated_at = now()
|
|
133
|
-
where id = $1 and state
|
|
177
|
+
where id = $1 and state in ('ready', 'delayed', 'running', 'suspended')
|
|
134
178
|
returning ${JOB_ROW_COLUMNS}
|
|
135
179
|
`.trim();
|
|
136
180
|
|
|
@@ -167,7 +211,7 @@ select queue,
|
|
|
167
211
|
(where state in ('ready', 'delayed') and run_at <= now()), 0) * 1000 as oldest_ready_ms
|
|
168
212
|
from x_jobs
|
|
169
213
|
group by queue
|
|
170
|
-
order by queue
|
|
214
|
+
order by queue collate "C"
|
|
171
215
|
`.trim();
|
|
172
216
|
|
|
173
217
|
/**
|
|
@@ -324,7 +368,17 @@ select id, name, payload, correlation_key,
|
|
|
324
368
|
limit $2
|
|
325
369
|
`.trim();
|
|
326
370
|
|
|
327
|
-
|
|
371
|
+
/**
|
|
372
|
+
* The sweep, COUNTED in the statement: `PgExecutor` answers rows and never a command tag, and
|
|
373
|
+
* `returning` alone would ship every deleted id back to count them. The statement's own time, as
|
|
374
|
+
* every other event statement reads — an unpurged row costs `find` a filter, never a wrong answer.
|
|
375
|
+
*/
|
|
376
|
+
export const SQL_EVENT_PURGE_COUNTED = `
|
|
377
|
+
with gone as (
|
|
378
|
+
delete from x_job_events where expires_at <= statement_timestamp() returning 1
|
|
379
|
+
)
|
|
380
|
+
select count(*)::int as removed from gone
|
|
381
|
+
`.trim();
|
|
328
382
|
|
|
329
383
|
export const SQL_STEP_GET = `
|
|
330
384
|
select run_id, name, status, output, attempts, error,
|
|
@@ -351,18 +405,33 @@ select run_id, name, status, output, attempts, error,
|
|
|
351
405
|
from x_job_steps where run_id = $1 order by started_at
|
|
352
406
|
`.trim();
|
|
353
407
|
|
|
408
|
+
/**
|
|
409
|
+
* FENCED on the claim when one is given, exactly as `SQL_ACK` is: `running`, the same worker, the
|
|
410
|
+
* same claim ordinal. The runner's own fence is an in-process signal, and it cannot stop a body in
|
|
411
|
+
* ANOTHER process — a worker stalled past its lease wrote its step over the re-claimer's. A null
|
|
412
|
+
* `$12` is an unfenced write: a transfer between drivers, with no row on this one to hold yet.
|
|
413
|
+
* Answers one row when the write landed, none when the fence refused it.
|
|
414
|
+
*
|
|
415
|
+
* $12 the job id or null, $13 the worker, $14 the claim.
|
|
416
|
+
*/
|
|
354
417
|
export const SQL_STEP_PUT = `
|
|
355
418
|
insert into x_job_steps
|
|
356
419
|
(run_id, name, status, output, started_at, completed_at, wake_at, event,
|
|
357
420
|
correlation_key, attempts, error)
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
421
|
+
select $1::uuid, $2, $3, $4::jsonb, to_timestamp($5::bigint / 1000.0),
|
|
422
|
+
case when $6::bigint is null then null else to_timestamp($6::bigint / 1000.0) end,
|
|
423
|
+
case when $7::bigint is null then null else to_timestamp($7::bigint / 1000.0) end,
|
|
424
|
+
$8, $9, $10::int, $11
|
|
425
|
+
where $12::uuid is null
|
|
426
|
+
or exists (
|
|
427
|
+
select 1 from x_jobs
|
|
428
|
+
where id = $12::uuid and state = 'running' and claimed_by = $13 and claims = $14::int
|
|
429
|
+
)
|
|
362
430
|
on conflict (run_id, name) do update
|
|
363
431
|
set status = excluded.status, output = excluded.output,
|
|
364
432
|
completed_at = excluded.completed_at, wake_at = excluded.wake_at,
|
|
365
433
|
attempts = excluded.attempts, error = excluded.error
|
|
434
|
+
returning 1 as written
|
|
366
435
|
`.trim();
|
|
367
436
|
|
|
368
437
|
/**
|