mysql-boss 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 +688 -0
- package/dist/cjs/archive.d.ts +8 -0
- package/dist/cjs/archive.d.ts.map +1 -0
- package/dist/cjs/archive.js +82 -0
- package/dist/cjs/archive.js.map +1 -0
- package/dist/cjs/boss.d.ts +45 -0
- package/dist/cjs/boss.d.ts.map +1 -0
- package/dist/cjs/boss.js +205 -0
- package/dist/cjs/boss.js.map +1 -0
- package/dist/cjs/claim.d.ts +5 -0
- package/dist/cjs/claim.d.ts.map +1 -0
- package/dist/cjs/claim.js +47 -0
- package/dist/cjs/claim.js.map +1 -0
- package/dist/cjs/connection.d.ts +4 -0
- package/dist/cjs/connection.d.ts.map +1 -0
- package/dist/cjs/connection.js +26 -0
- package/dist/cjs/connection.js.map +1 -0
- package/dist/cjs/cron/next.d.ts +3 -0
- package/dist/cjs/cron/next.d.ts.map +1 -0
- package/dist/cjs/cron/next.js +179 -0
- package/dist/cjs/cron/next.js.map +1 -0
- package/dist/cjs/cron/parse.d.ts +11 -0
- package/dist/cjs/cron/parse.d.ts.map +1 -0
- package/dist/cjs/cron/parse.js +99 -0
- package/dist/cjs/cron/parse.js.map +1 -0
- package/dist/cjs/dlq.d.ts +11 -0
- package/dist/cjs/dlq.d.ts.map +1 -0
- package/dist/cjs/dlq.js +92 -0
- package/dist/cjs/dlq.js.map +1 -0
- package/dist/cjs/errors.d.ts +10 -0
- package/dist/cjs/errors.d.ts.map +1 -0
- package/dist/cjs/errors.js +25 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/fail.d.ts +8 -0
- package/dist/cjs/fail.d.ts.map +1 -0
- package/dist/cjs/fail.js +19 -0
- package/dist/cjs/fail.js.map +1 -0
- package/dist/cjs/heartbeat.d.ts +4 -0
- package/dist/cjs/heartbeat.d.ts.map +1 -0
- package/dist/cjs/heartbeat.js +18 -0
- package/dist/cjs/heartbeat.js.map +1 -0
- package/dist/cjs/index.d.ts +70 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +10 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/migrate.d.ts +4 -0
- package/dist/cjs/migrate.d.ts.map +1 -0
- package/dist/cjs/migrate.js +14 -0
- package/dist/cjs/migrate.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/retry-util.d.ts +2 -0
- package/dist/cjs/retry-util.d.ts.map +1 -0
- package/dist/cjs/retry-util.js +31 -0
- package/dist/cjs/retry-util.js.map +1 -0
- package/dist/cjs/sql.d.ts +82 -0
- package/dist/cjs/sql.d.ts.map +1 -0
- package/dist/cjs/sql.js +396 -0
- package/dist/cjs/sql.js.map +1 -0
- package/dist/cjs/sweep.d.ts +4 -0
- package/dist/cjs/sweep.d.ts.map +1 -0
- package/dist/cjs/sweep.js +46 -0
- package/dist/cjs/sweep.js.map +1 -0
- package/dist/cjs/tick.d.ts +11 -0
- package/dist/cjs/tick.d.ts.map +1 -0
- package/dist/cjs/tick.js +82 -0
- package/dist/cjs/tick.js.map +1 -0
- package/dist/cjs/util.d.ts +2 -0
- package/dist/cjs/util.d.ts.map +1 -0
- package/dist/cjs/util.js +7 -0
- package/dist/cjs/util.js.map +1 -0
- package/dist/cjs/worker.d.ts +42 -0
- package/dist/cjs/worker.d.ts.map +1 -0
- package/dist/cjs/worker.js +232 -0
- package/dist/cjs/worker.js.map +1 -0
- package/dist/esm/archive.d.ts +8 -0
- package/dist/esm/archive.d.ts.map +1 -0
- package/dist/esm/archive.js +76 -0
- package/dist/esm/archive.js.map +1 -0
- package/dist/esm/boss.d.ts +45 -0
- package/dist/esm/boss.d.ts.map +1 -0
- package/dist/esm/boss.js +201 -0
- package/dist/esm/boss.js.map +1 -0
- package/dist/esm/claim.d.ts +5 -0
- package/dist/esm/claim.d.ts.map +1 -0
- package/dist/esm/claim.js +44 -0
- package/dist/esm/claim.js.map +1 -0
- package/dist/esm/connection.d.ts +4 -0
- package/dist/esm/connection.d.ts.map +1 -0
- package/dist/esm/connection.js +22 -0
- package/dist/esm/connection.js.map +1 -0
- package/dist/esm/cron/next.d.ts +3 -0
- package/dist/esm/cron/next.d.ts.map +1 -0
- package/dist/esm/cron/next.js +176 -0
- package/dist/esm/cron/next.js.map +1 -0
- package/dist/esm/cron/parse.d.ts +11 -0
- package/dist/esm/cron/parse.d.ts.map +1 -0
- package/dist/esm/cron/parse.js +96 -0
- package/dist/esm/cron/parse.js.map +1 -0
- package/dist/esm/dlq.d.ts +11 -0
- package/dist/esm/dlq.d.ts.map +1 -0
- package/dist/esm/dlq.js +87 -0
- package/dist/esm/dlq.js.map +1 -0
- package/dist/esm/errors.d.ts +10 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +19 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/fail.d.ts +8 -0
- package/dist/esm/fail.d.ts.map +1 -0
- package/dist/esm/fail.js +16 -0
- package/dist/esm/fail.js.map +1 -0
- package/dist/esm/heartbeat.d.ts +4 -0
- package/dist/esm/heartbeat.d.ts.map +1 -0
- package/dist/esm/heartbeat.js +15 -0
- package/dist/esm/heartbeat.js.map +1 -0
- package/dist/esm/index.d.ts +70 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/migrate.d.ts +4 -0
- package/dist/esm/migrate.d.ts.map +1 -0
- package/dist/esm/migrate.js +11 -0
- package/dist/esm/migrate.js.map +1 -0
- package/dist/esm/retry-util.d.ts +2 -0
- package/dist/esm/retry-util.d.ts.map +1 -0
- package/dist/esm/retry-util.js +28 -0
- package/dist/esm/retry-util.js.map +1 -0
- package/dist/esm/sql.d.ts +82 -0
- package/dist/esm/sql.d.ts.map +1 -0
- package/dist/esm/sql.js +392 -0
- package/dist/esm/sql.js.map +1 -0
- package/dist/esm/sweep.d.ts +4 -0
- package/dist/esm/sweep.d.ts.map +1 -0
- package/dist/esm/sweep.js +43 -0
- package/dist/esm/sweep.js.map +1 -0
- package/dist/esm/tick.d.ts +11 -0
- package/dist/esm/tick.d.ts.map +1 -0
- package/dist/esm/tick.js +77 -0
- package/dist/esm/tick.js.map +1 -0
- package/dist/esm/util.d.ts +2 -0
- package/dist/esm/util.d.ts.map +1 -0
- package/dist/esm/util.js +4 -0
- package/dist/esm/util.js.map +1 -0
- package/dist/esm/worker.d.ts +42 -0
- package/dist/esm/worker.d.ts.map +1 -0
- package/dist/esm/worker.js +228 -0
- package/dist/esm/worker.js.map +1 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Edafe Emunotor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
# mysql-boss
|
|
2
|
+
|
|
3
|
+
[](https://github.com/V-84/mysql-boss/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/V-84/mysql-boss)
|
|
5
|
+
|
|
6
|
+
A [pg-boss](https://github.com/timgit/pg-boss)-inspired job queue built for **MySQL 8.0.1+**. Zero runtime dependencies beyond [`mysql2`](https://github.com/sidorares/node-mysql2).
|
|
7
|
+
|
|
8
|
+
Correctness first, throughput second. Uses `FOR UPDATE SKIP LOCKED` for non-blocking, contention-safe job claims with lease-based crash recovery.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **At-least-once delivery** with lease-based crash recovery
|
|
13
|
+
- **Exactly-one-holder guarantee** — only one worker holds a job at any time
|
|
14
|
+
- **Retries** with fixed or exponential backoff
|
|
15
|
+
- **Dead-letter queue** for permanently failed jobs, with replay
|
|
16
|
+
- **Cron scheduling** — 5-field Vixie cron with timezone support (DST-aware)
|
|
17
|
+
- **Priority queues** — higher-priority jobs dequeue first
|
|
18
|
+
- **Archive** — completed jobs move to a separate table for audit/history
|
|
19
|
+
- **Singleton jobs** — deduplicate by key within a queue
|
|
20
|
+
- **Graceful shutdown** with configurable drain timeout
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- **Node.js** >= 20
|
|
25
|
+
- **MySQL** >= 8.0.1 (uses `FOR UPDATE SKIP LOCKED`, descending indexes, `DEFAULT (expression)`)
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install mysql-boss mysql2
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`mysql2` is a peer dependency — you provide the connection pool.
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import mysql from "mysql2/promise";
|
|
39
|
+
import { MysqlBoss } from "mysql-boss";
|
|
40
|
+
|
|
41
|
+
// 1. Create a mysql2 pool (you own it — mysql-boss never closes it)
|
|
42
|
+
const pool = mysql.createPool({
|
|
43
|
+
host: "localhost",
|
|
44
|
+
user: "root",
|
|
45
|
+
password: "secret",
|
|
46
|
+
database: "myapp",
|
|
47
|
+
waitForConnections: true,
|
|
48
|
+
connectionLimit: 10,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// 2. Create the boss
|
|
52
|
+
const boss = new MysqlBoss({ pool });
|
|
53
|
+
|
|
54
|
+
// 3. Run migrations (idempotent — safe to call on every startup)
|
|
55
|
+
await boss.migrate();
|
|
56
|
+
|
|
57
|
+
// 4. Register a worker
|
|
58
|
+
boss.work("send-email", async (job, { signal }) => {
|
|
59
|
+
const { to, subject, body } = job.payload;
|
|
60
|
+
await sendEmail(to, subject, body);
|
|
61
|
+
// Returning without throwing = success → job archived
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// 5. Enqueue a job
|
|
65
|
+
await boss.enqueue("send-email", {
|
|
66
|
+
to: "user@example.com",
|
|
67
|
+
subject: "Welcome!",
|
|
68
|
+
body: "Thanks for signing up.",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// 6. Shut down gracefully on SIGTERM
|
|
72
|
+
process.on("SIGTERM", async () => {
|
|
73
|
+
await boss.stop();
|
|
74
|
+
await pool.end();
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### JavaScript (CommonJS)
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
const mysql = require("mysql2/promise");
|
|
82
|
+
const { MysqlBoss } = require("mysql-boss");
|
|
83
|
+
|
|
84
|
+
const pool = mysql.createPool({ /* ... */ });
|
|
85
|
+
const boss = new MysqlBoss({ pool });
|
|
86
|
+
|
|
87
|
+
await boss.migrate();
|
|
88
|
+
|
|
89
|
+
boss.work("process-order", async (job) => {
|
|
90
|
+
console.log("Processing order:", job.payload.orderId);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await boss.enqueue("process-order", { orderId: "abc-123" });
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## API reference
|
|
99
|
+
|
|
100
|
+
### `new MysqlBoss(options)`
|
|
101
|
+
|
|
102
|
+
Creates a new instance. Validates configuration synchronously — throws `ConfigError` on invalid options.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import type { Pool } from "mysql2/promise";
|
|
106
|
+
|
|
107
|
+
interface MysqlBossOptions {
|
|
108
|
+
pool: Pool; // Required. Your mysql2 promise pool.
|
|
109
|
+
tablePrefix?: string; // Default: "mysql_boss". Set "" for legacy unprefixed table names.
|
|
110
|
+
pollIntervalMs?: number; // Default: 2000. How often to poll for jobs (ms). ±20% jitter applied.
|
|
111
|
+
batchSize?: number; // Default: 10. Jobs claimed per poll (1–100).
|
|
112
|
+
concurrency?: number; // Default: batchSize. Max in-flight jobs per worker.
|
|
113
|
+
leaseSeconds?: number; // Default: 300. How long a job is locked before recovery.
|
|
114
|
+
heartbeatSeconds?: number; // Default: 100. Lease renewal interval.
|
|
115
|
+
sweepIntervalMs?: number; // Default: 60000. Stale-job recovery check interval (ms).
|
|
116
|
+
tickIntervalMs?: number; // Default: 30000. Cron schedule check interval (ms).
|
|
117
|
+
archiveRetentionDays?: number; // Default: 14. Days to keep archived (completed) jobs. Integer >= 1.
|
|
118
|
+
drainTimeoutMs?: number; // Default: 30000. Max time to wait for in-flight jobs during stop().
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Constraints:**
|
|
123
|
+
- `leaseSeconds` must be >= `3 * heartbeatSeconds` (throws `ConfigError`)
|
|
124
|
+
- `batchSize` must be between 1 and 100 (throws `ConfigError`)
|
|
125
|
+
- `archiveRetentionDays` must be a positive integer (throws `ConfigError`)
|
|
126
|
+
- `tablePrefix` may contain only ASCII letters, numbers, and underscores, up to 50 characters
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
// Example: low-latency config for small jobs
|
|
130
|
+
const boss = new MysqlBoss({
|
|
131
|
+
pool,
|
|
132
|
+
pollIntervalMs: 500,
|
|
133
|
+
batchSize: 20,
|
|
134
|
+
concurrency: 20,
|
|
135
|
+
leaseSeconds: 60,
|
|
136
|
+
heartbeatSeconds: 15,
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### `boss.migrate()`
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
await boss.migrate(): Promise<void>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Creates all required tables (`mysql_boss_jobs`, `mysql_boss_jobs_archive`, `mysql_boss_jobs_dead`, `mysql_boss_schedules` by default) if they don't exist. **Idempotent** — safe to call on every app startup.
|
|
149
|
+
|
|
150
|
+
Use `tablePrefix` when multiple applications share a database:
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
const boss = new MysqlBoss({ pool, tablePrefix: "billing" });
|
|
154
|
+
await boss.migrate();
|
|
155
|
+
// billing_jobs, billing_jobs_archive, billing_jobs_dead, billing_schedules
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Set `tablePrefix: ""` to retain the original unprefixed table names. Changing the
|
|
159
|
+
prefix points the instance at a different set of tables; it does not rename or
|
|
160
|
+
migrate data from an existing set.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
### `boss.enqueue(queue, payload, options?)`
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
await boss.enqueue<T>(queue: string, payload: T, opts?: EnqueueOptions): Promise<string | null>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Adds a job to the queue. Returns the job ID as a string, or `null` if deduplicated by `singletonKey`.
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
interface EnqueueOptions {
|
|
174
|
+
priority?: number; // Default: 0. Range: -32768 to 32767. Higher runs first.
|
|
175
|
+
singletonKey?: string; // Dedupe key (max 191 chars). Must not start with "cron:".
|
|
176
|
+
retryLimit?: number; // Default: 2. Max retries after initial attempt (so 3 total runs max).
|
|
177
|
+
retryDelaySecs?: number; // Default: 30. Base delay between retries (seconds).
|
|
178
|
+
retryBackoff?: boolean; // Default: false. true = exponential backoff (delay * 2^n, capped at 24h).
|
|
179
|
+
runAt?: Date; // Default: now (DB clock). Schedule for future execution.
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
#### Examples
|
|
184
|
+
|
|
185
|
+
**Basic enqueue:**
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
const jobId = await boss.enqueue("send-email", {
|
|
189
|
+
to: "user@example.com",
|
|
190
|
+
subject: "Hello",
|
|
191
|
+
});
|
|
192
|
+
console.log(`Enqueued job ${jobId}`);
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**With priority (higher = more urgent):**
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
await boss.enqueue("notifications", { userId: 42 }, { priority: 10 });
|
|
199
|
+
await boss.enqueue("notifications", { userId: 99 }, { priority: 0 });
|
|
200
|
+
// userId 42 will be processed first
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**Delayed / scheduled job:**
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
const inOneHour = new Date(Date.now() + 60 * 60 * 1000);
|
|
207
|
+
await boss.enqueue("send-reminder", { userId: 42 }, { runAt: inOneHour });
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Singleton (deduplicate):**
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
// Only one "daily-report" job per queue at a time
|
|
214
|
+
const id = await boss.enqueue("reports", { type: "daily" }, {
|
|
215
|
+
singletonKey: "daily-report",
|
|
216
|
+
});
|
|
217
|
+
// Second call returns null — job already exists
|
|
218
|
+
const duplicate = await boss.enqueue("reports", { type: "daily" }, {
|
|
219
|
+
singletonKey: "daily-report",
|
|
220
|
+
});
|
|
221
|
+
console.log(duplicate); // null
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**With retries and exponential backoff:**
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
await boss.enqueue("call-api", { url: "https://api.example.com" }, {
|
|
228
|
+
retryLimit: 5, // Retry up to 5 times after the first attempt (6 total)
|
|
229
|
+
retryDelaySecs: 10, // Start at 10s
|
|
230
|
+
retryBackoff: true, // 10s, 20s, 40s, 80s, 160s (capped at 24h)
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
### `boss.work(queue, handler)`
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
boss.work<T>(queue: string, handler: JobHandler<T>): void
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Registers a handler for a queue and starts polling. **One handler per queue** — calling `work()` twice for the same queue throws.
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
type JobHandler<T = unknown> = (
|
|
246
|
+
job: ActiveJob<T>,
|
|
247
|
+
ctx: { signal: AbortSignal },
|
|
248
|
+
) => Promise<void>;
|
|
249
|
+
|
|
250
|
+
interface ActiveJob<T = unknown> {
|
|
251
|
+
id: string; // Job ID (BIGINT rendered as string)
|
|
252
|
+
queue: string; // Queue name
|
|
253
|
+
payload: T; // The payload you enqueued
|
|
254
|
+
retryCount: number; // How many retries have been consumed so far
|
|
255
|
+
retryLimit: number; // Max retries allowed
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Handler outcomes:**
|
|
260
|
+
|
|
261
|
+
| Handler does | Result |
|
|
262
|
+
|---|---|
|
|
263
|
+
| Resolves (returns) | Job is **completed** and moved to the archive table |
|
|
264
|
+
| Rejects (throws) with retries remaining | Job returns to queue with backoff delay |
|
|
265
|
+
| Rejects (throws) with retries exhausted | Job moves to the dead-letter table |
|
|
266
|
+
|
|
267
|
+
#### Examples
|
|
268
|
+
|
|
269
|
+
**Simple handler:**
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
boss.work("send-email", async (job) => {
|
|
273
|
+
await sendEmail(job.payload.to, job.payload.subject);
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Typed payload:**
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
interface EmailPayload {
|
|
281
|
+
to: string;
|
|
282
|
+
subject: string;
|
|
283
|
+
body: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
boss.work<EmailPayload>("send-email", async (job) => {
|
|
287
|
+
// job.payload is typed as EmailPayload
|
|
288
|
+
await sendEmail(job.payload.to, job.payload.subject, job.payload.body);
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Using the abort signal:**
|
|
293
|
+
|
|
294
|
+
The `signal` is aborted in two cases:
|
|
295
|
+
1. **Lease lost** — another worker reclaimed the job (the handler is a zombie)
|
|
296
|
+
2. **Shutdown** — `boss.stop()` was called and drain timeout expired
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
boss.work("long-task", async (job, { signal }) => {
|
|
300
|
+
for (const chunk of splitIntoChunks(job.payload.data)) {
|
|
301
|
+
if (signal.aborted) {
|
|
302
|
+
throw new Error("Aborted");
|
|
303
|
+
}
|
|
304
|
+
await processChunk(chunk);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**With fetch (Node 20+):**
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
boss.work("call-api", async (job, { signal }) => {
|
|
313
|
+
const response = await fetch(job.payload.url, { signal });
|
|
314
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
315
|
+
const data = await response.json();
|
|
316
|
+
await saveResult(job.id, data);
|
|
317
|
+
});
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
### `boss.schedule(name, queue, cron, options?)`
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
await boss.schedule(name: string, queue: string, cron: string, opts?: ScheduleOptions): Promise<void>
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Creates or updates a cron schedule. Jobs are enqueued automatically on each tick.
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
interface ScheduleOptions {
|
|
332
|
+
timezone?: string; // Default: "UTC". Any IANA timezone (e.g. "America/New_York").
|
|
333
|
+
payload?: unknown; // Payload for each spawned job.
|
|
334
|
+
priority?: number; // Default: 0.
|
|
335
|
+
retryLimit?: number; // Default: 2.
|
|
336
|
+
retryDelaySecs?: number; // Default: 30.
|
|
337
|
+
retryBackoff?: boolean; // Default: false.
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**Cron format** — standard 5-field Vixie cron:
|
|
342
|
+
|
|
343
|
+
```
|
|
344
|
+
┌───────── minute (0–59)
|
|
345
|
+
│ ┌─────── hour (0–23)
|
|
346
|
+
│ │ ┌───── day of month (1–31)
|
|
347
|
+
│ │ │ ┌─── month (1–12 or Jan–Dec)
|
|
348
|
+
│ │ │ │ ┌─ day of week (0–6 or Sun–Sat, 0 = Sunday)
|
|
349
|
+
│ │ │ │ │
|
|
350
|
+
* * * * *
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Supports: values, ranges (`1-5`), steps (`*/15`), lists (`1,3,5`), and month/day-of-week names.
|
|
354
|
+
|
|
355
|
+
**DOM/DOW OR-semantics:** when both day-of-month and day-of-week are specified (not `*`), the job fires if **either** matches (Vixie cron behavior).
|
|
356
|
+
|
|
357
|
+
#### Examples
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
// Every hour on the hour
|
|
361
|
+
await boss.schedule("hourly-cleanup", "cleanup", "0 * * * *");
|
|
362
|
+
|
|
363
|
+
// Every weekday at 9:00 AM Eastern
|
|
364
|
+
await boss.schedule("morning-report", "reports", "0 9 * * 1-5", {
|
|
365
|
+
timezone: "America/New_York",
|
|
366
|
+
payload: { type: "morning" },
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Every 15 minutes with high priority
|
|
370
|
+
await boss.schedule("health-check", "monitoring", "*/15 * * * *", {
|
|
371
|
+
priority: 10,
|
|
372
|
+
retryLimit: 0, // No retries for health checks
|
|
373
|
+
});
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**Catch-up policy:** if the system was down across multiple occurrences, only **one** catch-up job is created (not one per missed tick).
|
|
377
|
+
|
|
378
|
+
**Updating a schedule:** calling `schedule()` with the same `name` updates the existing schedule in place.
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
### `boss.unschedule(name)`
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
await boss.unschedule(name: string): Promise<void>
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Removes a cron schedule by name. Already-enqueued jobs from this schedule are not affected.
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
await boss.unschedule("hourly-cleanup");
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
### `boss.listDead(query)`
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
await boss.listDead(query): Promise<DeadJob[]>
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Lists jobs in the dead-letter queue (jobs that exhausted all retries).
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
interface DeadJob<T = unknown> {
|
|
406
|
+
id: string;
|
|
407
|
+
queue: string;
|
|
408
|
+
payload: T;
|
|
409
|
+
priority: number;
|
|
410
|
+
retryCount: number;
|
|
411
|
+
createdAt: Date;
|
|
412
|
+
failedAt: Date;
|
|
413
|
+
lastError: { message: string; stack?: string; at: string } | null;
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
```typescript
|
|
418
|
+
const deadJobs = await boss.listDead({
|
|
419
|
+
queue: "send-email",
|
|
420
|
+
after: new Date("2025-01-01"), // Optional: filter by failed_at range
|
|
421
|
+
before: new Date(), // Optional
|
|
422
|
+
limit: 20, // Default: 50
|
|
423
|
+
offset: 0, // Default: 0
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
for (const job of deadJobs) {
|
|
427
|
+
console.log(`Job ${job.id} failed: ${job.lastError?.message}`);
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
### `boss.replayDead(ids)`
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
await boss.replayDead(ids: string[]): Promise<number>
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Re-enqueues dead jobs as fresh jobs (new IDs, counters reset). Returns the count of replayed jobs.
|
|
440
|
+
|
|
441
|
+
Throws `SingletonCollisionError` if a replayed job collides with a live singleton — the dead row is preserved and nothing is replayed.
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
// Replay specific failed jobs
|
|
445
|
+
const replayed = await boss.replayDead(["42", "43", "44"]);
|
|
446
|
+
console.log(`Replayed ${replayed} jobs`);
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
// Replay all dead jobs for a queue
|
|
451
|
+
const deadJobs = await boss.listDead({ queue: "send-email" });
|
|
452
|
+
if (deadJobs.length > 0) {
|
|
453
|
+
const replayed = await boss.replayDead(deadJobs.map((j) => j.id));
|
|
454
|
+
console.log(`Replayed ${replayed} of ${deadJobs.length} dead jobs`);
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
### `boss.getArchivedJob(id)`
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
await boss.getArchivedJob(id: string): Promise<ArchivedJob | null>
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
Looks up a completed job by its original ID. Returns `null` if not found (either never existed or already pruned by retention).
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
interface ArchivedJob<T = unknown> {
|
|
470
|
+
id: string;
|
|
471
|
+
queue: string;
|
|
472
|
+
payload: T;
|
|
473
|
+
priority: number;
|
|
474
|
+
retryCount: number;
|
|
475
|
+
createdAt: Date;
|
|
476
|
+
startedAt: Date;
|
|
477
|
+
completedAt: Date;
|
|
478
|
+
durationMs: number; // Handler execution time in milliseconds
|
|
479
|
+
}
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
const archived = await boss.getArchivedJob("12345");
|
|
484
|
+
if (archived) {
|
|
485
|
+
console.log(`Job completed in ${archived.durationMs}ms`);
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
### `boss.listArchive(query)`
|
|
492
|
+
|
|
493
|
+
```typescript
|
|
494
|
+
await boss.listArchive(query): Promise<ArchivedJob[]>
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Lists completed jobs using keyset pagination (pass the last row's `completedAt` as `before` for the next page).
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
// First page
|
|
501
|
+
const page1 = await boss.listArchive({
|
|
502
|
+
queue: "send-email",
|
|
503
|
+
limit: 20,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Next page (use last row's completedAt as cursor)
|
|
507
|
+
if (page1.length === 20) {
|
|
508
|
+
const page2 = await boss.listArchive({
|
|
509
|
+
queue: "send-email",
|
|
510
|
+
before: page1[page1.length - 1].completedAt,
|
|
511
|
+
limit: 20,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
---
|
|
517
|
+
|
|
518
|
+
### `boss.stop(options?)`
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
await boss.stop(opts?: { drainTimeoutMs?: number }): Promise<void>
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Gracefully shuts down all workers. **Idempotent** — safe to call multiple times. The instance is unusable after `stop()`.
|
|
525
|
+
|
|
526
|
+
**Shutdown sequence:**
|
|
527
|
+
|
|
528
|
+
1. Stops all polling loops and the cron ticker (no new claims)
|
|
529
|
+
2. Waits for in-flight handlers up to `drainTimeoutMs` (default: 30s)
|
|
530
|
+
3. If handlers are still running at timeout: aborts their `AbortSignal` and releases jobs back to the queue **without incrementing retry count** (a drain is not a failure)
|
|
531
|
+
4. Stops heartbeat and sweep timers
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
// Wire up to process signals
|
|
535
|
+
process.on("SIGTERM", async () => {
|
|
536
|
+
console.log("Shutting down...");
|
|
537
|
+
await boss.stop({ drainTimeoutMs: 10_000 });
|
|
538
|
+
await pool.end();
|
|
539
|
+
console.log("Shutdown complete");
|
|
540
|
+
});
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
### Error classes
|
|
546
|
+
|
|
547
|
+
All predictable failures throw typed errors you can catch:
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
import { ConfigError, ValidationError, SingletonCollisionError } from "mysql-boss";
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
| Error | When |
|
|
554
|
+
|---|---|
|
|
555
|
+
| `ConfigError` | Invalid constructor options (bad `batchSize`, `leaseSeconds < 3 * heartbeatSeconds`, etc.) |
|
|
556
|
+
| `ValidationError` | Invalid enqueue arguments (`priority` out of range, `singletonKey` too long or starts with `cron:`) |
|
|
557
|
+
| `SingletonCollisionError` | `replayDead()` collides with a live singleton job |
|
|
558
|
+
|
|
559
|
+
---
|
|
560
|
+
|
|
561
|
+
## Delivery guarantee
|
|
562
|
+
|
|
563
|
+
mysql-boss provides **at-least-once delivery**. This means:
|
|
564
|
+
|
|
565
|
+
- A job will be processed **at least once** — crash recovery ensures no job is lost
|
|
566
|
+
- A job **may be processed more than once** if a worker crashes after completing work but before the completion transaction commits
|
|
567
|
+
- **Your handlers must be idempotent** — safe to run multiple times with the same input
|
|
568
|
+
|
|
569
|
+
### Why "at least once"?
|
|
570
|
+
|
|
571
|
+
If a worker finishes processing a job but crashes before the archive-move transaction commits, the job's lease will eventually expire. The sweep recovers it, and another worker re-processes it. The work was done twice, but the job record stays consistent.
|
|
572
|
+
|
|
573
|
+
**Making handlers idempotent:**
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
boss.work("charge-customer", async (job) => {
|
|
577
|
+
// Use an idempotency key to prevent double-charging
|
|
578
|
+
const idempotencyKey = `charge-${job.id}`;
|
|
579
|
+
await paymentProvider.charge(job.payload.amount, { idempotencyKey });
|
|
580
|
+
});
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
## Architecture
|
|
586
|
+
|
|
587
|
+
### Tables
|
|
588
|
+
|
|
589
|
+
| Table | Purpose | Size profile |
|
|
590
|
+
|---|---|---|
|
|
591
|
+
| `mysql_boss_jobs` | Hot table — only pending and in-flight jobs | Backlog-sized (small) |
|
|
592
|
+
| `mysql_boss_jobs_archive` | Completed jobs — audit/history | Grows with throughput, pruned by retention |
|
|
593
|
+
| `mysql_boss_jobs_dead` | Failed jobs (retries exhausted) | Small unless something is broken |
|
|
594
|
+
| `mysql_boss_schedules` | Cron schedule definitions | Tiny |
|
|
595
|
+
|
|
596
|
+
Completed jobs are **moved** out of the hot table into the archive table on success (and into the dead-letter table on permanent failure). This keeps the hot table small and the dequeue index fast — MySQL has no partial indexes, so terminal rows left in it would bloat every claim scan.
|
|
597
|
+
|
|
598
|
+
### How claims work
|
|
599
|
+
|
|
600
|
+
```
|
|
601
|
+
Worker A Worker B
|
|
602
|
+
| |
|
|
603
|
+
|-- BEGIN |-- BEGIN
|
|
604
|
+
|-- SELECT ... FOR UPDATE |-- SELECT ... FOR UPDATE
|
|
605
|
+
| SKIP LOCKED | SKIP LOCKED
|
|
606
|
+
| -> locks rows 1, 2, 3 | -> skips 1, 2, 3
|
|
607
|
+
| | -> locks rows 4, 5, 6
|
|
608
|
+
|-- UPDATE SET state='active' |-- UPDATE SET state='active'
|
|
609
|
+
|-- COMMIT |-- COMMIT
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
Workers never block each other. `SKIP LOCKED` means if a row is already claimed, the next worker silently skips it and takes the next available job.
|
|
613
|
+
|
|
614
|
+
### Crash recovery
|
|
615
|
+
|
|
616
|
+
Every claimed job has a `lease_expires_at` timestamp. If a worker crashes:
|
|
617
|
+
|
|
618
|
+
1. Its database connections die — all InnoDB row locks are released
|
|
619
|
+
2. The `lease_expires_at` data column remains
|
|
620
|
+
3. A periodic **sweep** finds jobs where `lease_expires_at < NOW()` and `state = 'active'`
|
|
621
|
+
4. The sweep resets them to `available` (with `retry_count` incremented) or moves them to the DLQ
|
|
622
|
+
|
|
623
|
+
### Fencing
|
|
624
|
+
|
|
625
|
+
Every post-claim operation (`complete`, `fail`, `heartbeat`) includes `AND locked_by = UUID_TO_BIN(?)`. A zombie worker whose lease expired cannot affect a job now owned by someone else.
|
|
626
|
+
|
|
627
|
+
---
|
|
628
|
+
|
|
629
|
+
## Archive retention and partitioning
|
|
630
|
+
|
|
631
|
+
The archive table (`mysql_boss_jobs_archive` by default) is pruned automatically based on `archiveRetentionDays` (default: 14 days). Pruning runs in batches of 5,000 rows to bound lock time.
|
|
632
|
+
|
|
633
|
+
### For high-throughput or long-retention setups
|
|
634
|
+
|
|
635
|
+
If you keep archives for 90+ days or process millions of jobs, consider partitioning your configured archive table by `completed_at` (monthly partitions). This turns retention cleanup from batched DELETEs into instant `ALTER TABLE ... DROP PARTITION`:
|
|
636
|
+
|
|
637
|
+
```sql
|
|
638
|
+
ALTER TABLE mysql_boss_jobs_archive
|
|
639
|
+
PARTITION BY RANGE (TO_DAYS(completed_at)) (
|
|
640
|
+
PARTITION p202501 VALUES LESS THAN (TO_DAYS('2025-02-01')),
|
|
641
|
+
PARTITION p202502 VALUES LESS THAN (TO_DAYS('2025-03-01')),
|
|
642
|
+
-- add partitions monthly
|
|
643
|
+
PARTITION pmax VALUES LESS THAN MAXVALUE
|
|
644
|
+
);
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
mysql-boss's batched `DELETE` still works against a partitioned table — partitioning is a deployment choice, not a library change.
|
|
648
|
+
|
|
649
|
+
---
|
|
650
|
+
|
|
651
|
+
## Connection pool sizing
|
|
652
|
+
|
|
653
|
+
mysql-boss uses short-lived connections: one per claim transaction, released immediately after COMMIT. Handlers never hold connections. A reasonable starting point:
|
|
654
|
+
|
|
655
|
+
```
|
|
656
|
+
connectionLimit = concurrency + 2 (sweep + tick)
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
For a single-process worker with `concurrency: 10`, a pool of 12–15 connections is plenty.
|
|
660
|
+
|
|
661
|
+
All connections are configured to `READ COMMITTED` isolation and `UTC` timezone automatically.
|
|
662
|
+
|
|
663
|
+
---
|
|
664
|
+
|
|
665
|
+
## TypeScript and JavaScript support
|
|
666
|
+
|
|
667
|
+
mysql-boss ships dual **ESM** and **CommonJS** builds with full type definitions.
|
|
668
|
+
|
|
669
|
+
**ESM (TypeScript / modern Node.js):**
|
|
670
|
+
|
|
671
|
+
```typescript
|
|
672
|
+
import { MysqlBoss } from "mysql-boss";
|
|
673
|
+
import type { ActiveJob, EnqueueOptions } from "mysql-boss";
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
**CommonJS (JavaScript):**
|
|
677
|
+
|
|
678
|
+
```javascript
|
|
679
|
+
const { MysqlBoss } = require("mysql-boss");
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
Both resolve automatically via the `exports` field in `package.json`.
|
|
683
|
+
|
|
684
|
+
---
|
|
685
|
+
|
|
686
|
+
## License
|
|
687
|
+
|
|
688
|
+
[MIT](LICENSE)
|