@ultimat3/jobs 27.16.0 → 27.16.1
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/package.json +6 -6
- package/src/driver-memory.ts +4 -0
- package/src/driver-pg-ddl.ts +9 -0
- package/src/driver-pg-sql.ts +7 -0
- package/src/driver-redis-scripts.ts +6 -3
- package/src/driver.ts +15 -0
- package/src/worker-queue-depth.ts +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "27.16.
|
|
3
|
+
"version": "27.16.1",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
"test": "bun test"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@ultimat3/core": "27.16.
|
|
37
|
-
"@ultimat3/db": "27.16.
|
|
38
|
-
"@ultimat3/entity": "27.16.
|
|
39
|
-
"@ultimat3/schema": "27.16.
|
|
40
|
-
"@ultimat3/time": "27.16.
|
|
36
|
+
"@ultimat3/core": "27.16.1",
|
|
37
|
+
"@ultimat3/db": "27.16.1",
|
|
38
|
+
"@ultimat3/entity": "27.16.1",
|
|
39
|
+
"@ultimat3/schema": "27.16.1",
|
|
40
|
+
"@ultimat3/time": "27.16.1"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/driver-memory.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
LIVE_STATES,
|
|
30
30
|
nackState,
|
|
31
31
|
REQUEUEABLE_STATES,
|
|
32
|
+
STATS_STATES,
|
|
32
33
|
} from './driver';
|
|
33
34
|
import { memoryOperator } from './driver-memory-operator';
|
|
34
35
|
import { signalEnqueued } from './enqueue-signal';
|
|
@@ -394,6 +395,9 @@ export function memoryJobDriver(options: MemoryJobDriverOptions = {}): MemoryJob
|
|
|
394
395
|
const at = nowMs(clock);
|
|
395
396
|
const byQueue = new Map<string, QueueStats>();
|
|
396
397
|
for (const record of jobs.values()) {
|
|
398
|
+
// A finished row counts toward no bucket, so it does not list its queue either — the
|
|
399
|
+
// `where` `SQL_STATS` reads through `x_jobs_depth_idx`.
|
|
400
|
+
if (!STATS_STATES.has(record.state)) continue;
|
|
397
401
|
const current = byQueue.get(record.queue) ?? {
|
|
398
402
|
queue: record.queue,
|
|
399
403
|
ready: 0,
|
package/src/driver-pg-ddl.ts
CHANGED
|
@@ -217,6 +217,15 @@ create index if not exists x_jobs_finished_idx
|
|
|
217
217
|
on x_jobs (updated_at)
|
|
218
218
|
where state in ('done', 'failed', 'dead', 'cancelled');
|
|
219
219
|
|
|
220
|
+
-- What the queue depth read (SQL_STATS) reads, every 15 seconds from every worker. Partial over
|
|
221
|
+
-- the six states it counts, so the read is a range over the counted rows and never a pass over the
|
|
222
|
+
-- finished ones the retention window keeps. Its predicate is the same list SQL_STATS filters on,
|
|
223
|
+
-- which is what lets the planner prove the index applies. Queue, state and run_at are every column
|
|
224
|
+
-- the read uses, so it can be answered from the index alone.
|
|
225
|
+
create index if not exists x_jobs_depth_idx
|
|
226
|
+
on x_jobs (queue, state, run_at)
|
|
227
|
+
where state in ('ready', 'delayed', 'running', 'suspended', 'failed', 'dead');
|
|
228
|
+
|
|
220
229
|
-- The rows of one run, by run id: the retention sweep asks, per deleted row, whether another row of
|
|
221
230
|
-- the run still stands before it deletes the step records of that run, and its follow-up and its
|
|
222
231
|
-- orphan pass ask again. Without it each question is a scan of the whole queue. Not partial: run_id
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -198,6 +198,12 @@ returning id
|
|
|
198
198
|
* `delayed`, a delayed dead-letter as `dead` AND `delayed`. The memory driver's `if/else if` chain
|
|
199
199
|
* has always been exclusive, so `x jobs` reported one depth under `x dev` and a larger one in
|
|
200
200
|
* production — off the same rows.
|
|
201
|
+
*
|
|
202
|
+
* The `where` is the six states a bucket counts (`STATS_STATES`), spelled as `x_jobs_depth_idx`'s
|
|
203
|
+
* predicate so the planner reads that index and never a `done` row: every worker runs this every
|
|
204
|
+
* 15 s, and unfiltered it scanned a fortnight of finished jobs to count a handful (prod,
|
|
205
|
+
* 2026-10-24: 29 ms mean over 48k rows, 22 counted). A queue holding only `done` or `cancelled`
|
|
206
|
+
* rows is therefore not listed, and the depth publisher zeroes a queue that drops out.
|
|
201
207
|
*/
|
|
202
208
|
export const SQL_STATS = `
|
|
203
209
|
select queue,
|
|
@@ -210,6 +216,7 @@ select queue,
|
|
|
210
216
|
coalesce(max(extract(epoch from now() - run_at)) filter
|
|
211
217
|
(where state in ('ready', 'delayed') and run_at <= now()), 0) * 1000 as oldest_ready_ms
|
|
212
218
|
from x_jobs
|
|
219
|
+
where state in ('ready', 'delayed', 'running', 'suspended', 'failed', 'dead')
|
|
213
220
|
group by queue
|
|
214
221
|
order by queue collate "C"
|
|
215
222
|
`.trim();
|
|
@@ -193,7 +193,9 @@ return 1
|
|
|
193
193
|
/**
|
|
194
194
|
* `SQL_STATS`, one consistent read. `ARGV`: namespace, now. Answers one row per queue:
|
|
195
195
|
* `{ queue, ready, delayed, running, suspended, failed, dead, oldest runAt or -1 }` — due is due
|
|
196
|
-
* whatever state the row was WRITTEN in, the split `SQL_STATS` makes.
|
|
196
|
+
* whatever state the row was WRITTEN in, the split `SQL_STATS` makes. A queue all six counts leave
|
|
197
|
+
* at zero is not answered: `SQL_STATS` reads only the states it counts, so a queue holding only
|
|
198
|
+
* finished jobs is absent there and in the memory driver, and `:queues` never forgets a name.
|
|
197
199
|
*/
|
|
198
200
|
export const REDIS_STATS = `
|
|
199
201
|
local P = ARGV[1]
|
|
@@ -204,7 +206,7 @@ for _, queue in ipairs(redis.call('SMEMBERS', P .. ':queues')) do
|
|
|
204
206
|
local oldest = redis.call('ZRANGE', wait, 0, 0, 'WITHSCORES')
|
|
205
207
|
local first = -1
|
|
206
208
|
if oldest[2] and tonumber(oldest[2]) <= now then first = oldest[2] end
|
|
207
|
-
|
|
209
|
+
local row = {
|
|
208
210
|
queue,
|
|
209
211
|
redis.call('ZCOUNT', wait, '-inf', now),
|
|
210
212
|
redis.call('ZCOUNT', wait, '(' .. now, '+inf'),
|
|
@@ -213,7 +215,8 @@ for _, queue in ipairs(redis.call('SMEMBERS', P .. ':queues')) do
|
|
|
213
215
|
redis.call('ZCARD', P .. ':failed:' .. queue),
|
|
214
216
|
redis.call('ZCARD', P .. ':dead:' .. queue),
|
|
215
217
|
first,
|
|
216
|
-
}
|
|
218
|
+
}
|
|
219
|
+
if row[2] + row[3] + row[4] + row[5] + row[6] + row[7] > 0 then table.insert(out, row) end
|
|
217
220
|
end
|
|
218
221
|
return out
|
|
219
222
|
`.trim();
|
package/src/driver.ts
CHANGED
|
@@ -58,6 +58,21 @@ export const LIVE_STATES: ReadonlySet<JobState> = new Set<JobState>([
|
|
|
58
58
|
'suspended',
|
|
59
59
|
]);
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The states `stats()` counts, one bucket each (`ready` and `delayed` split by `runAt`): the live
|
|
63
|
+
* four and the two an operator is paged on. `done` and `cancelled` rows count toward nothing, so
|
|
64
|
+
* neither driver reads them — `SQL_STATS` and `x_jobs_depth_idx` spell the same six, and a queue
|
|
65
|
+
* holding only finished rows is not listed.
|
|
66
|
+
*/
|
|
67
|
+
export const STATS_STATES: ReadonlySet<JobState> = new Set<JobState>([
|
|
68
|
+
'ready',
|
|
69
|
+
'delayed',
|
|
70
|
+
'running',
|
|
71
|
+
'suspended',
|
|
72
|
+
'failed',
|
|
73
|
+
'dead',
|
|
74
|
+
]);
|
|
75
|
+
|
|
61
76
|
/**
|
|
62
77
|
* What a row's `lastError` says when the CLAIM buried it: its lease lapsed on the attempt that was
|
|
63
78
|
* its last, so nothing ever settled it and nothing may run it again. Fixed text, written by the
|
|
@@ -36,12 +36,26 @@ export function createQueueDepthPublisher(input: {
|
|
|
36
36
|
readonly workerId: string;
|
|
37
37
|
}): () => Promise<void> {
|
|
38
38
|
let publishedAt = Number.NEGATIVE_INFINITY;
|
|
39
|
+
// The queues the last successful round published. A gauge keeps the last value it was given and
|
|
40
|
+
// `stats()` lists a queue only while it holds a row it counts, so a queue that drained to `done`
|
|
41
|
+
// would read its final backlog forever — and hold the HPA scaled out — unless it is zeroed on the
|
|
42
|
+
// round it drops out. Bounded by the queue names, and replaced each round.
|
|
43
|
+
let published: ReadonlySet<string> = new Set();
|
|
39
44
|
return async (): Promise<void> => {
|
|
40
45
|
const now = nowMs(input.clock);
|
|
41
46
|
if (now - publishedAt < QUEUE_DEPTH_INTERVAL_MS) return;
|
|
42
47
|
publishedAt = now;
|
|
43
48
|
try {
|
|
44
|
-
|
|
49
|
+
const stats = await input.driver.stats();
|
|
50
|
+
const reported = new Set(stats.map((stat) => stat.queue));
|
|
51
|
+
for (const queue of published) {
|
|
52
|
+
if (reported.has(queue)) continue;
|
|
53
|
+
recordQueueDepth(queue, 0);
|
|
54
|
+
recordQueueOldestReady(queue, 0);
|
|
55
|
+
recordQueueDeadJobs(queue, 0);
|
|
56
|
+
}
|
|
57
|
+
published = reported;
|
|
58
|
+
for (const stat of stats) {
|
|
45
59
|
recordQueueDepth(stat.queue, stat.ready);
|
|
46
60
|
// Depth alone is not alertable: it cannot tell "10 jobs stuck for an hour" from "10 jobs
|
|
47
61
|
// enqueued a second ago", and `jobs_total{outcome="dead"}` is a rate, so a dead-letter
|