@zerotal/queue 1.3.0 → 1.5.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/CHANGELOG.md CHANGED
@@ -8,6 +8,52 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.5.0] — 2026-08-15
12
+
13
+ ### Added
14
+
15
+ - **Debounced jobs — `debounce` on a `Job` collapses repeated dispatches into one run.**
16
+ A document saved eight times in a minute rebuilt its search index eight times, seven
17
+ of them wasted, and the eighth was the only one whose result anybody saw.
18
+
19
+ It is a **trailing** debounce and the name is accurate: each dispatch pushes the run
20
+ further out, and the job runs once, after the dispatches stop. The surviving job
21
+ carries the **newest** payload, because the premise is that the earlier ones are stale.
22
+
23
+ The default key is the class name plus the serialised payload, so `ReindexDocument(1)`
24
+ and `ReindexDocument(2)` never collapse into each other and the common case needs no
25
+ configuration. Override `debounceKey()` when two payloads mean the same work — a job
26
+ carrying a timestamp is unique on every dispatch and would otherwise collapse with
27
+ nothing.
28
+
29
+ **The key lives in the queue's backing store, so it is stable across processes.**
30
+ Collapsing has to be atomic or two processes dispatching at the same instant both find
31
+ nothing pending and both enqueue, which is the failure the feature exists to prevent:
32
+ `sqlite` does it as one `INSERT … ON CONFLICT` against a partial unique index covering
33
+ only unreserved rows, `redis` as one `EVAL`. A driver that cannot promise atomicity
34
+ throws `E_QUEUE_DEBOUNCE_UNSUPPORTED` rather than degrading to a per-process debounce —
35
+ which would appear to work in development and do nothing in production.
36
+
37
+ A job a worker has already claimed is never collapsed into: it is running, so the next
38
+ dispatch is genuinely new work. `sync` ignores `debounce`, because every job runs inline
39
+ and there is no window for a second dispatch to arrive in.
40
+
41
+ ### Added
42
+
43
+ - **The worker runs under `bun zt dev`, in its own tab.** An app with a queue needed
44
+ a second terminal and the discipline to restart the right one after a change; now
45
+ `QueueProvider` registers `queue:work` as a dev process and the runner supervises
46
+ it beside the server, restartable on its own with `r`.
47
+
48
+ It declines to appear when it would have nothing to do — under the `sync` driver,
49
+ where every dispatched job runs inline on the request, and when `queue.workers` is
50
+ set, where the server's own thread pool is already draining the queue. A tab that
51
+ polls an empty queue forever is worse than no tab, because it looks like it is
52
+ working.
53
+
54
+ Drop it with `app.dev.disable: ["queue"]`, or replace it by registering `queue`
55
+ yourself in `app.dev.processes`.
56
+
11
57
  ## [1.0.3] — 2026-08-07
12
58
 
13
59
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/queue",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -30,8 +30,8 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@zerotal/core": "1.3.0",
34
- "@zerotal/orm": "1.3.0"
33
+ "@zerotal/core": "1.5.0",
34
+ "@zerotal/orm": "1.5.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0"
package/src/Job.ts CHANGED
@@ -16,6 +16,59 @@ export abstract class Job {
16
16
  /** Set by Bus.chain() — remaining jobs to dispatch after this one succeeds. */
17
17
  _chain: SerializedJob[] | undefined = undefined;
18
18
 
19
+ /**
20
+ * Collapse repeated dispatches into a single run, `debounce` **seconds** after
21
+ * the last one. Unset (the default) dispatches immediately, as before.
22
+ *
23
+ * This is a **trailing** debounce, and the name is accurate: each dispatch
24
+ * pushes the run further out, and the job runs once, after the dispatches
25
+ * stop. It is the shape the problem has — a document saved eight times in a
26
+ * minute should rebuild its search index once, and the only rebuild anyone
27
+ * sees is the last one. A *leading* behaviour, where the first dispatch runs
28
+ * and the rest are dropped, is a different thing and is not this.
29
+ *
30
+ * **The last payload wins.** When eight dispatches collapse, the surviving
31
+ * job carries the eighth one's data, because the whole premise is that the
32
+ * earlier ones are stale.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * export class ReindexDocument extends Job {
37
+ * override readonly debounce = 30;
38
+ * constructor(private documentId: number) { super(); }
39
+ * override payload() { return { documentId: this.documentId }; }
40
+ * async handle() { await search.reindex(this.documentId); }
41
+ * }
42
+ * ```
43
+ */
44
+ readonly debounce?: number;
45
+
46
+ /**
47
+ * What counts as "the same job" for {@link debounce}.
48
+ *
49
+ * Defaults to the class name plus the serialised payload, so
50
+ * `ReindexDocument(1)` and `ReindexDocument(2)` are different work and do not
51
+ * collapse into each other — which is what makes the common case need no
52
+ * configuration at all.
53
+ *
54
+ * Override when two payloads mean the same work. A job carrying a timestamp,
55
+ * or a request id, is unique on every dispatch and would otherwise never
56
+ * collapse with anything:
57
+ *
58
+ * ```ts
59
+ * override debounceKey(): string {
60
+ * return `reindex:${this.documentId}`; // ignore the requestedAt field
61
+ * }
62
+ * ```
63
+ *
64
+ * The key is stored in the queue's own backing store, so it is stable across
65
+ * processes. A debounce that only held inside one worker would appear to work
66
+ * in development and do nothing in production, where there is more than one.
67
+ */
68
+ debounceKey(): string {
69
+ return `${this.className}:${JSON.stringify(this.payload())}`;
70
+ }
71
+
19
72
  /** The job's work — implement this in every subclass */
20
73
  abstract handle(): Promise<void>;
21
74
 
@@ -1,5 +1,9 @@
1
1
  import { randomUUIDv7 } from "bun";
2
- import { QueueShuttingDownError, QueueBatchingUnsupportedError } from "./errors.ts";
2
+ import {
3
+ QueueShuttingDownError,
4
+ QueueBatchingUnsupportedError,
5
+ QueueDebounceUnsupportedError,
6
+ } from "./errors.ts";
3
7
  import type { QueueDriver, JobRecord } from "./drivers/QueueDriver.ts";
4
8
  import type { Job } from "./Job.ts";
5
9
  import type { PendingBatch } from "./PendingBatch.ts";
@@ -115,16 +119,29 @@ export class QueueManager {
115
119
  payload["__chain"] = job._chain;
116
120
  }
117
121
 
118
- await this._driver.push({
122
+ // A debounced job runs `debounce` seconds after the *last* dispatch, so each
123
+ // one sets the run-at forward from now; the driver collapses it into whatever
124
+ // is already pending under the same key.
125
+ const debounceSeconds = job.debounce;
126
+ const record = {
119
127
  queue: job.queue,
120
128
  className: job.className,
121
129
  payload: JSON.stringify(payload),
122
130
  attempts: 0,
123
131
  maxAttempts: job.maxAttempts,
124
132
  retryDelay: job.retryDelay,
125
- availableAt: Math.floor(Date.now() / 1000),
133
+ availableAt: Math.floor(Date.now() / 1000) + (debounceSeconds ?? 0),
126
134
  batchId: job.batchId,
127
- });
135
+ };
136
+
137
+ if (debounceSeconds !== undefined && debounceSeconds > 0 && !this._isSyncDriver()) {
138
+ if (!this._driver.pushDebounced) {
139
+ throw new QueueDebounceUnsupportedError(job.className, this._driverName());
140
+ }
141
+ await this._driver.pushDebounced(record, job.debounceKey());
142
+ } else {
143
+ await this._driver.push(record);
144
+ }
128
145
 
129
146
  _fireJobEvent(job.className, job.queue, "dispatched", 0);
130
147
 
@@ -424,4 +441,9 @@ export class QueueManager {
424
441
  private _isSyncDriver(): boolean {
425
442
  return this._driver instanceof SyncDriver;
426
443
  }
444
+
445
+ /** The driver's class name, for an error that has to name what to change. */
446
+ private _driverName(): string {
447
+ return this._driver.constructor.name;
448
+ }
427
449
  }
@@ -9,6 +9,8 @@ export interface JobRecord {
9
9
  availableAt: number;
10
10
  createdAt: string;
11
11
  batchId?: string | undefined;
12
+ /** Debounce identity, when the job declared one. See `Job.debounce`. */
13
+ debounceKey?: string | undefined;
12
14
  }
13
15
 
14
16
  /**
@@ -32,6 +34,21 @@ export interface FailedRecord {
32
34
  export interface QueueDriver {
33
35
  // ── Active queue ──────────────────────────────────────────────────────────
34
36
  push(record: Omit<JobRecord, "id" | "createdAt">): Promise<void>;
37
+ /**
38
+ * Enqueue, or push an already-pending job with the same `key` out to this
39
+ * record's `availableAt`, replacing its payload.
40
+ *
41
+ * Optional, because it is the one operation a backing store has to be able to
42
+ * do *atomically*: two processes dispatching at once must produce one pending
43
+ * job, not two. A driver that cannot promise that does not implement this, and
44
+ * `Job.debounce` refuses rather than silently degrading to a per-process
45
+ * debounce — which would appear to work in development and do nothing in
46
+ * production, where there is more than one worker.
47
+ *
48
+ * Only jobs not yet reserved are collapsed into: once a worker has claimed one,
49
+ * it is running, and the next dispatch is genuinely new work.
50
+ */
51
+ pushDebounced?(record: Omit<JobRecord, "id" | "createdAt">, key: string): Promise<void>;
35
52
  pop(queue?: string): Promise<JobRecord | null>;
36
53
  fail(record: JobRecord, error: string): Promise<void>;
37
54
  retry(record: JobRecord): Promise<void>;
@@ -84,6 +84,10 @@ export class RedisDriver implements QueueDriver {
84
84
  private _failKey(queue: string): string {
85
85
  return `${this._prefix}${queue}:failed`;
86
86
  }
87
+ /** Hash: debounce key → the delayed-set member currently holding it. */
88
+ private _debounceKey(queue: string): string {
89
+ return `${this._prefix}${queue}:debounce`;
90
+ }
87
91
  /** Set of queue names seen by push() — lets flush() find every key family. */
88
92
  private _queuesKey(): string {
89
93
  return `${this._prefix}_queues`;
@@ -117,6 +121,47 @@ export class RedisDriver implements QueueDriver {
117
121
  }
118
122
  }
119
123
 
124
+ /**
125
+ * Collapse a dispatch into whatever is already pending under the same key.
126
+ *
127
+ * One EVAL, because the read ("is something pending?") and the write have to be
128
+ * one step: two processes dispatching at the same instant would otherwise both
129
+ * see nothing pending and both enqueue, which is the failure the whole feature
130
+ * exists to prevent.
131
+ *
132
+ * The hash maps key → the delayed-set member holding it. `ZREM` returning 0
133
+ * means that member is gone — already promoted to ready, or claimed — so the
134
+ * dispatch becomes a fresh job rather than collapsing into something running.
135
+ */
136
+ private static readonly _DEBOUNCE_LUA =
137
+ // KEYS: 1 delayed zset, 2 debounce hash. ARGV: 1 key, 2 score, 3 payload.
138
+ "local prev = redis.call('HGET', KEYS[2], ARGV[1]) " +
139
+ "if prev then redis.call('ZREM', KEYS[1], prev) end " +
140
+ "redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3]) " +
141
+ "redis.call('HSET', KEYS[2], ARGV[1], ARGV[3]) " +
142
+ "return 1";
143
+
144
+ async pushDebounced(record: Omit<JobRecord, "id" | "createdAt">, key: string): Promise<void> {
145
+ const redis = await this._redis();
146
+ const id = Number(await redis.incr(this._idKey()));
147
+ const payload = JSON.stringify({
148
+ ...record,
149
+ id,
150
+ createdAt: new Date().toISOString(),
151
+ debounceKey: key,
152
+ });
153
+ await redis.sadd(this._queuesKey(), record.queue);
154
+ await redis.send("EVAL", [
155
+ RedisDriver._DEBOUNCE_LUA,
156
+ "2",
157
+ this._delayedKey(record.queue),
158
+ this._debounceKey(record.queue),
159
+ key,
160
+ String(record.availableAt),
161
+ payload,
162
+ ]);
163
+ }
164
+
120
165
  /**
121
166
  * Move every member of sorted set `from` with score ≤ `maxScore` onto the
122
167
  * ready list. `ZREM` returns 0 when another worker won the race for a
@@ -129,7 +174,19 @@ export class RedisDriver implements QueueDriver {
129
174
  if (!due || due.length === 0) return;
130
175
  for (const member of due) {
131
176
  const removed = Number(await redis.send("ZREM", [from, member]));
132
- if (removed === 1) await redis.lpush(this._key(queue), member);
177
+ if (removed !== 1) continue;
178
+ await redis.lpush(this._key(queue), member);
179
+ // The job is runnable now, so it no longer holds its debounce key — the
180
+ // next dispatch is new work rather than a reschedule of something a worker
181
+ // is about to pick up.
182
+ try {
183
+ const parsed = JSON.parse(member) as { debounceKey?: string };
184
+ if (parsed.debounceKey) {
185
+ await redis.send("HDEL", [this._debounceKey(queue), parsed.debounceKey]);
186
+ }
187
+ } catch {
188
+ // An unparseable member is already on the ready list; nothing to release.
189
+ }
133
190
  }
134
191
  }
135
192
 
@@ -238,6 +295,7 @@ export class RedisDriver implements QueueDriver {
238
295
  await redis.del(this._delayedKey(queue));
239
296
  await redis.del(this._reservedKey(queue));
240
297
  await redis.del(this._failKey(queue));
298
+ await redis.del(this._debounceKey(queue));
241
299
  }
242
300
  await redis.del(this._queuesKey());
243
301
  await redis.del(this._idKey());
@@ -14,6 +14,7 @@ type JobRow = {
14
14
  reserved_at: number | null;
15
15
  created_at: string;
16
16
  batch_id: string | null;
17
+ debounce_key: string | null;
17
18
  };
18
19
 
19
20
  type FailedRow = {
@@ -105,10 +106,26 @@ export class SqliteDriver implements QueueDriver {
105
106
  } catch {
106
107
  // Column already exists — safe to ignore.
107
108
  }
109
+ // Add debounce_key column to existing databases that predate this migration.
110
+ try {
111
+ await this._sql`ALTER TABLE zerotal_jobs ADD COLUMN debounce_key TEXT`;
112
+ } catch {
113
+ // Column already exists — safe to ignore.
114
+ }
108
115
  await this._sql`
109
116
  CREATE INDEX IF NOT EXISTS zerotal_jobs_pop_idx
110
117
  ON zerotal_jobs (queue, available_at, reserved_at)
111
118
  `;
119
+ // The debounce upsert's conflict target, and the thing that makes it atomic:
120
+ // at most one *unreserved* pending job may hold a given key. Partial, so a
121
+ // job already claimed by a worker no longer occupies the key — the next
122
+ // dispatch is new work, not a collapse into something already running — and
123
+ // so the overwhelming majority of jobs, which set no key, are not indexed.
124
+ await this._sql`
125
+ CREATE UNIQUE INDEX IF NOT EXISTS zerotal_jobs_debounce_idx
126
+ ON zerotal_jobs (debounce_key)
127
+ WHERE debounce_key IS NOT NULL AND reserved_at IS NULL
128
+ `;
112
129
  }
113
130
 
114
131
  private _mapRow(row: JobRow): JobRecord {
@@ -121,6 +138,7 @@ export class SqliteDriver implements QueueDriver {
121
138
  maxAttempts: row.max_attempts,
122
139
  retryDelay: row.retry_delay,
123
140
  availableAt: row.available_at,
141
+ debounceKey: row.debounce_key ?? undefined,
124
142
  createdAt: row.created_at,
125
143
  batchId: row.batch_id ?? undefined,
126
144
  };
@@ -154,6 +172,35 @@ export class SqliteDriver implements QueueDriver {
154
172
  `;
155
173
  }
156
174
 
175
+ /**
176
+ * One statement, so two processes dispatching at once cannot both insert.
177
+ *
178
+ * `ON CONFLICT` targets the partial unique index on `debounce_key`, which only
179
+ * covers unreserved rows — so a dispatch collapses into a pending job and never
180
+ * into one a worker has already claimed. The update takes the *new* payload and
181
+ * run-at, because the point of collapsing is that the earlier dispatch is stale.
182
+ */
183
+ async pushDebounced(record: Omit<JobRecord, "id" | "createdAt">, key: string): Promise<void> {
184
+ await this._ready;
185
+ const now = new Date().toISOString();
186
+ const batchId = record.batchId ?? null;
187
+ await this._sql`
188
+ INSERT INTO zerotal_jobs
189
+ (queue, class_name, payload, attempts, max_attempts, retry_delay, available_at, created_at, batch_id, debounce_key)
190
+ VALUES
191
+ (${record.queue}, ${record.className}, ${record.payload},
192
+ ${record.attempts}, ${record.maxAttempts}, ${record.retryDelay},
193
+ ${record.availableAt}, ${now}, ${batchId}, ${key})
194
+ ON CONFLICT (debounce_key) WHERE debounce_key IS NOT NULL AND reserved_at IS NULL
195
+ DO UPDATE SET
196
+ available_at = excluded.available_at,
197
+ payload = excluded.payload,
198
+ queue = excluded.queue,
199
+ max_attempts = excluded.max_attempts,
200
+ retry_delay = excluded.retry_delay
201
+ `;
202
+ }
203
+
157
204
  async pop(queue = "default"): Promise<JobRecord | null> {
158
205
  await this._ready;
159
206
  const now = Math.floor(Date.now() / 1000);
package/src/errors.ts CHANGED
@@ -40,3 +40,21 @@ export class QueueBatchingUnsupportedError extends QueueError {
40
40
  );
41
41
  }
42
42
  }
43
+
44
+ /** Thrown when a job declares `debounce` on a driver that cannot collapse atomically. */
45
+ export class QueueDebounceUnsupportedError extends QueueError {
46
+ constructor(className: string, driver: string) {
47
+ super(
48
+ `[Zerotal] ${className} declares debounce, but the ${driver} queue driver cannot ` +
49
+ `collapse dispatches atomically.
50
+ ` +
51
+ `Collapsing in this process instead would appear to work in development and do ` +
52
+ `nothing in production, where more than one worker dispatches — so this refuses ` +
53
+ `rather than degrading quietly.
54
+ ` +
55
+ `Fix: use the sqlite or redis driver, or remove \`debounce\` from ${className}.`,
56
+ "E_QUEUE_DEBOUNCE_UNSUPPORTED",
57
+ 500,
58
+ );
59
+ }
60
+ }
@@ -1,5 +1,5 @@
1
1
  import { ServiceProvider } from "@zerotal/core";
2
- import type { AppEnvironment } from "@zerotal/core";
2
+ import type { AppEnvironment, DevProcessDefinition } from "@zerotal/core";
3
3
  import type { ConfigManager } from "@zerotal/core/config";
4
4
  import { _getConnection } from "@zerotal/orm";
5
5
  import { QueueManager } from "../QueueManager.ts";
@@ -29,6 +29,33 @@ export class QueueProvider extends ServiceProvider {
29
29
  private _workerPool: WorkerPool | undefined = undefined;
30
30
  private _disposeObservability: (() => void) | undefined = undefined;
31
31
 
32
+ /**
33
+ * The worker that `bun zt dev` runs beside the server.
34
+ *
35
+ * Two conditions, and both are about not putting a tab on screen that has
36
+ * nothing to do. The `sync` driver runs every dispatched job inline on the
37
+ * request, so a worker would poll an empty queue forever; and an in-process
38
+ * `workers` pool means the server thread is already draining the queue, so a
39
+ * second consumer is duplicated work rather than more of it.
40
+ */
41
+ override devProcesses(): DevProcessDefinition[] {
42
+ return [
43
+ {
44
+ name: "queue",
45
+ command: "queue:work",
46
+ enabled: () => {
47
+ const config = this.app.container.makeSync("config") as ConfigManager;
48
+ const driver = config.get<string>("queue.driver", "sqlite");
49
+ const workers = config.get<number>("queue.workers", 0);
50
+ return driver !== "sync" && (workers ?? 0) === 0;
51
+ },
52
+ // A worker that exits cleanly has been told to stop; one that crashes
53
+ // has not, and the developer wants it back.
54
+ restart: "on-failure",
55
+ },
56
+ ];
57
+ }
58
+
32
59
  override onRegister(): void {
33
60
  // Refuse a production boot on structural queue misconfiguration (unknown
34
61
  // driver, workers without a bootstrap module). Runs in the boot-time config pass.