@kitr/jobs 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +12 -2
- package/dist/commands-Bo7c-TIC.d.ts +89 -0
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +103 -0
- package/dist/index.d.ts +187 -0
- package/dist/index.js +350 -0
- package/dist/kitr.d.ts +41 -0
- package/dist/kitr.js +73 -0
- package/dist/provider.d.ts +6 -0
- package/dist/provider.js +26 -0
- package/docs/api.md +312 -0
- package/docs/index.md +789 -0
- package/docs/overview.md +81 -0
- package/package.json +70 -3
package/docs/index.md
ADDED
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Jobs
|
|
3
|
+
description: Run work in the background or on a schedule, with retries when it fails.
|
|
4
|
+
order: 1
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Jobs
|
|
8
|
+
|
|
9
|
+
Some work should not keep a request waiting: emailing an invoice, generating a PDF, syncing orders, pruning expired sessions at 3am. `@kitr/jobs` hands that work to a background worker. You define a job with a typed payload, dispatch it from a controller, and the worker runs it with retries, backoff, a timeout and an optional schedule.
|
|
10
|
+
|
|
11
|
+
> [!SUMMARY]
|
|
12
|
+
> Background and scheduled jobs with typed payloads, retries and timeouts. Add it with `kitr add jobs`; the overview is at [/packages/jobs](/packages/jobs).
|
|
13
|
+
|
|
14
|
+
It runs on the server only. The built-in queue lives in memory, which suits one process; for several servers, or jobs that must survive a restart, use a shared driver ([Share the queue between processes](#share-the-queue-between-processes)). Work that must finish before the response, such as charging a card, should be called directly.
|
|
15
|
+
|
|
16
|
+
The examples follow Acme's app at `app.acme.com`, which generates a monthly invoice for each team, emails it, and cleans up old data every night.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
kitr add jobs
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
<details>
|
|
25
|
+
<summary>Everything kitr add changes</summary>
|
|
26
|
+
|
|
27
|
+
What `kitr add` does, in order:
|
|
28
|
+
|
|
29
|
+
- Installs `@kitr/jobs`. Jobs require the [server](/packages/server) package, so `kitr add` also adds `server` (and its `logger`) when the project does not have them yet.
|
|
30
|
+
- Adds `'@kitr/jobs'` to `packages` and `() => import('@kitr/jobs/provider')` to `providers` in `kitrrc.ts`.
|
|
31
|
+
- Writes `config/jobs.ts` (shown in [Configure](#configure)) and regenerates `config/index.ts`.
|
|
32
|
+
- Writes an example job, `server/jobs/example.ts`, exporting `exampleJob`.
|
|
33
|
+
- When you answer no to the worker question, adds a `worker` script (`kitr jobs:work`) to `package.json`.
|
|
34
|
+
- Adds a `## kitr: jobs` section to `AGENTS.md` telling coding agents to put background work in `server/jobs/` and never `setTimeout` work in a request, plus a line about the separate worker when there is one.
|
|
35
|
+
|
|
36
|
+
No kit includes jobs; add it to any kit with a server platform.
|
|
37
|
+
|
|
38
|
+
`kitr remove jobs` reverses every step above. Your jobs in `server/jobs/` stay, because you may have edited them.
|
|
39
|
+
|
|
40
|
+
</details>
|
|
41
|
+
|
|
42
|
+
`kitr add jobs` asks one question. Agents and CI answer it with a flag:
|
|
43
|
+
|
|
44
|
+
| Question | Default | Flag |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| Run the job worker inside the server process? | yes | `--worker` / `--no-worker` |
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
kitr add jobs --no-worker
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
With `--no-worker`, `config/jobs.ts` gets `worker: false` and the worker runs in its own process, as in [Run the worker in its own process](#run-the-worker-in-its-own-process).
|
|
53
|
+
|
|
54
|
+
## Configure
|
|
55
|
+
|
|
56
|
+
`kitr add jobs` writes this file:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// config/jobs.ts
|
|
60
|
+
import { defineConfig } from '@kitr/jobs'
|
|
61
|
+
import { exampleJob } from '#server/jobs/example'
|
|
62
|
+
|
|
63
|
+
export default defineConfig({
|
|
64
|
+
// Every job the worker runs (kitr make:job <name> writes one in server/jobs/).
|
|
65
|
+
jobs: [exampleJob],
|
|
66
|
+
// In-memory queue by default: fine for one process. For several, pass a shared driver.
|
|
67
|
+
concurrency: 5,
|
|
68
|
+
worker: true, // false when a separate worker process runs the jobs
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| Option | Default | Description |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `jobs` | `[]` | Every job the worker can run. A queued job not listed here is dropped with a warning. |
|
|
75
|
+
| `driver` | the memory driver | Where queued jobs wait. `createRedisJobsDriver(redis)` shares the queue between processes; see [Share the queue between processes](#share-the-queue-between-processes) and [Extend](#extend). |
|
|
76
|
+
| `concurrency` | `5` | How many jobs run at once in each process. |
|
|
77
|
+
| `pollInterval` | `500` | Milliseconds between checks of the queue. |
|
|
78
|
+
| `worker` | `true` | Start the worker when the server starts. `false` when a separate process runs jobs. |
|
|
79
|
+
| `onFailed(job, error)` | none | Called when a job has used up its retries, or its payload fails the schema. Receives the `QueuedJob` and the last error. |
|
|
80
|
+
|
|
81
|
+
Per-job options (`retries`, `backoff`, `timeout`, `schedule`) live on the job itself; see [Define a job](#define-a-job).
|
|
82
|
+
|
|
83
|
+
## Dispatch your first job
|
|
84
|
+
|
|
85
|
+
Generate a job:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
kitr make:job generate-invoice
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
That writes `server/jobs/generate-invoice.ts`. Edit it to build a team's invoice for a month:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// server/jobs/generate-invoice.ts
|
|
95
|
+
import { defineJob } from '@kitr/jobs'
|
|
96
|
+
import { z } from 'zod'
|
|
97
|
+
|
|
98
|
+
export const generateInvoiceInput = z.object({
|
|
99
|
+
teamId: z.string(),
|
|
100
|
+
month: z.string().regex(/^\d{4}-\d{2}$/),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
export const generateInvoice = defineJob('generate_invoice', {
|
|
104
|
+
schema: generateInvoiceInput,
|
|
105
|
+
retries: 3,
|
|
106
|
+
async run({ teamId, month }, { attempt }) {
|
|
107
|
+
// Load the team's usage, render the PDF, store it under storage/invoices/.
|
|
108
|
+
return { invoiceNumber: `INV-${month}-${teamId}`, attempt }
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
List it in `config/jobs.ts`, next to the example:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// config/jobs.ts
|
|
117
|
+
import { defineConfig } from '@kitr/jobs'
|
|
118
|
+
import { exampleJob } from '#server/jobs/example'
|
|
119
|
+
import { generateInvoice } from '#server/jobs/generate_invoice'
|
|
120
|
+
|
|
121
|
+
export default defineConfig({
|
|
122
|
+
jobs: [exampleJob, generateInvoice],
|
|
123
|
+
concurrency: 5,
|
|
124
|
+
worker: true,
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Dispatch it from a controller. `dispatch()` checks the payload, queues the job and resolves with its id straight away:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
// server/controllers/invoices-controller.ts
|
|
132
|
+
import type { HttpContext } from '@kitr/api'
|
|
133
|
+
import { useJobs } from '@kitr/jobs'
|
|
134
|
+
import { generateInvoice } from '#server/jobs/generate_invoice'
|
|
135
|
+
|
|
136
|
+
export default class InvoicesController {
|
|
137
|
+
async store({ app, params, response }: HttpContext<{ teamId: string }>) {
|
|
138
|
+
const jobId = await useJobs(app).dispatch(generateInvoice, {
|
|
139
|
+
teamId: params.teamId,
|
|
140
|
+
month: '2026-10',
|
|
141
|
+
})
|
|
142
|
+
return response.json({ jobId }, { status: 202 })
|
|
143
|
+
// => { "jobId": "3f0c9a52-6b1e-4d7a-9a51-0e2f8c1d7b44" }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Register the route with `api.post('/teams/:teamId/invoices', [InvoicesController, 'store'])` in `server/routes.ts`. The request answers at once; within half a second (`pollInterval`) the worker picks the job up.
|
|
149
|
+
|
|
150
|
+
The payload is typed from the schema. A wrong value at run time, such as `month: 'October'`, makes `dispatch()` throw a `VALIDATION` error before anything is queued; in a controller that becomes a 422.
|
|
151
|
+
|
|
152
|
+
## Define a job
|
|
153
|
+
|
|
154
|
+
`defineJob(name, options)` returns a job object.
|
|
155
|
+
|
|
156
|
+
> [!WARNING] Keep job names stable
|
|
157
|
+
> The name is how the queue refers to the job. A queued job whose name no longer exists is dropped.
|
|
158
|
+
|
|
159
|
+
| Option | Default | Description |
|
|
160
|
+
| --- | --- | --- |
|
|
161
|
+
| `run(payload, ctx)` | required | The work. Its return value is what `jobs.run()` resolves with. |
|
|
162
|
+
| `schema` | none | A [Standard Schema](https://standardschema.dev) (zod, valibot, arktype). Checked at dispatch and again when the worker picks the job up. |
|
|
163
|
+
| `retries` | `3` | Retries after a failure. `3` means up to 4 attempts in all. |
|
|
164
|
+
| `backoff` | `1000` | Milliseconds before the first retry. It doubles each time: 1s, 2s, 4s. |
|
|
165
|
+
| `timeout` | `60000` | Milliseconds a run may take. Then its `signal` aborts and the attempt fails. |
|
|
166
|
+
| `schedule` | none | A 5-field cron expression or `{ every: '5m' }`. See [Run a job on a schedule](#run-a-job-on-a-schedule). |
|
|
167
|
+
|
|
168
|
+
The second argument of `run` is the `JobContext`:
|
|
169
|
+
|
|
170
|
+
| Field | Description |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| `app` | The app, for `app.container.use(...)` and `useMailer(app)`. |
|
|
173
|
+
| `attempt` | `1` on the first run, one more per retry. |
|
|
174
|
+
| `jobId` | The id `dispatch()` returned. Stays the same across retries. |
|
|
175
|
+
| `signal` | An `AbortSignal` that aborts at the timeout. Pass it to `fetch` and database calls. |
|
|
176
|
+
|
|
177
|
+
Without a schema, a type parameter types the payload: `defineJob<{ teamId: string }>('notify_team', { run: ... })`. A job with no payload is dispatched with `undefined`.
|
|
178
|
+
|
|
179
|
+
## Dispatch a job
|
|
180
|
+
|
|
181
|
+
On the server, `useJobs(app).dispatch(job, payload, options?)` queues a job and resolves with its id. It works in controllers, actions, services and other jobs:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// server/jobs/close-month.ts
|
|
185
|
+
import { defineJob, useJobs } from '@kitr/jobs'
|
|
186
|
+
import { generateInvoice } from '#server/jobs/generate_invoice'
|
|
187
|
+
|
|
188
|
+
/** Fans out one invoice job per team. */
|
|
189
|
+
export const closeMonth = defineJob<{ month: string; teamIds: string[] }>('close_month', {
|
|
190
|
+
async run({ month, teamIds }, { app }) {
|
|
191
|
+
const jobs = useJobs(app)
|
|
192
|
+
const ids = await Promise.all(teamIds.map((teamId) => jobs.dispatch(generateInvoice, { teamId, month })))
|
|
193
|
+
return ids.length
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
| Where | How to reach the queue |
|
|
199
|
+
| --- | --- |
|
|
200
|
+
| A controller | `useJobs(ctx.app)` |
|
|
201
|
+
| A job | `useJobs(ctx.app)` in `run(payload, ctx)` |
|
|
202
|
+
| An action or service during a request | `useJobs(useRequestApp())`, with `useRequestApp` from `@kitr/server` |
|
|
203
|
+
| A script | `useJobs(server.app)` after `await server.boot()` |
|
|
204
|
+
|
|
205
|
+
`useJobs(app)` is `app.container.use('jobs')`. Pass ids and small plain values in the payload, not whole records; the job loads fresh records when it runs.
|
|
206
|
+
|
|
207
|
+
> [!WARNING] No setTimeout in handlers
|
|
208
|
+
> Work started with `setTimeout` has no retries, nothing waits for it at shutdown, and its errors are lost. Dispatch a job.
|
|
209
|
+
|
|
210
|
+
## Delay a job
|
|
211
|
+
|
|
212
|
+
Pass `delay` in milliseconds, or `at` for a fixed time:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
// server/services/trial-service.ts
|
|
216
|
+
import type { KitrApp } from '@kitr/kitr/app'
|
|
217
|
+
import { useJobs } from '@kitr/jobs'
|
|
218
|
+
import { generateInvoice } from '#server/jobs/generate_invoice'
|
|
219
|
+
|
|
220
|
+
export async function scheduleFirstInvoice(app: KitrApp, teamId: string) {
|
|
221
|
+
const jobs = useJobs(app)
|
|
222
|
+
// Ten minutes from now.
|
|
223
|
+
await jobs.dispatch(generateInvoice, { teamId, month: '2026-11' }, { delay: 10 * 60_000 })
|
|
224
|
+
// On the first of next month, 09:00 UTC.
|
|
225
|
+
await jobs.dispatch(
|
|
226
|
+
generateInvoice,
|
|
227
|
+
{ teamId, month: '2026-11' },
|
|
228
|
+
{ at: new Date('2026-12-01T09:00:00Z') },
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
| Option | Description |
|
|
234
|
+
| --- | --- |
|
|
235
|
+
| `delay` | Milliseconds from now. |
|
|
236
|
+
| `at` | A `Date`. Takes precedence over `delay`. A time in the past runs at the next poll. |
|
|
237
|
+
|
|
238
|
+
With the memory driver a delayed job is lost if the process restarts before it runs.
|
|
239
|
+
|
|
240
|
+
## Run a job on a schedule
|
|
241
|
+
|
|
242
|
+
Give a job a `schedule` and list it in `config/jobs.ts`. The worker dispatches it; you never call `dispatch()`:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// server/jobs/prune-sessions.ts
|
|
246
|
+
import { defineJob } from '@kitr/jobs'
|
|
247
|
+
|
|
248
|
+
/** Every day at 03:00 server time. */
|
|
249
|
+
export const pruneSessions = defineJob('prune_sessions', {
|
|
250
|
+
schedule: '0 3 * * *',
|
|
251
|
+
async run(_payload, { app }) {
|
|
252
|
+
app.container.use('logger').info('pruning expired sessions')
|
|
253
|
+
},
|
|
254
|
+
})
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
// server/jobs/sync-orders.ts
|
|
259
|
+
import { defineJob } from '@kitr/jobs'
|
|
260
|
+
|
|
261
|
+
/** Every five minutes, starting when the worker starts. */
|
|
262
|
+
export const syncOrders = defineJob('sync_orders', {
|
|
263
|
+
schedule: { every: '5m' },
|
|
264
|
+
timeout: 4 * 60_000,
|
|
265
|
+
async run(_payload, { signal }) {
|
|
266
|
+
const res = await fetch('https://payments.acme.com/orders?since=5m', { signal })
|
|
267
|
+
return res.status
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Cron expressions have five fields, read in the server's local time zone:
|
|
273
|
+
|
|
274
|
+
| Field | Range | Example |
|
|
275
|
+
| --- | --- | --- |
|
|
276
|
+
| minute | `0`–`59` | `*/15`: every 15 minutes |
|
|
277
|
+
| hour | `0`–`23` | `9-17`: office hours |
|
|
278
|
+
| day of month | `1`–`31` | `1`: the first |
|
|
279
|
+
| month | `1`–`12` | `1,4,7,10`: each quarter |
|
|
280
|
+
| weekday | `0`–`7`, `0` and `7` are Sunday | `1-5`: weekdays |
|
|
281
|
+
|
|
282
|
+
Each field takes `*`, a number, a list (`1,15`), a range (`1-5`) or a step (`*/10`, `0-30/10`). Names like `MON` are not supported. When both day of month and weekday are set, either may match, as in standard cron.
|
|
283
|
+
|
|
284
|
+
Intervals take a number and a unit: `250ms`, `30s`, `5m`, `1.5h`, `1d`. An interval job runs once when the worker starts, then every interval after.
|
|
285
|
+
|
|
286
|
+
> [!WARNING] No schema that rejects undefined
|
|
287
|
+
> A scheduled job is dispatched with an `undefined` payload. If its schema rejects that, the worker logs `[jobs] could not queue scheduled <name>` and the job never runs.
|
|
288
|
+
|
|
289
|
+
A bad expression or interval throws at once, when the app boots. Check new expressions with `cronMatches()`:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
// scripts/check_cron.ts
|
|
293
|
+
import { cronMatches, parseInterval } from '@kitr/jobs'
|
|
294
|
+
|
|
295
|
+
cronMatches('0 3 * * *', new Date(2026, 10, 7, 3, 0)) // => true
|
|
296
|
+
cronMatches('*/15 9-17 * * 1-5', new Date(2026, 10, 7, 9, 30)) // => false, a Saturday
|
|
297
|
+
parseInterval('5m') // => 300000
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
With the memory driver, each worker queues its own copy, so two servers run `prune_sessions` twice; the [Redis driver](#share-the-queue-between-processes) queues each run once. Cron reads each server's local time, so give every server the same `TZ`.
|
|
301
|
+
|
|
302
|
+
## Retry a failing job
|
|
303
|
+
|
|
304
|
+
When `run` throws or times out, the worker queues the job again after `backoff × 2^(attempt - 1)` milliseconds, until it has used its retries:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
// server/jobs/send-invoice-email.ts
|
|
308
|
+
import { defineJob } from '@kitr/jobs'
|
|
309
|
+
import { useMailer } from '@kitr/mail'
|
|
310
|
+
import { z } from 'zod'
|
|
311
|
+
import { invoiceReadyMail } from '#server/mail/invoice_ready'
|
|
312
|
+
|
|
313
|
+
export const sendInvoiceEmail = defineJob('send_invoice_email', {
|
|
314
|
+
schema: z.object({ teamName: z.string(), invoiceNumber: z.string(), email: z.string() }),
|
|
315
|
+
retries: 5,
|
|
316
|
+
backoff: 10_000,
|
|
317
|
+
async run({ teamName, invoiceNumber, email }, { app }) {
|
|
318
|
+
await useMailer(app).send(invoiceReadyMail, { teamName, invoiceNumber, total: '$480.00' }, { to: email })
|
|
319
|
+
},
|
|
320
|
+
})
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
A mail outage is retried after 10s, 20s, 40s, 80s and 160s, then given up. The logger shows each step:
|
|
324
|
+
|
|
325
|
+
```
|
|
326
|
+
WARN 09:00:10.112 [app] [jobs] send_invoice_email failed (attempt 1), retry in 10000ms
|
|
327
|
+
WARN 09:00:20.140 [app] [jobs] send_invoice_email failed (attempt 2), retry in 20000ms
|
|
328
|
+
...
|
|
329
|
+
ERROR 09:05:10.301 [app] [jobs] send_invoice_email failed after 6 attempts
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
A payload that fails the schema when the worker picks it up is dropped without a retry and logged as `[jobs] <name> dropped: bad payload`.
|
|
333
|
+
|
|
334
|
+
> [!WARNING] Make run safe to repeat
|
|
335
|
+
> A job that fails after charging a card is retried, and charges again unless you check first.
|
|
336
|
+
|
|
337
|
+
## Handle a job that keeps failing
|
|
338
|
+
|
|
339
|
+
`onFailed` in `config/jobs.ts` runs when a job has used its retries or its payload was rejected. Use it to alert someone:
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
// config/jobs.ts
|
|
343
|
+
import { defineConfig } from '@kitr/jobs'
|
|
344
|
+
import { generateInvoice } from '#server/jobs/generate_invoice'
|
|
345
|
+
import { sendInvoiceEmail } from '#server/jobs/send_invoice_email'
|
|
346
|
+
|
|
347
|
+
export default defineConfig({
|
|
348
|
+
jobs: [generateInvoice, sendInvoiceEmail],
|
|
349
|
+
onFailed(job, error) {
|
|
350
|
+
console.error(`job ${job.name} (${job.id}) gave up after ${job.attempt} attempts`, error)
|
|
351
|
+
},
|
|
352
|
+
})
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`job` is the `QueuedJob`:
|
|
356
|
+
|
|
357
|
+
| Field | Description |
|
|
358
|
+
| --- | --- |
|
|
359
|
+
| `id` | The id `dispatch()` returned. |
|
|
360
|
+
| `name` | The job's name. |
|
|
361
|
+
| `payload` | The payload as queued. |
|
|
362
|
+
| `attempt` | The attempt that failed last. |
|
|
363
|
+
| `runAt` | When that attempt was due, in milliseconds since the epoch. |
|
|
364
|
+
|
|
365
|
+
`onFailed` has no `app`. To report through [monitoring](/packages/monitoring), catch inside `run` with `ctx.app` and rethrow so the job still retries:
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
// server/jobs/generate-invoice.ts
|
|
369
|
+
import { defineJob } from '@kitr/jobs'
|
|
370
|
+
|
|
371
|
+
export const generateInvoice = defineJob<{ teamId: string; month: string }>('generate_invoice', {
|
|
372
|
+
retries: 3,
|
|
373
|
+
async run({ teamId, month }, { app, attempt }) {
|
|
374
|
+
try {
|
|
375
|
+
return { invoiceNumber: `INV-${month}-${teamId}` }
|
|
376
|
+
} catch (error) {
|
|
377
|
+
app.container.use('monitoring').captureException(error, { extra: { teamId, month, attempt } })
|
|
378
|
+
throw error
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
## Time out a stuck job
|
|
385
|
+
|
|
386
|
+
Each attempt gets `timeout` milliseconds (60 seconds by default). At the limit `ctx.signal` aborts and the attempt fails and is retried. Pass the signal to anything that accepts one, so the work really stops:
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
// server/jobs/export-projects.ts
|
|
390
|
+
import { defineJob } from '@kitr/jobs'
|
|
391
|
+
|
|
392
|
+
export const exportProjects = defineJob<{ teamId: string }>('export_projects', {
|
|
393
|
+
timeout: 5 * 60_000,
|
|
394
|
+
retries: 1,
|
|
395
|
+
async run({ teamId }, { signal }) {
|
|
396
|
+
const res = await fetch(`https://exports.acme.com/teams/${teamId}`, { method: 'POST', signal })
|
|
397
|
+
if (!res.ok) throw new Error(`export failed: ${res.status}`)
|
|
398
|
+
return res.status
|
|
399
|
+
},
|
|
400
|
+
})
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
> [!WARNING] Pass the signal
|
|
404
|
+
> A job that ignores its signal still fails at the timeout, but its code keeps running until it finishes on its own.
|
|
405
|
+
|
|
406
|
+
## Run a job inline
|
|
407
|
+
|
|
408
|
+
`useJobs(app).run(job, payload)` runs a job now, in this process, with its retries and timeout. Nothing is queued:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
// scripts/backfill_invoices.ts
|
|
412
|
+
import { useJobs } from '@kitr/jobs'
|
|
413
|
+
import { generateInvoice } from '#server/jobs/generate_invoice'
|
|
414
|
+
import { server } from '#server/main'
|
|
415
|
+
|
|
416
|
+
await server.boot()
|
|
417
|
+
const result = await useJobs(server.app).run(generateInvoice, { teamId: 'team_engines', month: '2026-09' })
|
|
418
|
+
// => { invoiceNumber: 'INV-2026-09-team_engines', attempt: 1 }
|
|
419
|
+
await server.close()
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
When every attempt fails, `run()` throws the last error after the real backoff, and does not call `onFailed`.
|
|
423
|
+
|
|
424
|
+
## Share the queue between processes
|
|
425
|
+
|
|
426
|
+
With the memory driver, other servers never see the queue's jobs, and a restart loses them. To share one queue, use `createRedisJobsDriver` with an [ioredis](https://github.com/redis/ioredis) client:
|
|
427
|
+
|
|
428
|
+
```sh
|
|
429
|
+
pnpm add ioredis
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
// config/jobs.ts
|
|
434
|
+
import { createRedisJobsDriver, defineConfig } from '@kitr/jobs'
|
|
435
|
+
import { Redis } from 'ioredis'
|
|
436
|
+
import { generateInvoice } from '#server/jobs/generate_invoice'
|
|
437
|
+
import { pruneSessions } from '#server/jobs/prune_sessions'
|
|
438
|
+
|
|
439
|
+
export default defineConfig({
|
|
440
|
+
jobs: [generateInvoice, pruneSessions],
|
|
441
|
+
driver: createRedisJobsDriver(new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379'), {
|
|
442
|
+
prefix: 'acme-app:jobs:',
|
|
443
|
+
visibilityTimeout: 10 * 60_000,
|
|
444
|
+
}),
|
|
445
|
+
})
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Add the connection string to `.env`:
|
|
449
|
+
|
|
450
|
+
```sh
|
|
451
|
+
REDIS_URL=redis://localhost:6379
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
| Option | Default | Description |
|
|
455
|
+
| --- | --- | --- |
|
|
456
|
+
| `prefix` | `'kitr:jobs:'` | Prepended to the driver's keys, so apps sharing a Redis keep separate queues. |
|
|
457
|
+
| `visibilityTimeout` | `300000` | Milliseconds a pulled job stays hidden from other workers. Keep it above your longest job `timeout`. |
|
|
458
|
+
|
|
459
|
+
> [!WARNING] Keep visibilityTimeout above your longest job
|
|
460
|
+
> A job not acknowledged within `visibilityTimeout` returns to the queue, so a longer job can run twice.
|
|
461
|
+
|
|
462
|
+
What the driver guarantees:
|
|
463
|
+
|
|
464
|
+
- **Each due job runs on one worker.** Twenty invoices dispatched while three workers poll are generated twenty times, not sixty.
|
|
465
|
+
- **A crashed worker's jobs come back** after `visibilityTimeout`.
|
|
466
|
+
- **Scheduled jobs queue once per run**, through a Redis lock named after the job and the minute.
|
|
467
|
+
- **Retries keep their id**, so `onFailed` and your logs see one job.
|
|
468
|
+
|
|
469
|
+
<details>
|
|
470
|
+
<summary>Redis keys</summary>
|
|
471
|
+
|
|
472
|
+
The queue lives under `<prefix>queue` (a sorted set by `runAt`), `<prefix>reserved` and `<prefix>data`. `jobs.driver.size()` counts waiting and reserved jobs across every process.
|
|
473
|
+
|
|
474
|
+
</details>
|
|
475
|
+
|
|
476
|
+
## Run the worker in its own process
|
|
477
|
+
|
|
478
|
+
By default the worker runs inside the web server, starts with it and drains on shutdown. When long jobs should not compete with requests, run a worker process and turn the worker off in the web servers.
|
|
479
|
+
|
|
480
|
+
> [!WARNING] Needs a shared queue
|
|
481
|
+
> With the memory driver, a job dispatched in a web server never reaches a separate worker.
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
// config/jobs.ts
|
|
485
|
+
import { createRedisJobsDriver, defineConfig } from '@kitr/jobs'
|
|
486
|
+
import { Redis } from 'ioredis'
|
|
487
|
+
import { generateInvoice } from '#server/jobs/generate_invoice'
|
|
488
|
+
import { pruneSessions } from '#server/jobs/prune_sessions'
|
|
489
|
+
|
|
490
|
+
export default defineConfig({
|
|
491
|
+
jobs: [generateInvoice, pruneSessions],
|
|
492
|
+
driver: createRedisJobsDriver(new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379')),
|
|
493
|
+
// JOBS_WORKER=off on the web servers: they only queue, the worker process runs jobs.
|
|
494
|
+
worker: process.env.JOBS_WORKER !== 'off',
|
|
495
|
+
})
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
// server/worker.ts
|
|
500
|
+
import { useJobs } from '@kitr/jobs'
|
|
501
|
+
import { server } from '#server/main'
|
|
502
|
+
|
|
503
|
+
await server.boot()
|
|
504
|
+
const jobs = useJobs(server.app)
|
|
505
|
+
jobs.start()
|
|
506
|
+
server.logger.info('worker started', { jobs: [...jobs.jobs.keys()] })
|
|
507
|
+
|
|
508
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
509
|
+
process.once(signal, async () => {
|
|
510
|
+
// Waits for running jobs, then runs every provider's shutdown.
|
|
511
|
+
await server.close()
|
|
512
|
+
process.exit(0)
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Deploy the web servers with `JOBS_WORKER=off`, and run workers beside them with `tsx server/worker.ts` or `kitr jobs:work`. `jobs.stop()` waits for running jobs, each for at most its timeout, so set your platform's shutdown grace period above your longest job timeout.
|
|
518
|
+
|
|
519
|
+
## Read the queue
|
|
520
|
+
|
|
521
|
+
The `Jobs` service exposes its state for health checks:
|
|
522
|
+
|
|
523
|
+
```ts
|
|
524
|
+
// server/controllers/admin-jobs-controller.ts
|
|
525
|
+
import type { HttpContext } from '@kitr/api'
|
|
526
|
+
import { useJobs } from '@kitr/jobs'
|
|
527
|
+
|
|
528
|
+
export default class AdminJobsController {
|
|
529
|
+
async show({ app }: HttpContext) {
|
|
530
|
+
const jobs = useJobs(app)
|
|
531
|
+
return {
|
|
532
|
+
driver: jobs.driver.name,
|
|
533
|
+
running: jobs.running,
|
|
534
|
+
queued: await jobs.driver.size(),
|
|
535
|
+
jobs: [...jobs.jobs.keys()],
|
|
536
|
+
}
|
|
537
|
+
// => { driver: 'memory', running: true, queued: 0, jobs: ['example', 'generate_invoice'] }
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
| Member | Description |
|
|
543
|
+
| --- | --- |
|
|
544
|
+
| `register(...jobs)` | Add jobs after boot. Returns the service, so calls chain. Checks cron and interval syntax. |
|
|
545
|
+
| `dispatch(job, payload, options?)` | Queue a job; resolves with its id. |
|
|
546
|
+
| `run(job, payload, attempt?)` | Run a job inline with retries; resolves with its result. |
|
|
547
|
+
| `start()` | Start polling. Calling it twice does nothing. |
|
|
548
|
+
| `stop()` | Stop polling and wait for running jobs. |
|
|
549
|
+
| `running` | Whether the worker is polling. |
|
|
550
|
+
| `driver` | The queue driver. |
|
|
551
|
+
| `jobs` | A map of known jobs by name. |
|
|
552
|
+
|
|
553
|
+
> [!WARNING] List every job in config/jobs.ts
|
|
554
|
+
> `dispatch()` registers an unknown job in this process only, so a separate worker would not know it.
|
|
555
|
+
|
|
556
|
+
## Commands
|
|
557
|
+
|
|
558
|
+
| Command | What it does |
|
|
559
|
+
| --- | --- |
|
|
560
|
+
| `kitr jobs:list` | Loads `config/jobs.ts` and prints each job's name, retries, timeout and schedule. `--json` prints the rows as JSON. |
|
|
561
|
+
| `kitr jobs:work` | Run the worker until stopped, then finish running jobs. `--entry <file>` boots another file. |
|
|
562
|
+
| `kitr make:job <name>` | Writes a job to `server/jobs/<name>.ts` (kebab-case, nested names allowed). `--force` overwrites an existing file. |
|
|
563
|
+
| `kitr eject stub job` | Copies the generator's template to `stubs/job.ts`, so `make:job` writes your own starting point. |
|
|
564
|
+
|
|
565
|
+
`kitr jobs:list` reads the defaults when a job sets none (3 retries, a 60,000 ms timeout):
|
|
566
|
+
|
|
567
|
+
```sh
|
|
568
|
+
kitr jobs:list
|
|
569
|
+
# name retries timeout schedule
|
|
570
|
+
# generate_invoice 3 60000
|
|
571
|
+
# prune_sessions 3 60000 0 3 * * *
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
`kitr jobs:work` boots `server.app` from the entry's exported `server` (or `app`). `kitr doctor` flags a `config/jobs.ts` that lists no jobs.
|
|
575
|
+
|
|
576
|
+
`kitr make:job generate-invoice` writes `server/jobs/generate-invoice.ts`:
|
|
577
|
+
|
|
578
|
+
```ts
|
|
579
|
+
// server/jobs/generate-invoice.ts
|
|
580
|
+
import { defineJob } from '@kitr/jobs'
|
|
581
|
+
import { z } from 'zod'
|
|
582
|
+
|
|
583
|
+
export const generateInvoiceInput = z.object({
|
|
584
|
+
id: z.string(),
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
/** List it in config/jobs.ts, then dispatch with `useJobs(app).dispatch(generateInvoice, { id })`; it runs on the worker with retries. */
|
|
588
|
+
export const generateInvoice = defineJob('generate_invoice', {
|
|
589
|
+
schema: generateInvoiceInput,
|
|
590
|
+
retries: 3,
|
|
591
|
+
// schedule: { every: '1h' },
|
|
592
|
+
async run({ id }, ctx) {
|
|
593
|
+
console.info(`generate_invoice ${id} (attempt ${ctx.attempt})`)
|
|
594
|
+
},
|
|
595
|
+
})
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
The generator does not add the job to `config/jobs.ts`; do that yourself.
|
|
599
|
+
|
|
600
|
+
## Errors
|
|
601
|
+
|
|
602
|
+
| Error | When | What to do |
|
|
603
|
+
| --- | --- | --- |
|
|
604
|
+
| `KitrError` `VALIDATION` (422), message `[jobs] <name>: bad payload` | `dispatch()` or `run()` with a payload the schema rejects. `error.fieldErrors` lists the fields. | Fix the payload. In a controller it becomes a 422 response. |
|
|
605
|
+
| `[@kitr/jobs] Bad cron "<expr>" (5 fields)` | A cron expression without exactly five fields. | Use `minute hour day month weekday`. |
|
|
606
|
+
| `[@kitr/jobs] Bad cron "<expr>" (field "<field>")` | A field out of range, a name like `MON`, or a step of `0`. | See the table in [Run a job on a schedule](#run-a-job-on-a-schedule). |
|
|
607
|
+
| `[@kitr/jobs] Bad interval "<text>" (use 30s, 5m, 1h, 1d)` | `{ every }` without a number and a unit. | Write `5m`, not `5 minutes`. |
|
|
608
|
+
| `[jobs] <name> timed out after <ms>ms` | An attempt ran past `timeout`. Counts as a failure. | Raise `timeout`, or pass `signal` to the slow call. |
|
|
609
|
+
|
|
610
|
+
The worker never throws; it logs through the app's [logger](/packages/logger):
|
|
611
|
+
|
|
612
|
+
| Log line | Level | Meaning |
|
|
613
|
+
| --- | --- | --- |
|
|
614
|
+
| `[jobs] <name> failed (attempt <n>), retry in <ms>ms` | warn | An attempt failed and the job is queued again. |
|
|
615
|
+
| `[jobs] <name> failed after <n> attempts` | error | Retries used up; `onFailed` runs. |
|
|
616
|
+
| `[jobs] <name> dropped: bad payload` | error | The queued payload failed the schema; `onFailed` runs. |
|
|
617
|
+
| `[jobs] unknown job "<name>" dropped` | warn | No job with that name is listed in this process. |
|
|
618
|
+
| `[jobs] could not queue scheduled <name>` | error | A scheduled dispatch failed, usually a schema that rejects `undefined`. |
|
|
619
|
+
| `[jobs] worker poll failed` | error | The driver threw (Redis restarted) or a cron expression is bad. The worker keeps polling. |
|
|
620
|
+
|
|
621
|
+
## Testing
|
|
622
|
+
|
|
623
|
+
In a test the worker does not start. Dispatched jobs wait in the memory queue, where you can read them, and `jobs.run()` runs one inline:
|
|
624
|
+
|
|
625
|
+
```ts
|
|
626
|
+
// test/generate-invoice.test.ts
|
|
627
|
+
import { useJobs } from '@kitr/jobs'
|
|
628
|
+
import jobsProvider from '@kitr/jobs/provider'
|
|
629
|
+
import { createTestApp } from '@kitr/testing'
|
|
630
|
+
import { describe, expect, it } from 'vitest'
|
|
631
|
+
import { generateInvoice } from '#server/jobs/generate_invoice'
|
|
632
|
+
|
|
633
|
+
describe('generate_invoice', () => {
|
|
634
|
+
it('queues the job with its payload', async () => {
|
|
635
|
+
const app = await createTestApp({
|
|
636
|
+
providers: [jobsProvider],
|
|
637
|
+
config: { jobs: { jobs: [generateInvoice] } },
|
|
638
|
+
})
|
|
639
|
+
const jobs = useJobs(app)
|
|
640
|
+
|
|
641
|
+
const id = await jobs.dispatch(generateInvoice, { teamId: 'team_engines', month: '2026-10' })
|
|
642
|
+
|
|
643
|
+
expect(jobs.running).toBe(false)
|
|
644
|
+
expect(await jobs.driver.size()).toBe(1)
|
|
645
|
+
const [queued] = await jobs.driver.pull(Date.now(), 10)
|
|
646
|
+
expect(queued).toMatchObject({
|
|
647
|
+
id,
|
|
648
|
+
name: 'generate_invoice',
|
|
649
|
+
payload: { teamId: 'team_engines', month: '2026-10' },
|
|
650
|
+
attempt: 1,
|
|
651
|
+
})
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('builds the invoice number', async () => {
|
|
655
|
+
const app = await createTestApp({ providers: [jobsProvider] })
|
|
656
|
+
const result = await useJobs(app).run(generateInvoice, {
|
|
657
|
+
teamId: 'team_engines',
|
|
658
|
+
month: '2026-10',
|
|
659
|
+
})
|
|
660
|
+
expect(result).toEqual({ invoiceNumber: 'INV-2026-10-team_engines', attempt: 1 })
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
it('rejects a bad month before queueing', async () => {
|
|
664
|
+
const app = await createTestApp({ providers: [jobsProvider] })
|
|
665
|
+
const jobs = useJobs(app)
|
|
666
|
+
await expect(
|
|
667
|
+
jobs.dispatch(generateInvoice, { teamId: 'team_engines', month: 'October' }),
|
|
668
|
+
).rejects.toMatchObject({ code: 'VALIDATION', status: 422 })
|
|
669
|
+
expect(await jobs.driver.size()).toBe(0)
|
|
670
|
+
})
|
|
671
|
+
})
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
To check a delay, pull at a later time: `jobs.driver.pull(Date.now() + 10 * 60_000, 10)`.
|
|
675
|
+
|
|
676
|
+
> [!WARNING] run() waits the real backoff
|
|
677
|
+
> A failing job with the defaults takes 7 seconds to give up. Use `retries: 0` or a small `backoff` in tests:
|
|
678
|
+
|
|
679
|
+
```ts
|
|
680
|
+
// test/flaky-job.test.ts
|
|
681
|
+
import { createJobs, defineJob } from '@kitr/jobs'
|
|
682
|
+
import { createTestApp } from '@kitr/testing'
|
|
683
|
+
import { expect, it } from 'vitest'
|
|
684
|
+
|
|
685
|
+
it('retries until the third attempt succeeds', async () => {
|
|
686
|
+
const jobs = createJobs(await createTestApp())
|
|
687
|
+
const attempts: number[] = []
|
|
688
|
+
const flaky = defineJob('sync_orders_flaky', {
|
|
689
|
+
retries: 2,
|
|
690
|
+
backoff: 1,
|
|
691
|
+
run: (_payload, { attempt }) => {
|
|
692
|
+
attempts.push(attempt)
|
|
693
|
+
if (attempt < 3) throw new Error('payments API down')
|
|
694
|
+
return 'synced'
|
|
695
|
+
},
|
|
696
|
+
})
|
|
697
|
+
expect(await jobs.run(flaky, undefined)).toBe('synced')
|
|
698
|
+
expect(attempts).toEqual([1, 2, 3])
|
|
699
|
+
})
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
To test the worker loop, start it with a short poll, wait for the effect, then stop it:
|
|
703
|
+
|
|
704
|
+
```ts
|
|
705
|
+
// test/worker.test.ts
|
|
706
|
+
import { createJobs, defineJob } from '@kitr/jobs'
|
|
707
|
+
import { createTestApp } from '@kitr/testing'
|
|
708
|
+
import { expect, it, vi } from 'vitest'
|
|
709
|
+
|
|
710
|
+
it('runs dispatched jobs on the worker', async () => {
|
|
711
|
+
const done: string[] = []
|
|
712
|
+
const notify = defineJob<string>('notify_team', { run: (teamId) => void done.push(teamId) })
|
|
713
|
+
const jobs = createJobs(await createTestApp(), { jobs: [notify], pollInterval: 5 })
|
|
714
|
+
jobs.start()
|
|
715
|
+
await jobs.dispatch(notify, 'team_engines')
|
|
716
|
+
await vi.waitFor(() => expect(done).toEqual(['team_engines']))
|
|
717
|
+
await jobs.stop()
|
|
718
|
+
})
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
`createJobs(app, config)` builds the service without the provider, with the same options as `config/jobs.ts`.
|
|
722
|
+
|
|
723
|
+
## Extend
|
|
724
|
+
|
|
725
|
+
A driver is where queued jobs wait. The worker calls `pull(now, limit)` every `pollInterval` and `ack(job)` after each run. See [`JobsDriver`](/packages/jobs/api#jobsdriver) and [`QueuedJob`](/packages/jobs/api#queuedjob).
|
|
726
|
+
|
|
727
|
+
| Method | Must |
|
|
728
|
+
| --- | --- |
|
|
729
|
+
| `push(job)` | Store a `QueuedJob`. Called by `dispatch()` and for every retry. |
|
|
730
|
+
| `pull(now, limit)` | Return up to `limit` jobs whose `runAt` is at or before `now`, earliest first, and remove them so no other worker gets them. |
|
|
731
|
+
| `size()` | Count the jobs waiting, and the ones pulled but not yet acknowledged. |
|
|
732
|
+
| `clear()` | Remove every job. |
|
|
733
|
+
| `ack(job)` (optional) | Forget a finished job. With it, a job whose worker crashed runs again. |
|
|
734
|
+
| `claim(key, ttlMs)` (optional) | Take a lock for `ttlMs`; `true` for one caller only. Stops several workers queuing the same scheduled run. |
|
|
735
|
+
|
|
736
|
+
The smallest driver that shares a queue is one [Redis](https://redis.io/docs/latest/develop/data-types/sorted-sets/) sorted set scored by `runAt`. Each job is claimed with `ZREM`, so two workers never run the same one:
|
|
737
|
+
|
|
738
|
+
```ts
|
|
739
|
+
// server/jobs/sorted-set-driver.ts
|
|
740
|
+
import { Redis } from 'ioredis'
|
|
741
|
+
import type { JobsDriver, QueuedJob } from '@kitr/jobs'
|
|
742
|
+
|
|
743
|
+
export function createSortedSetDriver(url: string, key = 'acme:jobs'): JobsDriver {
|
|
744
|
+
const redis = new Redis(url)
|
|
745
|
+
return {
|
|
746
|
+
name: 'redis',
|
|
747
|
+
async push(job) {
|
|
748
|
+
await redis.zadd(key, job.runAt, JSON.stringify(job))
|
|
749
|
+
},
|
|
750
|
+
async pull(now, limit) {
|
|
751
|
+
const due = await redis.zrangebyscore(key, 0, now, 'LIMIT', 0, limit)
|
|
752
|
+
const claimed: QueuedJob[] = []
|
|
753
|
+
for (const entry of due) {
|
|
754
|
+
// Only the worker whose ZREM removes the entry runs it.
|
|
755
|
+
if ((await redis.zrem(key, entry)) === 1) claimed.push(JSON.parse(entry) as QueuedJob)
|
|
756
|
+
}
|
|
757
|
+
return claimed
|
|
758
|
+
},
|
|
759
|
+
async size() {
|
|
760
|
+
return redis.zcard(key)
|
|
761
|
+
},
|
|
762
|
+
async clear() {
|
|
763
|
+
await redis.del(key)
|
|
764
|
+
},
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
Register it in `config/jobs.ts`:
|
|
770
|
+
|
|
771
|
+
```ts
|
|
772
|
+
// config/jobs.ts
|
|
773
|
+
import { defineConfig } from '@kitr/jobs'
|
|
774
|
+
import { generateInvoice } from '#server/jobs/generate_invoice'
|
|
775
|
+
import { createSortedSetDriver } from '#server/jobs/sorted_set_driver'
|
|
776
|
+
|
|
777
|
+
export default defineConfig({
|
|
778
|
+
jobs: [generateInvoice],
|
|
779
|
+
driver: createSortedSetDriver(process.env.REDIS_URL ?? 'redis://localhost:6379'),
|
|
780
|
+
})
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
Keep payloads to plain JSON: a `Date` comes back as a string. Without `ack`, a job is lost if the process dies mid-run; without `claim`, each worker queues its own copy of a scheduled job.
|
|
784
|
+
|
|
785
|
+
## Next steps
|
|
786
|
+
|
|
787
|
+
- [Jobs API reference](/packages/jobs/api): every export with its signature.
|
|
788
|
+
- [Mail](/packages/mail): send email from a job.
|
|
789
|
+
- [Run on more than one instance](/guides/scale-out): the queue, cache and sockets on shared Redis.
|