@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "27.16.0",
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.0",
37
- "@ultimat3/db": "27.16.0",
38
- "@ultimat3/entity": "27.16.0",
39
- "@ultimat3/schema": "27.16.0",
40
- "@ultimat3/time": "27.16.0"
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
  }
@@ -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,
@@ -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
@@ -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
- table.insert(out, {
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
- for (const stat of await input.driver.stats()) {
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