@zerotal/arch 1.7.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/docs/queue.md
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Queue
|
|
3
|
+
description: Offload slow work to background jobs that run outside the request cycle.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Queue
|
|
7
|
+
|
|
8
|
+
Offload slow work to background jobs. Define jobs, dispatch them (singly, batched,
|
|
9
|
+
or chained), pick a driver, and process them with a worker loop or Bun Worker
|
|
10
|
+
threads.
|
|
11
|
+
|
|
12
|
+
## Getting Started
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# in your project root
|
|
16
|
+
bun add @zerotal/queue
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Register the provider
|
|
20
|
+
|
|
21
|
+
Add `QueueProvider` to the providers array in `bootstrap/providers.ts`:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// bootstrap/providers.ts
|
|
25
|
+
import { QueueProvider } from "@zerotal/queue";
|
|
26
|
+
|
|
27
|
+
const providers = [
|
|
28
|
+
// …your other providers
|
|
29
|
+
QueueProvider,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export default providers;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Registering the provider switches on the following (in lifecycle order):
|
|
36
|
+
|
|
37
|
+
- `onRegister` — binds `queue` (a `QueueManager`) as a singleton, selecting the
|
|
38
|
+
driver from `config('queue.driver')`.
|
|
39
|
+
- `onBooting` — registers the internal `CallQueuedListener` job and points `Bus`
|
|
40
|
+
at the manager so batching and chaining work.
|
|
41
|
+
- `onBooted` — registers the `queue:work`, `queue:failed`, `queue:retry`, and
|
|
42
|
+
`queue:flush` commands.
|
|
43
|
+
- `onStarted` — starts the polling loop when running as a dedicated `worker`
|
|
44
|
+
process, or spawns Bun Worker threads when `workers > 0` on the web server.
|
|
45
|
+
- `onStopping` — clears the poll interval, drains in-flight jobs, and terminates
|
|
46
|
+
any Bun Worker threads, so nothing leaks between boots or test suites.
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
Create `config/queue.ts` using the `QueueConfig()` helper so every field stays
|
|
51
|
+
type-checked while defaults fill in the rest:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
// config/queue.ts
|
|
55
|
+
import { QueueConfig } from "@zerotal/queue";
|
|
56
|
+
import { env } from "zerotal";
|
|
57
|
+
|
|
58
|
+
export default QueueConfig({
|
|
59
|
+
driver: env("QUEUE_DRIVER", "sqlite"),
|
|
60
|
+
pollInterval: env("QUEUE_POLL_INTERVAL", 500),
|
|
61
|
+
queues: ["default"],
|
|
62
|
+
workers: env("QUEUE_WORKERS", 0),
|
|
63
|
+
// workerBootstrap: new URL("../bootstrap/queue-worker.ts", import.meta.url).href,
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
| Field | Required | Default | Description |
|
|
68
|
+
| ----------------- | ------------------ | ------------- | ------------------------------------------------------------------- |
|
|
69
|
+
| `driver` | no | `"sqlite"` | Queue driver: `"sqlite"`, `"redis"`, or `"sync"`. |
|
|
70
|
+
| `pollInterval` | no | `500` | Milliseconds between worker polls for new jobs. |
|
|
71
|
+
| `queues` | no | `["default"]` | Queue names the worker listens on. |
|
|
72
|
+
| `workers` | no | `0` | Number of Bun Worker threads to spawn. `0` = main-thread only. |
|
|
73
|
+
| `workerBootstrap` | when `workers > 0` | — | Absolute path or file URL to a module that imports every job class. |
|
|
74
|
+
|
|
75
|
+
> **Note** — `QueueConfig()` supplies the defaults above, so you only set the
|
|
76
|
+
> fields you want to change.
|
|
77
|
+
|
|
78
|
+
## Writing a job
|
|
79
|
+
|
|
80
|
+
A job is a class that extends `Job` and implements `handle()`. Constructor
|
|
81
|
+
arguments are the job's state — serialise them in `payload()` and restore them in
|
|
82
|
+
a static `fromPayload()`:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// app/jobs/NotifyFollowersJob.ts
|
|
86
|
+
import { Job, JobRegistry } from "@zerotal/queue";
|
|
87
|
+
|
|
88
|
+
export class NotifyFollowersJob extends Job {
|
|
89
|
+
// Route this job to a specific named queue (default: 'default')
|
|
90
|
+
override readonly queue = "notifications";
|
|
91
|
+
|
|
92
|
+
// Number of attempts before the job is marked as permanently failed (default: 3)
|
|
93
|
+
override readonly maxAttempts = 3;
|
|
94
|
+
|
|
95
|
+
// Milliseconds to wait between retries (default: 1000)
|
|
96
|
+
override readonly retryDelay = 5000;
|
|
97
|
+
|
|
98
|
+
constructor(public readonly postId: number) {
|
|
99
|
+
super();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Serialise state for storage
|
|
103
|
+
payload(): Record<string, unknown> {
|
|
104
|
+
return { postId: this.postId };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Deserialise from storage — called by the worker
|
|
108
|
+
static fromPayload(p: Record<string, unknown>): NotifyFollowersJob {
|
|
109
|
+
return new NotifyFollowersJob(p["postId"] as number);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The actual work
|
|
113
|
+
async handle(): Promise<void> {
|
|
114
|
+
const followers = await Follower.query().where("following_id", this.postId).get();
|
|
115
|
+
for (const follower of followers) {
|
|
116
|
+
await Mail.send(new NewPostMail(follower.email));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Register so the worker can deserialise it by class name
|
|
122
|
+
JobRegistry.register(NotifyFollowersJob as never);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Minimal job
|
|
126
|
+
|
|
127
|
+
A job with no constructor state needs only `handle()` plus the registration line:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// app/jobs/PruneDeletedContentJob.ts
|
|
131
|
+
import { Job, JobRegistry } from "@zerotal/queue";
|
|
132
|
+
|
|
133
|
+
export class PruneDeletedContentJob extends Job {
|
|
134
|
+
async handle(): Promise<void> {
|
|
135
|
+
await Post.query().withTrashed().where("deleted_at", "<", cutoff).forceDelete();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
JobRegistry.register(PruneDeletedContentJob as never);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Auto-registration
|
|
143
|
+
|
|
144
|
+
You don't import or wire up your jobs anywhere. Any job class placed under
|
|
145
|
+
`app/jobs/` is **auto-discovered at boot** — the convention loader imports each
|
|
146
|
+
file, which runs the `JobRegistry.register(...)` call at the bottom of it. That
|
|
147
|
+
registration is what lets the worker rebuild a job from its serialized payload by
|
|
148
|
+
class name, so `Queue.dispatch(new NotifyFollowersJob(id))` works from anywhere
|
|
149
|
+
with no manual import.
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
// app/jobs/
|
|
153
|
+
app/jobs/
|
|
154
|
+
NotifyFollowersJob.ts ← discovered + registered automatically
|
|
155
|
+
PruneDeletedContentJob.ts
|
|
156
|
+
SendWeeklyDigestJob.ts
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The discovery runs in every runtime (web, console, worker) so dispatching works
|
|
160
|
+
the same everywhere. The Bun Worker thread re-runs the same scan unless you point
|
|
161
|
+
`workerBootstrap` at an explicit barrel module. The only per-job requirement is
|
|
162
|
+
the `JobRegistry.register(...)` line — keep it at the bottom of each job file. See
|
|
163
|
+
[Conventions](/docs/conventions#jobs-appjobs).
|
|
164
|
+
|
|
165
|
+
## Dispatching jobs
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
// in a controller
|
|
169
|
+
import { Queue } from "@zerotal/queue";
|
|
170
|
+
|
|
171
|
+
// Dispatch a job to the queue
|
|
172
|
+
await Queue.dispatch(new NotifyFollowersJob(post.id));
|
|
173
|
+
|
|
174
|
+
// The job's `queue` property decides which named queue it lands on
|
|
175
|
+
await Queue.dispatch(new SendWeeklyDigestJob());
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The `Queue` facade resolves the `queue` container binding (a `QueueManager`). All
|
|
179
|
+
dispatched jobs are persisted to the queue driver; they are processed by the
|
|
180
|
+
worker loop, not the web request.
|
|
181
|
+
|
|
182
|
+
## Debounced jobs
|
|
183
|
+
|
|
184
|
+
A document saved eight times in a minute should rebuild its search index once, and the only rebuild anyone sees is the last one. Set `debounce` to a number of seconds and repeated dispatches collapse into a single run:
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
export class ReindexDocument extends Job {
|
|
188
|
+
/** Run 30s after the last dispatch, not once per dispatch. */
|
|
189
|
+
override readonly debounce = 30;
|
|
190
|
+
|
|
191
|
+
constructor(private documentId: number) {
|
|
192
|
+
super();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
override payload(): Record<string, unknown> {
|
|
196
|
+
return { documentId: this.documentId };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async handle(): Promise<void> {
|
|
200
|
+
await search.reindex(this.documentId);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
// Eight saves in quick succession…
|
|
207
|
+
for (const _ of edits) await Queue.dispatch(new ReindexDocument(doc.id));
|
|
208
|
+
// …one job, running 30s after the last one.
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
This is a **trailing** debounce, and the name is accurate: each dispatch pushes the run further out, and the job runs once, after the dispatches stop. The other behaviour that sometimes wears this word — the first dispatch runs and the rest are dropped within the window — is a different thing, and is not what this does. If you need that, dispatch once and rate-limit at the edge.
|
|
212
|
+
|
|
213
|
+
### The last payload wins
|
|
214
|
+
|
|
215
|
+
When eight dispatches collapse, the surviving job carries the **eighth** one's data. That is the whole premise: the earlier dispatches are stale, and running with the newest state is the point.
|
|
216
|
+
|
|
217
|
+
### What counts as "the same job"
|
|
218
|
+
|
|
219
|
+
By default, the class name plus the serialised payload. So `ReindexDocument(1)` and `ReindexDocument(2)` are different work and never collapse into each other — which is what makes the common case need no configuration.
|
|
220
|
+
|
|
221
|
+
Override `debounceKey()` when two payloads mean the same work. A job carrying a timestamp or a request id is unique on every dispatch and would otherwise never collapse with anything:
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
export class ReindexDocument extends Job {
|
|
225
|
+
override readonly debounce = 30;
|
|
226
|
+
|
|
227
|
+
override payload(): Record<string, unknown> {
|
|
228
|
+
return { documentId: this.documentId, requestedAt: Date.now() };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Ignore `requestedAt` — two requests for the same document are one job. */
|
|
232
|
+
override debounceKey(): string {
|
|
233
|
+
return `reindex:${this.documentId}`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The key lives in the queue's own backing store, so it is stable **across processes**. A debounce that only held inside one worker would appear to work in development and do nothing in production, where more than one process dispatches.
|
|
239
|
+
|
|
240
|
+
### Driver support
|
|
241
|
+
|
|
242
|
+
| Driver | Debounce |
|
|
243
|
+
| -------- | ------------------------------------------------------------------ |
|
|
244
|
+
| `sqlite` | Yes — one `INSERT … ON CONFLICT` against a partial unique index |
|
|
245
|
+
| `redis` | Yes — one `EVAL`, so two processes cannot both enqueue |
|
|
246
|
+
| `sync` | Inert: every job runs inline, so there is no window to collapse in |
|
|
247
|
+
|
|
248
|
+
Collapsing has to be **atomic**, or two processes dispatching at the same instant both find nothing pending and both enqueue — the exact failure the feature exists to prevent. A driver that cannot promise that throws `QueueDebounceUnsupportedError` (`E_QUEUE_DEBOUNCE_UNSUPPORTED`) rather than silently degrading to a per-process debounce, and the message names the driver and what to change.
|
|
249
|
+
|
|
250
|
+
### A job already being worked is never collapsed into
|
|
251
|
+
|
|
252
|
+
Once a worker has claimed a job, it is running, and the next dispatch is genuinely new work — it becomes its own pending job rather than trying to reschedule something already in flight. On `sqlite` the unique index covers only unreserved rows; on `redis` the key is released when the job is promoted to the ready list.
|
|
253
|
+
|
|
254
|
+
This is the behaviour you want for the reindex case: a save that lands while the previous rebuild is running still gets a rebuild.
|
|
255
|
+
|
|
256
|
+
## Job batching
|
|
257
|
+
|
|
258
|
+
Batch a set of jobs and react when they all finish. Batching uses the
|
|
259
|
+
`zerotal_job_batches` table (auto-created by `SqliteDriver`).
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
// in a controller
|
|
263
|
+
import { Bus } from "@zerotal/queue";
|
|
264
|
+
|
|
265
|
+
// Dispatch 10,000 import jobs; send a summary email when all finish.
|
|
266
|
+
const batch = await Bus.batch(rows.map((row) => new ImportCsvRowJob(row)))
|
|
267
|
+
.name("csv-import-2024") // optional label
|
|
268
|
+
.then(new SendImportSummaryJob(user)) // dispatched when ALL succeed
|
|
269
|
+
.catch(new NotifyAdminOfFailureJob(user)) // dispatched when ANY fail
|
|
270
|
+
.finally(new CleanupTempFilesJob(uploadId)) // always dispatched when complete
|
|
271
|
+
.dispatch();
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
> **Note** — `then`, `catch`, and `finally` accept `Job | Job[]`. They are
|
|
275
|
+
> serialized as class name + payload and stored in the batch row, so they survive
|
|
276
|
+
> process restarts.
|
|
277
|
+
|
|
278
|
+
> **Warning** — Batching requires `SqliteDriver`. `SyncDriver` and `RedisDriver`
|
|
279
|
+
> do not implement the batch table, so `Bus.batch(...).dispatch()` throws a
|
|
280
|
+
> `QueueBatchingUnsupportedError` with them.
|
|
281
|
+
|
|
282
|
+
### Batch status object
|
|
283
|
+
|
|
284
|
+
`Bus.batch(...).dispatch()` resolves to a `Batch` instance:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// after .dispatch()
|
|
288
|
+
batch.id; // UUID string
|
|
289
|
+
batch.name; // label from .name()
|
|
290
|
+
batch.totalJobs; // jobs dispatched
|
|
291
|
+
batch.pendingJobs; // jobs not yet processed
|
|
292
|
+
batch.failedJobs; // jobs permanently failed
|
|
293
|
+
batch.failedJobIds; // array of zerotal_jobs.id values
|
|
294
|
+
batch.finished(); // true once the batch has a finishedAt timestamp
|
|
295
|
+
batch.failed(); // true if any job failed
|
|
296
|
+
batch.progress(); // 0.0 → 1.0
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
> **Note** — The `Batch` you get back is a snapshot taken at dispatch time; it is
|
|
300
|
+
> not a live view. Re-fetch the batch from the driver to see updated progress.
|
|
301
|
+
|
|
302
|
+
## Job chaining
|
|
303
|
+
|
|
304
|
+
Run jobs sequentially: each job dispatches the next one only after it succeeds. If
|
|
305
|
+
any job fails, the rest of the chain is abandoned.
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
// in a controller
|
|
309
|
+
import { Bus } from "@zerotal/queue";
|
|
310
|
+
|
|
311
|
+
await Bus.chain([
|
|
312
|
+
new ValidateImportJob(fileId),
|
|
313
|
+
new ProcessImportJob(fileId),
|
|
314
|
+
new SendImportCompleteEmailJob(user),
|
|
315
|
+
]).dispatch();
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
The chain is stored in the payload of each job under `__chain` — no extra table is
|
|
319
|
+
needed, so chaining works with any driver.
|
|
320
|
+
|
|
321
|
+
## Processing jobs
|
|
322
|
+
|
|
323
|
+
### Dedicated worker process
|
|
324
|
+
|
|
325
|
+
The standard way to process jobs in production is a long-running worker process:
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
# in your project root
|
|
329
|
+
bun zt queue:work # process the 'default' queue
|
|
330
|
+
bun zt queue:work --queue=emails # process a specific queue
|
|
331
|
+
bun zt queue:work --once # process one job, then exit
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
The worker polls continuously, retries failed jobs up to `maxAttempts`, and moves
|
|
335
|
+
permanently-failed jobs to the `zerotal_failed_jobs` table.
|
|
336
|
+
|
|
337
|
+
### Manual processing
|
|
338
|
+
|
|
339
|
+
For development or small apps that don't need a separate process, drive
|
|
340
|
+
`Queue.processNext()` on an interval from a provider:
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
// in AppServiceProvider.onStarted()
|
|
344
|
+
import { Queue } from "@zerotal/queue";
|
|
345
|
+
|
|
346
|
+
const queues = ["default", "notifications", "emails"];
|
|
347
|
+
setInterval(async () => {
|
|
348
|
+
for (const q of queues) {
|
|
349
|
+
await Queue.processNext(q).catch(console.error);
|
|
350
|
+
}
|
|
351
|
+
}, 500);
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Which should I use?
|
|
355
|
+
|
|
356
|
+
- **`queue:work`** — production and anything with real volume. Failures, retries,
|
|
357
|
+
and graceful shutdown are handled for you in a process you can scale separately.
|
|
358
|
+
- **Manual `setInterval`** — local development or tiny apps where running a second
|
|
359
|
+
process isn't worth it.
|
|
360
|
+
- **`sync` driver** — tests and scripts where you want jobs to run inline and
|
|
361
|
+
immediately rather than in the background.
|
|
362
|
+
|
|
363
|
+
### Draining on shutdown
|
|
364
|
+
|
|
365
|
+
`Queue.isShuttingDown` flips to `true` once the provider's `onStopping` hook runs
|
|
366
|
+
(on `SIGTERM`). The worker stops accepting new jobs, and `QueueManager.drain()`
|
|
367
|
+
waits for in-flight jobs to finish before the process exits.
|
|
368
|
+
|
|
369
|
+
### In the admin panel
|
|
370
|
+
|
|
371
|
+
When [`@zerotal/admin`](/docs/admin) is installed, the queue puts a **Jobs**
|
|
372
|
+
console in the panel — no configuration, just both providers registered. It has a
|
|
373
|
+
tab each for failed jobs, pending jobs, per-queue depth, and this process's
|
|
374
|
+
throughput counters, and it offers the same operations as the CLI commands: retry
|
|
375
|
+
or forget a single failed job, clear all of them, flush the pending queue. The
|
|
376
|
+
sidebar entry carries a failed-job count, which is the number you want to notice
|
|
377
|
+
without going looking for it.
|
|
378
|
+
|
|
379
|
+
Access is gated on the `queue.view` ability, checked both when the sidebar is
|
|
380
|
+
drawn and again on every action. To keep the queue provider but drop the console,
|
|
381
|
+
set `plugins: { queue: false }` in `config/admin.ts`.
|
|
382
|
+
|
|
383
|
+
The queue does not depend on the admin package to do this — it resolves the
|
|
384
|
+
panel's contribution surface from the container at boot and describes the console
|
|
385
|
+
as data. An app running the queue without the panel pulls in nothing extra.
|
|
386
|
+
|
|
387
|
+
## Queue drivers
|
|
388
|
+
|
|
389
|
+
| Driver | Notes |
|
|
390
|
+
| ---------- | ----------------------------------------------------------------------------------------- |
|
|
391
|
+
| `"sqlite"` | Jobs stored in a `zerotal_jobs` table in the app database. Default. Good for most apps. |
|
|
392
|
+
| `"redis"` | Jobs stored in Redis lists. Better throughput for high-volume apps. Requires `REDIS_URL`. |
|
|
393
|
+
| `"sync"` | Jobs run immediately and synchronously in the dispatching process. Intended for tests. |
|
|
394
|
+
|
|
395
|
+
> **Warning** — Only `"sqlite"` supports batching. Pick it if you rely on
|
|
396
|
+
> `Bus.batch()`.
|
|
397
|
+
|
|
398
|
+
## Bun Worker threads
|
|
399
|
+
|
|
400
|
+
Set `workers > 0` in `config/queue.ts` and the web server process runs jobs in Bun
|
|
401
|
+
Worker threads — genuine OS threads — so CPU-bound jobs don't stall the HTTP event
|
|
402
|
+
loop. The provider builds and wires the `WorkerPool` for you from config; you do
|
|
403
|
+
not construct it yourself.
|
|
404
|
+
|
|
405
|
+
```typescript
|
|
406
|
+
// config/queue.ts
|
|
407
|
+
import { QueueConfig } from "@zerotal/queue";
|
|
408
|
+
|
|
409
|
+
export default QueueConfig({
|
|
410
|
+
workers: 4, // spawn 4 Bun Worker threads
|
|
411
|
+
workerBootstrap: new URL("../bootstrap/queue-worker.ts", import.meta.url).href,
|
|
412
|
+
});
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
> **Note** — When `workerBootstrap` is omitted, each worker thread re-discovers
|
|
416
|
+
> jobs by scanning `app/jobs/*.ts`. Set `workerBootstrap` to a barrel module that
|
|
417
|
+
> imports every job when you want to skip the filesystem scan.
|
|
418
|
+
|
|
419
|
+
> **Warning** — `workerBootstrap` is required in practice once `workers > 0` if
|
|
420
|
+
> your jobs aren't all under `app/jobs/`: a worker thread can only run a job whose
|
|
421
|
+
> class it has registered.
|
|
422
|
+
|
|
423
|
+
On `SIGTERM` the provider drains the manager and calls `WorkerPool.terminate()`,
|
|
424
|
+
which stops every thread. Any in-flight or queued work is resolved with
|
|
425
|
+
`{ success: false }` so the driver can retry it.
|
|
426
|
+
|
|
427
|
+
## Testing
|
|
428
|
+
|
|
429
|
+
`QueueFake` swaps the `queue` binding for a fake that captures dispatched jobs
|
|
430
|
+
instead of running them, so you can assert on them:
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
// in a test
|
|
434
|
+
import { QueueFake } from "@zerotal/queue";
|
|
435
|
+
|
|
436
|
+
const queue = QueueFake.install(); // replaces the 'queue' binding with a fake
|
|
437
|
+
|
|
438
|
+
await MyController.store({ http: ctx });
|
|
439
|
+
|
|
440
|
+
queue.assertDispatched(NotifyFollowersJob);
|
|
441
|
+
queue.assertDispatchedCount(1);
|
|
442
|
+
|
|
443
|
+
queue.restore(); // call in afterEach
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
## References
|
|
447
|
+
|
|
448
|
+
### Commands
|
|
449
|
+
|
|
450
|
+
`@zerotal/queue` ships the worker and the failed-job tools:
|
|
451
|
+
|
|
452
|
+
| Command | What it does |
|
|
453
|
+
| -------------------------- | ---------------------------------------------------------------- |
|
|
454
|
+
| `bun zt queue:work` | Process jobs from the queue — run this as a daemon in production |
|
|
455
|
+
| `bun zt queue:work --once` | Process a single job, then exit |
|
|
456
|
+
| `bun zt queue:failed` | List all failed jobs |
|
|
457
|
+
| `bun zt queue:retry <id>` | Retry a failed job by id, or `all` to retry everything |
|
|
458
|
+
| `bun zt queue:flush` | Delete all failed jobs from the database |
|
|
459
|
+
|
|
460
|
+
### `Queue` facade
|
|
461
|
+
|
|
462
|
+
The facade proxies a `QueueManager` resolved from the `queue` binding.
|
|
463
|
+
|
|
464
|
+
| Method | Signature | Description |
|
|
465
|
+
| ---------------- | -------------------------------------- | ------------------------------------------------ |
|
|
466
|
+
| `dispatch` | `(job: Job) => Promise<void>` | Persist a job to its queue for later processing. |
|
|
467
|
+
| `processNext` | `(queue?: string) => Promise<boolean>` | Pop and run the next job; `false` if none. |
|
|
468
|
+
| `size` | `(queue?: string) => Promise<number>` | Count pending jobs on a queue. |
|
|
469
|
+
| `drain` | `() => Promise<void>` | Stop accepting work and wait for in-flight jobs. |
|
|
470
|
+
| `isShuttingDown` | `boolean` | `true` once shutdown has begun. |
|
|
471
|
+
|
|
472
|
+
### `Bus`
|
|
473
|
+
|
|
474
|
+
| Method | Signature | Description |
|
|
475
|
+
| ------- | ------------------------------------------------ | ---------------------------------------------------- |
|
|
476
|
+
| `batch` | `(jobs: Job[]) => PendingBatch` | Start a batch builder (`.then`/`.catch`/`.finally`). |
|
|
477
|
+
| `chain` | `(jobs: Job[]) => { dispatch(): Promise<void> }` | Run jobs sequentially, stopping on first failure. |
|
|
478
|
+
|
|
479
|
+
### `PendingBatch`
|
|
480
|
+
|
|
481
|
+
| Method | Signature | Description |
|
|
482
|
+
| ---------- | ----------------------------- | ------------------------------------------- |
|
|
483
|
+
| `name` | `(n: string) => this` | Label the batch. |
|
|
484
|
+
| `then` | `(job: Job \| Job[]) => this` | Dispatched when all batched jobs succeed. |
|
|
485
|
+
| `catch` | `(job: Job \| Job[]) => this` | Dispatched when any batched job fails. |
|
|
486
|
+
| `finally` | `(job: Job \| Job[]) => this` | Always dispatched once the batch completes. |
|
|
487
|
+
| `dispatch` | `() => Promise<Batch>` | Persist the batch and its jobs. |
|
|
488
|
+
|
|
489
|
+
### `Job` (extend this)
|
|
490
|
+
|
|
491
|
+
| Member | Type | Description |
|
|
492
|
+
| ------------- | ---------------------------------------------- | ---------------------------------------------- |
|
|
493
|
+
| `queue` | `string` (default `"default"`) | Named queue to route this job to. |
|
|
494
|
+
| `maxAttempts` | `number` (default `3`) | Attempts before the job is permanently failed. |
|
|
495
|
+
| `retryDelay` | `number` (default `1000`) | Milliseconds to wait between retries. |
|
|
496
|
+
| `handle` | `() => Promise<void>` | The work to perform. Required. |
|
|
497
|
+
| `payload` | `() => Record<string, unknown>` | Serialise constructor state for storage. |
|
|
498
|
+
| `fromPayload` | `(p: Record<string, unknown>) => Job` (static) | Rebuild the job from its payload. |
|
|
499
|
+
|
|
500
|
+
### `QueueFake`
|
|
501
|
+
|
|
502
|
+
| Method | Signature | Description |
|
|
503
|
+
| ------------------------- | ----------------------------------------------- | -------------------------------------- |
|
|
504
|
+
| `install` | `() => QueueFake` (static) | Swap the `queue` binding for the fake. |
|
|
505
|
+
| `restore` | `() => void` | Restore the original `queue` binding. |
|
|
506
|
+
| `dispatched` | `() => Job[]` | All captured jobs. |
|
|
507
|
+
| `assertDispatched` | `(JobClass, filter?: (job) => boolean) => void` | Assert a job class was dispatched. |
|
|
508
|
+
| `assertNotDispatched` | `(JobClass) => void` | Assert a job class was not dispatched. |
|
|
509
|
+
| `assertNothingDispatched` | `() => void` | Assert no jobs were dispatched. |
|
|
510
|
+
| `assertDispatchedCount` | `(count: number) => void` | Assert the exact dispatched count. |
|
|
511
|
+
|
|
512
|
+
### Errors
|
|
513
|
+
|
|
514
|
+
Every queue error extends `QueueError`, which extends the framework's
|
|
515
|
+
`ZerotalError` — so `catch (e) { if (e instanceof QueueError) … }` catches the lot
|
|
516
|
+
while leaving unrelated failures alone.
|
|
517
|
+
|
|
518
|
+
| Error | Code | Raised when |
|
|
519
|
+
| ------------------------------- | ------------------------------ | ----------------------------------------------------------------- |
|
|
520
|
+
| `QueueError` | `E_QUEUE` | Base class — catch this to handle any queue failure. |
|
|
521
|
+
| `QueueNotInitializedError` | `E_QUEUE_NOT_INITIALIZED` | Dispatching before `QueueProvider` is registered. |
|
|
522
|
+
| `QueueShuttingDownError` | `E_QUEUE_SHUTTING_DOWN` | Dispatching during a graceful shutdown — the manager is draining. |
|
|
523
|
+
| `QueueBatchingUnsupportedError` | `E_QUEUE_BATCHING_UNSUPPORTED` | Using batches on a driver that has no batch support. |
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
// in a controller or service
|
|
527
|
+
import { QueueError, QueueShuttingDownError } from "@zerotal/queue";
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
await ProcessPayment.dispatch({ orderId });
|
|
531
|
+
} catch (error) {
|
|
532
|
+
// A shutdown is expected during a deploy — retry rather than alert.
|
|
533
|
+
if (error instanceof QueueShuttingDownError) return retryLater(orderId);
|
|
534
|
+
if (error instanceof QueueError) return reportQueueOutage(error);
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
`QueueShuttingDownError` is the one worth handling explicitly: it means the
|
|
540
|
+
process is draining, not that anything is broken, so the right response is to
|
|
541
|
+
re-dispatch on the next boot rather than to fail the request.
|
|
542
|
+
|
|
543
|
+
## Next steps
|
|
544
|
+
|
|
545
|
+
- [Scheduler](/docs/scheduler) — run recurring jobs alongside the queue worker.
|
|
546
|
+
- [Notifications](/docs/notifications) — a common payload for background jobs.
|
|
547
|
+
- [Notifications](/docs/notifications) — queue user notifications off the request path.
|
|
548
|
+
- [Conventions](/docs/conventions#jobs-appjobs) — how `app/jobs/` auto-registration works.
|
|
549
|
+
- [Testing mocking](/docs/testing/mocking) — assert dispatched jobs with `QueueFake`.
|