tasklane 0.1.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 +734 -0
- package/dist/index.cjs +265 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +190 -0
- package/dist/index.d.ts +190 -0
- package/dist/index.js +255 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
package/README.md
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
# tasklane
|
|
2
|
+
|
|
3
|
+
Background jobs that feel like normal async functions, powered by [BullMQ](https://docs.bullmq.io/) and Redis.
|
|
4
|
+
|
|
5
|
+
No central job registry. No manual registration. You wrap a function with `job()`, and calling it enqueues it. That's it.
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { job } from "tasklane";
|
|
9
|
+
|
|
10
|
+
export const sendSms = job(async function sendSms(to: string, message: string) {
|
|
11
|
+
await smsProvider.send({ to, message });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Enqueues in the background — returns immediately
|
|
15
|
+
await sendSms("Mushud", "Your OTP is 1234");
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Table of Contents
|
|
21
|
+
|
|
22
|
+
- [Requirements](#requirements)
|
|
23
|
+
- [Installation](#installation)
|
|
24
|
+
- [Quick Start](#quick-start)
|
|
25
|
+
- [API Reference](#api-reference)
|
|
26
|
+
- [initJobs](#initjobs)
|
|
27
|
+
- [job](#job)
|
|
28
|
+
- [startWorker](#startworker)
|
|
29
|
+
- [stopWorker](#stopworker)
|
|
30
|
+
- [onJobFailed](#onjobfailed)
|
|
31
|
+
- [onJobCompleted](#onjobcompleted)
|
|
32
|
+
- [flow](#flow)
|
|
33
|
+
- [getFailed](#getfailed)
|
|
34
|
+
- [retryJob](#retryjob)
|
|
35
|
+
- [Dispatching Jobs](#dispatching-jobs)
|
|
36
|
+
- [Immediate](#immediate)
|
|
37
|
+
- [Delayed](#delayed)
|
|
38
|
+
- [One-time scheduled](#one-time-scheduled)
|
|
39
|
+
- [Recurring cron](#recurring-cron)
|
|
40
|
+
- [Run directly without queue](#run-directly-without-queue)
|
|
41
|
+
- [Backoff Strategies](#backoff-strategies)
|
|
42
|
+
- [Job Flows](#job-flows)
|
|
43
|
+
- [Failure Handling](#failure-handling)
|
|
44
|
+
- [Multiple Processes](#multiple-processes)
|
|
45
|
+
- [TypeScript](#typescript)
|
|
46
|
+
- [Contributing](#contributing)
|
|
47
|
+
- [License](#license)
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Requirements
|
|
52
|
+
|
|
53
|
+
- Node.js 18+
|
|
54
|
+
- Redis 6+
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install tasklane
|
|
62
|
+
# or
|
|
63
|
+
pnpm add tasklane
|
|
64
|
+
# or
|
|
65
|
+
yarn add tasklane
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
BullMQ (and its Redis client `ioredis`) are included as dependencies — nothing extra to install. You just need a running Redis server.
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
initJobs({ redis: "redis://127.0.0.1:6379" }); // local, no auth
|
|
72
|
+
initJobs({ redis: "redis://:password@host:6379" }); // with password
|
|
73
|
+
initJobs({ redis: "redis://host:6379/2" }); // database 2
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Quick Start
|
|
79
|
+
|
|
80
|
+
**1. Define a job anywhere**
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// jobs/sms.ts
|
|
84
|
+
import { job } from "tasklane";
|
|
85
|
+
|
|
86
|
+
export const sendSms = job(async function sendSms(to: string, message: string) {
|
|
87
|
+
await smsProvider.send({ to, message });
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**2. Use it anywhere in your app**
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// routes/booking.ts
|
|
95
|
+
import { sendSms } from "../jobs/sms";
|
|
96
|
+
|
|
97
|
+
router.post("/book", async (req, res) => {
|
|
98
|
+
await createBooking(req.body);
|
|
99
|
+
await sendSms(req.body.phone, "Booking confirmed!");
|
|
100
|
+
res.json({ ok: true });
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Importing `sendSms` is enough — the handler self-registers the moment the module loads. No separate registration step.
|
|
105
|
+
|
|
106
|
+
**3. Initialize and start the worker once at app startup**
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// app.ts
|
|
110
|
+
import { initJobs, startWorker, onJobFailed } from "tasklane";
|
|
111
|
+
import "./routes"; // your routes import job functions, which registers their handlers
|
|
112
|
+
|
|
113
|
+
initJobs({ redis: process.env.REDIS_URL! });
|
|
114
|
+
await startWorker();
|
|
115
|
+
|
|
116
|
+
onJobFailed((event) => {
|
|
117
|
+
console.error(`Job "${event.name}" failed:`, event.error.message);
|
|
118
|
+
if (event.isFinalFailure) {
|
|
119
|
+
console.error("All retries exhausted.");
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The only rule: call `startWorker()` after your app's modules have been imported — so all handlers are registered before the worker starts picking up jobs.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## API Reference
|
|
129
|
+
|
|
130
|
+
### `initJobs`
|
|
131
|
+
|
|
132
|
+
Initializes the jobs runtime. Call this **once** at application startup before dispatching or starting any workers.
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
initJobs(config: JobsConfig): void
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
initJobs({
|
|
140
|
+
redis: "redis://127.0.0.1:6379", // required
|
|
141
|
+
queue: "default", // optional, default: "default"
|
|
142
|
+
attempts: 3, // optional, default: 3
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
| Option | Type | Default | Description |
|
|
147
|
+
|---|---|---|---|
|
|
148
|
+
| `redis` | `string` | — | Redis connection URL |
|
|
149
|
+
| `queue` | `string` | `"default"` | Queue name for all jobs |
|
|
150
|
+
| `attempts` | `number` | `3` | Default retry attempts for all jobs |
|
|
151
|
+
| `backoff` | `BackoffStrategy` | `{ type: "exponential", delay: 1000 }` | Default backoff strategy for all jobs |
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
### `job`
|
|
156
|
+
|
|
157
|
+
Wraps an async function as a background job. The returned function has the same call signature as the original, but calling it enqueues the job instead of running it inline.
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
job(fn: AsyncFunction, opts?: JobFnOptions): JobFn
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
export const sendSms = job(async function sendSms(to: string, message: string) {
|
|
165
|
+
await smsProvider.send({ to, message });
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The function **must be named** — arrow functions are not allowed because the function name is used as the job identifier.
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
// ✅ correct
|
|
173
|
+
const sendSms = job(async function sendSms(to: string) { ... });
|
|
174
|
+
|
|
175
|
+
// ❌ throws — no name to use as job id
|
|
176
|
+
const sendSms = job(async (to: string) => { ... });
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Per-job options:**
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
export const sendSms = job(
|
|
183
|
+
async function sendSms(to: string, message: string) { ... },
|
|
184
|
+
{
|
|
185
|
+
attempts: 5,
|
|
186
|
+
backoff: { type: "fixed", delay: 2000 },
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
| Option | Type | Default | Description |
|
|
192
|
+
|---|---|---|---|
|
|
193
|
+
| `attempts` | `number` | global `attempts` | Retry attempts for this job |
|
|
194
|
+
| `backoff` | `BackoffStrategy` | global `backoff` | Backoff strategy for this job (overrides global) |
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
### `startWorker`
|
|
199
|
+
|
|
200
|
+
Starts consuming jobs from the queue. Call after `initJobs()` and after importing all job definition files.
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
await startWorker(): Promise<void>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Retry backoff is configured via `initJobs()` or per-job options. See [Backoff Strategies](#backoff-strategies).
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
### `stopWorker`
|
|
211
|
+
|
|
212
|
+
Gracefully closes the worker and all Redis connections.
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
await stopWorker(): Promise<void>
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
### `onJobFailed`
|
|
221
|
+
|
|
222
|
+
Registers a listener that fires on every failed job attempt. Returns an unsubscribe function.
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
onJobFailed(listener: (event: FailedEvent) => void): () => void
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
const unsub = onJobFailed((event) => {
|
|
230
|
+
console.error(event.name, event.error.message);
|
|
231
|
+
|
|
232
|
+
if (event.isFinalFailure) {
|
|
233
|
+
// All retries exhausted — alert, log to DB, etc.
|
|
234
|
+
alerting.send(`Job ${event.name} permanently failed`);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Stop listening later
|
|
239
|
+
unsub();
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**`FailedEvent` shape:**
|
|
243
|
+
|
|
244
|
+
| Field | Type | Description |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| `jobId` | `string` | BullMQ job ID |
|
|
247
|
+
| `name` | `string` | Job function name |
|
|
248
|
+
| `args` | `unknown[]` | Arguments the job was called with |
|
|
249
|
+
| `error` | `Error` | The error that was thrown |
|
|
250
|
+
| `attemptsMade` | `number` | How many attempts have been made |
|
|
251
|
+
| `isFinalFailure` | `boolean` | `true` when all retries are exhausted |
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
### `onJobCompleted`
|
|
256
|
+
|
|
257
|
+
Registers a listener that fires when a job completes successfully. Returns an unsubscribe function.
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
onJobCompleted(listener: (event: CompletedEvent) => void): () => void
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
const unsub = onJobCompleted((event) => {
|
|
265
|
+
console.log(`Job "${event.name}" completed`, event.result);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Stop listening later
|
|
269
|
+
unsub();
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**`CompletedEvent` shape:**
|
|
273
|
+
|
|
274
|
+
| Field | Type | Description |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
| `jobId` | `string` | BullMQ job ID |
|
|
277
|
+
| `name` | `string` | Job function name |
|
|
278
|
+
| `args` | `unknown[]` | Arguments the job was called with |
|
|
279
|
+
| `result` | `unknown` | Return value of the handler |
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
### `flow`
|
|
284
|
+
|
|
285
|
+
Dispatches a job flow — a parent job that runs only after all its children complete. Supports unlimited nesting depth.
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
flow(node: FlowNode): Promise<void>
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
import { flow } from "tasklane";
|
|
293
|
+
|
|
294
|
+
await flow({
|
|
295
|
+
job: processOrder,
|
|
296
|
+
args: ["ord_123"],
|
|
297
|
+
children: [
|
|
298
|
+
{ job: chargePayment, args: ["ord_123"] },
|
|
299
|
+
{ job: reserveInventory, args: ["ord_123"] },
|
|
300
|
+
],
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Children run in parallel. The parent runs only after every child (and their children) completes. If any child fails permanently, the parent is not executed.
|
|
305
|
+
|
|
306
|
+
See [Job Flows](#job-flows) for detailed examples.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
### `getFailed`
|
|
311
|
+
|
|
312
|
+
Returns a list of permanently failed jobs (all retries exhausted). Useful for building an admin dashboard or alerting pipeline.
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
getFailed(start?: number, limit?: number): Promise<FailedJob[]>
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
import { getFailed } from "tasklane";
|
|
320
|
+
|
|
321
|
+
const jobs = await getFailed(); // first 50
|
|
322
|
+
const next = await getFailed(50, 50); // next page
|
|
323
|
+
|
|
324
|
+
for (const job of jobs) {
|
|
325
|
+
console.log(job.jobId, job.name, job.failedReason, job.attemptsMade);
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**`FailedJob` shape:**
|
|
330
|
+
|
|
331
|
+
| Field | Type | Description |
|
|
332
|
+
|---|---|---|
|
|
333
|
+
| `jobId` | `string` | Pass this to `retryJob()` to re-queue |
|
|
334
|
+
| `name` | `string` | Job function name |
|
|
335
|
+
| `args` | `unknown[]` | Arguments the job was originally called with |
|
|
336
|
+
| `failedReason` | `string` | Last error message |
|
|
337
|
+
| `attemptsMade` | `number` | Total attempts made before giving up |
|
|
338
|
+
| `timestamp` | `number` | Unix ms when the job was first created |
|
|
339
|
+
| `finishedOn` | `number \| undefined` | Unix ms when the job finally failed |
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
### `retryJob`
|
|
344
|
+
|
|
345
|
+
Re-queues a permanently failed job by its ID. The job is picked up by a worker and retried from scratch — attempt count resets to zero.
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
retryJob(jobId: string): Promise<void>
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
import { getFailed, retryJob } from "tasklane";
|
|
353
|
+
|
|
354
|
+
// Retry all permanently failed jobs
|
|
355
|
+
const failed = await getFailed();
|
|
356
|
+
await Promise.all(failed.map((job) => retryJob(job.jobId)));
|
|
357
|
+
|
|
358
|
+
// Or retry a single job by known ID
|
|
359
|
+
await retryJob("job_12345");
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Dispatching Jobs
|
|
365
|
+
|
|
366
|
+
Every job created with `job()` supports five dispatch modes.
|
|
367
|
+
|
|
368
|
+
### Immediate
|
|
369
|
+
|
|
370
|
+
Enqueues the job to run as soon as a worker is available.
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
await sendSms("Mushud", "Your OTP is 1234");
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Delayed
|
|
377
|
+
|
|
378
|
+
Enqueues the job to run after a delay in milliseconds.
|
|
379
|
+
|
|
380
|
+
```typescript
|
|
381
|
+
// Run after 1 hour
|
|
382
|
+
await sendSms.delay(60 * 60 * 1000)("Mushud", "Just checking in");
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### One-time scheduled
|
|
386
|
+
|
|
387
|
+
Enqueues the job to run once at a specific `Date`.
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
await sendSms.at(new Date("2026-04-13T09:00:00Z"))("Mushud", "Good morning");
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Throws if the date is in the past.
|
|
394
|
+
|
|
395
|
+
### Recurring cron
|
|
396
|
+
|
|
397
|
+
Registers a repeating schedule using a cron expression. Uses BullMQ's job scheduler internally — safe to call on every deploy since it is an idempotent upsert.
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
// Every day at 9:00 AM
|
|
401
|
+
await sendSms.cron("0 9 * * *")("Mushud", "Daily reminder");
|
|
402
|
+
|
|
403
|
+
// Every Monday at 8:00 AM
|
|
404
|
+
await sendSms.cron("0 8 * * 1")("Mushud", "Weekly report");
|
|
405
|
+
|
|
406
|
+
// Every hour
|
|
407
|
+
await sendSms.cron("0 * * * *")("Mushud", "Hourly ping");
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**Cron expression format:**
|
|
411
|
+
|
|
412
|
+
```
|
|
413
|
+
┌─ minute (0-59)
|
|
414
|
+
│ ┌─ hour (0-23)
|
|
415
|
+
│ │ ┌─ day of month (1-31)
|
|
416
|
+
│ │ │ ┌─ month (1-12)
|
|
417
|
+
│ │ │ │ ┌─ day of week (0-6, Sunday = 0)
|
|
418
|
+
│ │ │ │ │
|
|
419
|
+
* * * * *
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Run directly without queue
|
|
423
|
+
|
|
424
|
+
Calls the original handler function immediately, bypassing Redis and the queue entirely. Returns the handler's actual return value.
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
// No Redis needed — runs inline like a normal async function
|
|
428
|
+
await sendSms.run("Mushud", "Hello");
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Useful for:
|
|
432
|
+
|
|
433
|
+
- Testing handlers without a Redis connection
|
|
434
|
+
- Running a job inline when you need the result right away
|
|
435
|
+
- CLI scripts and one-off tasks
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
## Backoff Strategies
|
|
440
|
+
|
|
441
|
+
Backoff controls how long BullMQ waits before retrying a failed job. You can configure it globally, per job definition, or leave it at the default.
|
|
442
|
+
|
|
443
|
+
**Default:** exponential backoff starting at 1 second.
|
|
444
|
+
|
|
445
|
+
### Exponential backoff
|
|
446
|
+
|
|
447
|
+
Doubles the wait on each retry: 1s → 2s → 4s → 8s → ...
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
initJobs({
|
|
451
|
+
redis: process.env.REDIS_URL!,
|
|
452
|
+
backoff: { type: "exponential", delay: 1000 },
|
|
453
|
+
});
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### Fixed backoff
|
|
457
|
+
|
|
458
|
+
Waits the same amount of time between every retry.
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
initJobs({
|
|
462
|
+
redis: process.env.REDIS_URL!,
|
|
463
|
+
backoff: { type: "fixed", delay: 5000 }, // always 5s
|
|
464
|
+
});
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### Per-job backoff
|
|
468
|
+
|
|
469
|
+
Override the global default for a specific job. Per-job settings always win over the global setting.
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
export const chargePayment = job(
|
|
473
|
+
async function chargePayment(orderId: string) { ... },
|
|
474
|
+
{ backoff: { type: "fixed", delay: 2000 }, attempts: 5 }
|
|
475
|
+
);
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
**`BackoffStrategy` shape:**
|
|
479
|
+
|
|
480
|
+
| Field | Type | Description |
|
|
481
|
+
|---|---|---|
|
|
482
|
+
| `type` | `"exponential" \| "fixed"` | Retry timing pattern |
|
|
483
|
+
| `delay` | `number` | Base delay in milliseconds |
|
|
484
|
+
|
|
485
|
+
**Priority order:** per-job `backoff` → global `initJobs` `backoff` → default `{ type: "exponential", delay: 1000 }`
|
|
486
|
+
|
|
487
|
+
---
|
|
488
|
+
|
|
489
|
+
## Job Flows
|
|
490
|
+
|
|
491
|
+
A flow is a group of jobs with explicit dependencies. Children always run before their parent, so you can model multi-step pipelines without polling or callbacks.
|
|
492
|
+
|
|
493
|
+
```typescript
|
|
494
|
+
import { flow } from "tasklane";
|
|
495
|
+
|
|
496
|
+
await flow({
|
|
497
|
+
job: processOrder,
|
|
498
|
+
args: ["ord_123"],
|
|
499
|
+
children: [
|
|
500
|
+
{ job: chargePayment, args: ["ord_123"] },
|
|
501
|
+
{ job: reserveInventory, args: ["ord_123"] },
|
|
502
|
+
],
|
|
503
|
+
});
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
`chargePayment` and `reserveInventory` run in parallel. `processOrder` runs only after both complete.
|
|
507
|
+
|
|
508
|
+
### Nested flows
|
|
509
|
+
|
|
510
|
+
Children can have their own children, to any depth:
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
await flow({
|
|
514
|
+
job: processOrder,
|
|
515
|
+
args: ["ord_123"],
|
|
516
|
+
children: [
|
|
517
|
+
{ job: chargePayment, args: ["ord_123"] },
|
|
518
|
+
{
|
|
519
|
+
job: prepareShipment,
|
|
520
|
+
args: ["ord_123"],
|
|
521
|
+
children: [
|
|
522
|
+
{ job: validateAddress, args: ["ord_123"] },
|
|
523
|
+
{ job: pickInventory, args: ["ord_123"] },
|
|
524
|
+
],
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
Execution order: `validateAddress` + `pickInventory` → `prepareShipment` + `chargePayment` → `processOrder`.
|
|
531
|
+
|
|
532
|
+
### Flow options
|
|
533
|
+
|
|
534
|
+
Each node in the flow accepts `attempts` and `backoff` overrides:
|
|
535
|
+
|
|
536
|
+
```typescript
|
|
537
|
+
await flow({
|
|
538
|
+
job: processOrder,
|
|
539
|
+
args: ["ord_123"],
|
|
540
|
+
children: [
|
|
541
|
+
{
|
|
542
|
+
job: chargePayment,
|
|
543
|
+
args: ["ord_123"],
|
|
544
|
+
attempts: 5,
|
|
545
|
+
backoff: { type: "fixed", delay: 3000 },
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
});
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
### Failure behaviour
|
|
552
|
+
|
|
553
|
+
If any child fails permanently (all retries exhausted), the parent job is never executed. The parent stays in a waiting state in Redis and can be inspected or cleaned up via the BullMQ dashboard.
|
|
554
|
+
|
|
555
|
+
### Completed events
|
|
556
|
+
|
|
557
|
+
`onJobCompleted` fires for every job in the flow — children and parent alike.
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
onJobCompleted((event) => {
|
|
561
|
+
console.log(`${event.name} done`);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
await flow({
|
|
565
|
+
job: processOrder,
|
|
566
|
+
args: ["ord_123"],
|
|
567
|
+
children: [{ job: chargePayment, args: ["ord_123"] }],
|
|
568
|
+
});
|
|
569
|
+
// fires: "chargePayment done", then "processOrder done"
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
## Failure Handling
|
|
575
|
+
|
|
576
|
+
### How retries work
|
|
577
|
+
|
|
578
|
+
`onJobFailed` fires on **every failed attempt** — not just the final one. Use `isFinalFailure` to tell them apart.
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
onJobFailed((event) => {
|
|
582
|
+
if (!event.isFinalFailure) {
|
|
583
|
+
// This attempt failed but BullMQ will retry automatically
|
|
584
|
+
console.warn(`Job "${event.name}" failed (attempt ${event.attemptsMade}), retrying...`);
|
|
585
|
+
} else {
|
|
586
|
+
// All retries exhausted — job is now permanently failed
|
|
587
|
+
console.error(`Job "${event.name}" permanently failed:`, event.error.message);
|
|
588
|
+
// Alert, write to DB, notify a human, etc.
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Jobs automatically retry with exponential backoff when they throw. Default is 3 attempts. Configure attempts and backoff per job or globally — see [Backoff Strategies](#backoff-strategies).
|
|
594
|
+
|
|
595
|
+
Once `isFinalFailure` is `true`, the job sits in the failed set and will not run again unless you manually retry it via [`retryJob`](#retryjob).
|
|
596
|
+
|
|
597
|
+
**Common pattern — retry on deploy:**
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
// scripts/retry-failed.ts
|
|
601
|
+
import { initJobs, getFailed, retryJob } from "tasklane";
|
|
602
|
+
|
|
603
|
+
initJobs({ redis: process.env.REDIS_URL! });
|
|
604
|
+
|
|
605
|
+
const failed = await getFailed();
|
|
606
|
+
console.log(`Retrying ${failed.length} failed jobs...`);
|
|
607
|
+
await Promise.all(failed.map((j) => retryJob(j.jobId)));
|
|
608
|
+
console.log("Done.");
|
|
609
|
+
process.exit(0);
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
---
|
|
613
|
+
|
|
614
|
+
## Multiple Processes
|
|
615
|
+
|
|
616
|
+
Running multiple processes (PM2, cluster, Docker replicas) works out of the box. Each process has its own in-memory state so a few rules apply.
|
|
617
|
+
|
|
618
|
+
**Every process must:**
|
|
619
|
+
|
|
620
|
+
1. Call `initJobs()` independently
|
|
621
|
+
2. Have all job modules in its import graph before calling `startWorker()`
|
|
622
|
+
3. Call `startWorker()` if it should process jobs
|
|
623
|
+
|
|
624
|
+
In most apps this is automatic — your worker entry point imports your routes or services, which import job functions, which register their handlers:
|
|
625
|
+
|
|
626
|
+
```typescript
|
|
627
|
+
// worker.ts
|
|
628
|
+
import { initJobs, startWorker } from "tasklane";
|
|
629
|
+
import "./routes"; // transitively imports all job definitions
|
|
630
|
+
|
|
631
|
+
initJobs({ redis: process.env.REDIS_URL! });
|
|
632
|
+
await startWorker();
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
If you run a dedicated worker process that doesn't import routes, explicitly import the job files that process handles:
|
|
636
|
+
|
|
637
|
+
```typescript
|
|
638
|
+
// worker.ts — dedicated worker without routes
|
|
639
|
+
import { initJobs, startWorker } from "tasklane";
|
|
640
|
+
import "./jobs/sms";
|
|
641
|
+
import "./jobs/email";
|
|
642
|
+
|
|
643
|
+
initJobs({ redis: process.env.REDIS_URL! });
|
|
644
|
+
await startWorker();
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
**Cron schedules** — call `.cron()` from only one process per deploy. Since `upsertJobScheduler` is idempotent (it upserts by a stable key in Redis), calling it from multiple processes is safe but unnecessary. The cleanest approach:
|
|
648
|
+
|
|
649
|
+
```typescript
|
|
650
|
+
// PM2 / cluster: only the primary process sets up cron schedules
|
|
651
|
+
if (process.env.pm_id === "0") {
|
|
652
|
+
await sendSms.cron("0 9 * * *")("Mushud", "Daily reminder");
|
|
653
|
+
await generateReport.cron("0 0 * * *")();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
await startWorker(); // all processes run this
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
**With 4 worker processes**, each job is still executed exactly once — BullMQ uses atomic Redis operations to ensure no duplicate processing.
|
|
660
|
+
|
|
661
|
+
---
|
|
662
|
+
|
|
663
|
+
## TypeScript
|
|
664
|
+
|
|
665
|
+
`job()` is fully generic. The return type and parameter types of the original function flow through automatically.
|
|
666
|
+
|
|
667
|
+
```typescript
|
|
668
|
+
export const sendSms = job(async function sendSms(to: string, message: string) {
|
|
669
|
+
return { sent: true };
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// ✅ TypeScript knows these are (string, string)
|
|
673
|
+
await sendSms("Mushud", "Hello");
|
|
674
|
+
|
|
675
|
+
// ✅ .run() preserves the return type
|
|
676
|
+
const result = await sendSms.run("Mushud", "Hello");
|
|
677
|
+
// result: { sent: boolean }
|
|
678
|
+
|
|
679
|
+
// ❌ TypeScript error — wrong argument types
|
|
680
|
+
await sendSms(123, "Hello");
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
**Available types:**
|
|
684
|
+
|
|
685
|
+
```typescript
|
|
686
|
+
import type {
|
|
687
|
+
JobsConfig,
|
|
688
|
+
JobFnOptions,
|
|
689
|
+
JobFn,
|
|
690
|
+
BackoffStrategy,
|
|
691
|
+
FlowNode,
|
|
692
|
+
FailedEvent,
|
|
693
|
+
CompletedEvent,
|
|
694
|
+
FailedJob,
|
|
695
|
+
} from "tasklane";
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
700
|
+
## Contributing
|
|
701
|
+
|
|
702
|
+
Contributions are welcome. Please open an issue before submitting a pull request for large changes.
|
|
703
|
+
|
|
704
|
+
**Setup:**
|
|
705
|
+
|
|
706
|
+
```bash
|
|
707
|
+
git clone https://github.com/mushud/tasklane
|
|
708
|
+
cd tasklane
|
|
709
|
+
pnpm install
|
|
710
|
+
pnpm build
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
**Project structure:**
|
|
714
|
+
|
|
715
|
+
```
|
|
716
|
+
src/
|
|
717
|
+
index.ts — public API exports
|
|
718
|
+
job.ts — job() factory
|
|
719
|
+
registry.ts — global singleton (BullMQ Queue + Worker + handler map)
|
|
720
|
+
types.ts — TypeScript interfaces
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
**Before submitting a PR:**
|
|
724
|
+
|
|
725
|
+
```bash
|
|
726
|
+
pnpm typecheck
|
|
727
|
+
pnpm build
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
---
|
|
731
|
+
|
|
732
|
+
## License
|
|
733
|
+
|
|
734
|
+
MIT — see [LICENSE](LICENSE).
|