@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 +46 -0
- package/package.json +3 -3
- package/src/Job.ts +53 -0
- package/src/QueueManager.ts +26 -4
- package/src/drivers/QueueDriver.ts +17 -0
- package/src/drivers/RedisDriver.ts +59 -1
- package/src/drivers/SqliteDriver.ts +47 -0
- package/src/errors.ts +18 -0
- package/src/provider/QueueProvider.ts +28 -1
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
|
+
"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.
|
|
34
|
-
"@zerotal/orm": "1.
|
|
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
|
|
package/src/QueueManager.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { randomUUIDv7 } from "bun";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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.
|