@chidchanun/bcp 0.2.8 → 0.2.10
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/README.md +164 -163
- package/docs/README.md +48 -139
- package/docs/api-manifest.json +4 -2
- package/docs/api-reference.md +92 -242
- package/docs/docs-web-manifest.json +7 -3
- package/docs/durable-jobs.md +359 -0
- package/docs/job-scheduling.md +357 -0
- package/docs/platform-manifest.json +23 -4
- package/docs/releases/0.2.10.md +148 -0
- package/docs/releases/0.2.9.md +162 -0
- package/package.json +2 -2
- package/packages/client/src/jobs.mjs +2289 -0
- package/packages/client/src/jobs.ts +34 -0
- package/packages/server/src/job-scheduler.ts +1144 -0
- package/packages/server/src/jobs-redis.ts +1226 -0
- package/packages/server/src/jobs.ts +768 -128
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# Durable Jobs Platform
|
|
2
|
+
|
|
3
|
+
BCP Framework `0.2.10` extends `bcp/jobs` with production-oriented worker lifecycle primitives, dead-letter handling, retention utilities and Redis-compatible queue/scheduler adapters.
|
|
4
|
+
|
|
5
|
+
The public entrypoint remains:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import {
|
|
9
|
+
createJobQueue,
|
|
10
|
+
createJobScheduler,
|
|
11
|
+
} from "bcp/jobs";
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`0.2.10` is additive to the queue contract introduced in `0.2.8` and the scheduling contract introduced in `0.2.9`.
|
|
15
|
+
|
|
16
|
+
## What 0.2.10 adds
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
visibility timeout
|
|
20
|
+
worker lease owner
|
|
21
|
+
heartbeat renewal
|
|
22
|
+
stale-running recovery
|
|
23
|
+
dead-letter queue (DLQ)
|
|
24
|
+
dead-letter requeue
|
|
25
|
+
terminal-job cleanup
|
|
26
|
+
queue statistics
|
|
27
|
+
Redis-compatible JobQueueAdapter
|
|
28
|
+
Redis-compatible JobScheduleStore
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The built-in memory adapter implements the same lifecycle for development and deterministic tests. Durable multi-process deployments should use a shared adapter such as the Redis-compatible adapter or another implementation of the public contracts.
|
|
32
|
+
|
|
33
|
+
## Worker visibility timeout
|
|
34
|
+
|
|
35
|
+
Workers now reserve jobs with a visibility lease:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const worker =
|
|
39
|
+
jobs.startWorker({
|
|
40
|
+
workerId: "email-worker",
|
|
41
|
+
concurrency: 4,
|
|
42
|
+
visibilityTimeoutMs: 30_000,
|
|
43
|
+
heartbeatIntervalMs: 10_000,
|
|
44
|
+
pollIntervalMs: 250,
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A durable adapter can persist:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
leaseOwner
|
|
52
|
+
leaseUntil
|
|
53
|
+
heartbeatAt
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
While a handler is running, BCP renews the lease when the adapter implements `heartbeat()`.
|
|
57
|
+
|
|
58
|
+
If the process disappears and the lease expires, `recoverStale()` can move the job back to `queued` so another worker can process it.
|
|
59
|
+
|
|
60
|
+
This is an **at-least-once** model. Handlers that perform non-idempotent side effects should use application-level idempotency keys or transactions.
|
|
61
|
+
|
|
62
|
+
## Manual stale recovery
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const recovered =
|
|
66
|
+
await jobs.recoverStale({
|
|
67
|
+
limit: 100,
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Workers also request stale-job recovery before reserving work when the adapter implements the capability.
|
|
72
|
+
|
|
73
|
+
A recovered job keeps its attempt count. It may therefore reach `maxAttempts` after repeated crashes or visibility-timeout recoveries.
|
|
74
|
+
|
|
75
|
+
## Dead-letter queue
|
|
76
|
+
|
|
77
|
+
When a handler exhausts `maxAttempts`, the memory and Redis adapters keep the job in terminal `failed` state and also add it to the DLQ index.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const failedJobs =
|
|
81
|
+
await jobs.deadLetters();
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Each result includes:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
{
|
|
88
|
+
state: "failed",
|
|
89
|
+
deadLetteredAt: number,
|
|
90
|
+
// normal JobRecord fields
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Requeue a DLQ job:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
await jobs.requeueDeadLetter(
|
|
98
|
+
jobId,
|
|
99
|
+
{
|
|
100
|
+
delayMs: 5_000,
|
|
101
|
+
resetAttempts: true,
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`resetAttempts` defaults to `true`.
|
|
107
|
+
|
|
108
|
+
## Queue statistics
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const stats =
|
|
112
|
+
await jobs.stats();
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Shape:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
{
|
|
119
|
+
total: number;
|
|
120
|
+
queued: number;
|
|
121
|
+
running: number;
|
|
122
|
+
succeeded: number;
|
|
123
|
+
failed: number;
|
|
124
|
+
cancelled: number;
|
|
125
|
+
deadLetters: number;
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Adapters may provide an optimized `stats()` implementation. Otherwise BCP derives statistics from `list()` plus the optional DLQ contract.
|
|
130
|
+
|
|
131
|
+
## Retention cleanup
|
|
132
|
+
|
|
133
|
+
Terminal jobs can be removed after an application-defined retention window:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const sevenDaysAgo =
|
|
137
|
+
Date.now() -
|
|
138
|
+
7 * 24 * 60 * 60 * 1000;
|
|
139
|
+
|
|
140
|
+
await jobs.cleanup({
|
|
141
|
+
before: sevenDaysAgo,
|
|
142
|
+
states: [
|
|
143
|
+
"succeeded",
|
|
144
|
+
"cancelled",
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
If `states` is omitted, cleanup considers:
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
succeeded
|
|
153
|
+
failed
|
|
154
|
+
cancelled
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
For failed jobs, cleanup also removes the DLQ index entry.
|
|
158
|
+
|
|
159
|
+
The Redis reference adapter removes at most 1,000 matching terminal records per cleanup call. Large installations can invoke cleanup repeatedly from a scheduled maintenance job.
|
|
160
|
+
|
|
161
|
+
## Redis-compatible queue adapter
|
|
162
|
+
|
|
163
|
+
BCP does not install or own a Redis client library. The adapter accepts a minimal command client:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
interface RedisCommandClient {
|
|
167
|
+
sendCommand(
|
|
168
|
+
command: string[]
|
|
169
|
+
): Promise<unknown>;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Create the queue adapter:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import {
|
|
177
|
+
createJobQueue,
|
|
178
|
+
createRedisJobQueueAdapter,
|
|
179
|
+
} from "bcp/jobs";
|
|
180
|
+
|
|
181
|
+
const adapter =
|
|
182
|
+
createRedisJobQueueAdapter({
|
|
183
|
+
client: redisCommandClient,
|
|
184
|
+
namespace: "my-app:{jobs}",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export const jobs =
|
|
188
|
+
createJobQueue({
|
|
189
|
+
adapter,
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The default namespace is:
|
|
194
|
+
|
|
195
|
+
```text
|
|
196
|
+
bcp:{jobs}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The `{jobs}` Redis hash tag keeps all adapter keys in the same Redis Cluster slot so Lua operations can remain atomic.
|
|
200
|
+
|
|
201
|
+
### Connection lifecycle
|
|
202
|
+
|
|
203
|
+
Applications own the Redis connection. When desired, provide a close hook:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const adapter =
|
|
207
|
+
createRedisJobQueueAdapter({
|
|
208
|
+
client: redisCommandClient,
|
|
209
|
+
close: async () => {
|
|
210
|
+
await redisClient.quit();
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Then:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
await jobs.close();
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
will stop BCP workers and invoke the adapter close hook.
|
|
222
|
+
|
|
223
|
+
If queue and scheduler share one Redis connection, normally provide the close hook to only one owner or coordinate connection shutdown at the application layer.
|
|
224
|
+
|
|
225
|
+
## Redis environment configuration
|
|
226
|
+
|
|
227
|
+
A typical application can use:
|
|
228
|
+
|
|
229
|
+
```dotenv
|
|
230
|
+
REDIS_URL=redis://localhost:6379
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`bcp/jobs` does **not** read `REDIS_URL` automatically. Connection creation remains application-owned so credentials, TLS, Sentinel/Cluster configuration and client-library choice stay explicit.
|
|
234
|
+
|
|
235
|
+
## Atomic Redis behavior
|
|
236
|
+
|
|
237
|
+
The reference Redis adapter uses Lua `EVAL` for state-changing operations that require atomicity, including:
|
|
238
|
+
|
|
239
|
+
```text
|
|
240
|
+
enqueue duplicate protection
|
|
241
|
+
job reservation
|
|
242
|
+
complete/fail/cancel
|
|
243
|
+
heartbeat lease renewal
|
|
244
|
+
stale-running recovery
|
|
245
|
+
DLQ requeue
|
|
246
|
+
retention cleanup
|
|
247
|
+
schedule upsert/remove
|
|
248
|
+
schedule lease acquisition
|
|
249
|
+
schedule completion/release
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Queue reservation atomically removes one eligible job from the available index, marks it running and writes the visibility lease before another worker can reserve it.
|
|
253
|
+
|
|
254
|
+
## Redis-backed scheduler
|
|
255
|
+
|
|
256
|
+
The same Redis command client can back the scheduling contract:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import {
|
|
260
|
+
createJobScheduler,
|
|
261
|
+
createRedisJobScheduleStore,
|
|
262
|
+
} from "bcp/jobs";
|
|
263
|
+
|
|
264
|
+
const scheduleStore =
|
|
265
|
+
createRedisJobScheduleStore({
|
|
266
|
+
client: redisCommandClient,
|
|
267
|
+
namespace: "my-app:{jobs}",
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
export const scheduler =
|
|
271
|
+
createJobScheduler({
|
|
272
|
+
queue: jobs,
|
|
273
|
+
store: scheduleStore,
|
|
274
|
+
ownerId: "scheduler-a",
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`acquireDue()` is implemented with an atomic Redis lease, allowing multiple scheduler processes to share the same schedule store.
|
|
279
|
+
|
|
280
|
+
## Recommended production topology
|
|
281
|
+
|
|
282
|
+
```text
|
|
283
|
+
Web/API instances
|
|
284
|
+
|
|
|
285
|
+
| enqueue / schedule
|
|
286
|
+
v
|
|
287
|
+
Redis-compatible shared job state
|
|
288
|
+
|
|
|
289
|
+
+-------------------+
|
|
290
|
+
| |
|
|
291
|
+
v v
|
|
292
|
+
Worker A Worker B
|
|
293
|
+
visibility lease visibility lease
|
|
294
|
+
heartbeat heartbeat
|
|
295
|
+
|
|
|
296
|
+
+---- failure ----> retry / DLQ
|
|
297
|
+
|
|
298
|
+
Scheduler A -----+
|
|
299
|
+
+---- shared schedule lease
|
|
300
|
+
Scheduler B -----+
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Queue workers and schedulers can run in the same Node process for small deployments, but separate worker processes/containers are recommended when job load can affect web latency.
|
|
304
|
+
|
|
305
|
+
## Adapter compatibility
|
|
306
|
+
|
|
307
|
+
Existing `0.2.8` `JobQueueAdapter` implementations remain valid. Durable methods are optional:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
heartbeat?()
|
|
311
|
+
recoverStale?()
|
|
312
|
+
listDeadLetters?()
|
|
313
|
+
requeueDeadLetter?()
|
|
314
|
+
cleanup?()
|
|
315
|
+
stats?()
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
The `reserve()` method now receives an optional second argument with the worker owner ID and visibility timeout. Existing JavaScript adapters that ignore the optional argument continue to work.
|
|
319
|
+
|
|
320
|
+
For production durable adapters, implementations should support the lease contract rather than ignoring it.
|
|
321
|
+
|
|
322
|
+
## Failure model
|
|
323
|
+
|
|
324
|
+
BCP jobs intentionally use practical at-least-once delivery semantics.
|
|
325
|
+
|
|
326
|
+
Applications should assume that a job can execute more than once when:
|
|
327
|
+
|
|
328
|
+
- a worker completes external side effects but crashes before recording success,
|
|
329
|
+
- a visibility lease expires during a long or blocked handler,
|
|
330
|
+
- a DLQ job is manually requeued,
|
|
331
|
+
- infrastructure retries a command after an ambiguous network result.
|
|
332
|
+
|
|
333
|
+
Use idempotency keys, unique database constraints or transactional outbox/inbox patterns for side effects where duplicates are unsafe.
|
|
334
|
+
|
|
335
|
+
## Observability
|
|
336
|
+
|
|
337
|
+
`jobs.stats()` can feed application metrics from `bcp/observability`:
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
const stats =
|
|
341
|
+
await jobs.stats();
|
|
342
|
+
|
|
343
|
+
queueDepth.set(
|
|
344
|
+
stats.queued
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
dlqDepth.set(
|
|
348
|
+
stats.deadLetters
|
|
349
|
+
);
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
For Redis deployments, infrastructure-level Redis monitoring should complement application-level BCP metrics.
|
|
353
|
+
|
|
354
|
+
## Related guides
|
|
355
|
+
|
|
356
|
+
- [Background Jobs Platform](background-jobs.md)
|
|
357
|
+
- [Job Scheduling Platform](job-scheduling.md)
|
|
358
|
+
- [Observability Platform v2](observability.md)
|
|
359
|
+
- [Production Hardening](production-hardening.md)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# Job Scheduling Platform
|
|
2
|
+
|
|
3
|
+
BCP Framework `0.2.9` extends the Background Jobs Platform with recurring job schedules, dependency-free UTC cron expressions, interval schedules and a schedule-store lease contract for multi-instance deployments.
|
|
4
|
+
|
|
5
|
+
Import scheduling APIs from the server-only `bcp/jobs` entrypoint:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import {
|
|
9
|
+
createJobQueue,
|
|
10
|
+
createJobScheduler,
|
|
11
|
+
} from "bcp/jobs";
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Create a scheduler
|
|
15
|
+
|
|
16
|
+
A scheduler enqueues work into an existing BCP background job queue:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import {
|
|
20
|
+
createJobQueue,
|
|
21
|
+
createJobScheduler,
|
|
22
|
+
} from "bcp/jobs";
|
|
23
|
+
|
|
24
|
+
export const jobs =
|
|
25
|
+
createJobQueue();
|
|
26
|
+
|
|
27
|
+
export const scheduler =
|
|
28
|
+
createJobScheduler({
|
|
29
|
+
queue: jobs,
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The scheduler does not execute application work itself. It creates normal queue jobs when a schedule becomes due; queue workers execute the registered job handlers.
|
|
34
|
+
|
|
35
|
+
## Interval schedules
|
|
36
|
+
|
|
37
|
+
Schedule a recurring job every five minutes:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
await scheduler.schedule(
|
|
41
|
+
"cache.cleanup",
|
|
42
|
+
{
|
|
43
|
+
scope: "expired",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "cache-cleanup",
|
|
47
|
+
everyMs: 5 * 60 * 1000,
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`everyMs` must be a positive integer.
|
|
53
|
+
|
|
54
|
+
By default the first run occurs one interval after the schedule is created.
|
|
55
|
+
|
|
56
|
+
Use `startAt` to select the first due time explicitly:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
await scheduler.schedule(
|
|
60
|
+
"report.daily",
|
|
61
|
+
{},
|
|
62
|
+
{
|
|
63
|
+
everyMs:
|
|
64
|
+
24 * 60 * 60 * 1000,
|
|
65
|
+
startAt:
|
|
66
|
+
Date.now() + 10_000,
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`startAt` accepts either a millisecond timestamp or a `Date`.
|
|
72
|
+
|
|
73
|
+
## Cron schedules
|
|
74
|
+
|
|
75
|
+
BCP `0.2.9` supports dependency-free five-field cron expressions:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
minute hour day-of-month month day-of-week
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Example — every weekday at 09:30 UTC:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
await scheduler.schedule(
|
|
85
|
+
"report.weekday",
|
|
86
|
+
{},
|
|
87
|
+
{
|
|
88
|
+
cron: "30 9 * * 1-5",
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Example — every 15 minutes:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
await scheduler.schedule(
|
|
97
|
+
"sync.incremental",
|
|
98
|
+
{},
|
|
99
|
+
{
|
|
100
|
+
cron: "*/15 * * * *",
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Supported field syntax:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
* every allowed value
|
|
109
|
+
*/n step
|
|
110
|
+
1,5,10 list
|
|
111
|
+
1-5 range
|
|
112
|
+
1-10/2 range with step
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Ranges:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
minute 0-59
|
|
119
|
+
hour 0-23
|
|
120
|
+
day-of-month 1-31
|
|
121
|
+
month 1-12
|
|
122
|
+
day-of-week 0-7 (0 and 7 are Sunday)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Cron evaluation is **UTC** in `0.2.9`. This avoids machine-local timezone differences between containers and servers.
|
|
126
|
+
|
|
127
|
+
When both day-of-month and day-of-week are restricted, BCP follows conventional cron OR semantics: either field may match.
|
|
128
|
+
|
|
129
|
+
Use `nextCronTime()` when application tooling needs to preview the next UTC occurrence:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import {
|
|
133
|
+
nextCronTime,
|
|
134
|
+
} from "bcp/jobs";
|
|
135
|
+
|
|
136
|
+
const next =
|
|
137
|
+
nextCronTime(
|
|
138
|
+
"0 2 * * *",
|
|
139
|
+
Date.now()
|
|
140
|
+
);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Run due schedules manually
|
|
144
|
+
|
|
145
|
+
`runDue()` is useful for deterministic tests or externally driven schedulers:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const count =
|
|
149
|
+
await scheduler.runDue();
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The return value is the number of newly enqueued jobs.
|
|
153
|
+
|
|
154
|
+
Options:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
await scheduler.runDue({
|
|
158
|
+
limit: 100,
|
|
159
|
+
leaseMs: 30_000,
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Each call claims at most one occurrence per due schedule. If an application was offline for many intervals, later calls may progressively catch up.
|
|
164
|
+
|
|
165
|
+
## Start a scheduler runner
|
|
166
|
+
|
|
167
|
+
For a long-running Node.js process:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
const runner =
|
|
171
|
+
scheduler.start({
|
|
172
|
+
pollIntervalMs: 1_000,
|
|
173
|
+
leaseMs: 30_000,
|
|
174
|
+
limit: 100,
|
|
175
|
+
onError(error) {
|
|
176
|
+
console.error(
|
|
177
|
+
"scheduler failure",
|
|
178
|
+
error
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Graceful shutdown:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
await runner.stop();
|
|
188
|
+
await scheduler.close();
|
|
189
|
+
await jobs.close();
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Stopping a scheduler does not close the queue automatically because applications may run queue workers independently.
|
|
193
|
+
|
|
194
|
+
## Schedule records
|
|
195
|
+
|
|
196
|
+
`JobScheduleRecord` includes:
|
|
197
|
+
|
|
198
|
+
```text
|
|
199
|
+
id
|
|
200
|
+
jobName
|
|
201
|
+
payload
|
|
202
|
+
schedule
|
|
203
|
+
createdAt
|
|
204
|
+
updatedAt
|
|
205
|
+
nextRunAt
|
|
206
|
+
lastRunAt
|
|
207
|
+
maxAttempts
|
|
208
|
+
leaseOwner
|
|
209
|
+
leaseUntil
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Inspect schedules:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const schedules =
|
|
216
|
+
await scheduler.list();
|
|
217
|
+
|
|
218
|
+
const schedule =
|
|
219
|
+
await scheduler.get(
|
|
220
|
+
"cache-cleanup"
|
|
221
|
+
);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Remove one:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
await scheduler.remove(
|
|
228
|
+
"cache-cleanup"
|
|
229
|
+
);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Calling `schedule()` with an existing schedule id is an upsert. Use stable ids for application-owned recurring schedules.
|
|
233
|
+
|
|
234
|
+
## Retry policy
|
|
235
|
+
|
|
236
|
+
A schedule may forward a queue retry limit:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
await scheduler.schedule(
|
|
240
|
+
"billing.reconcile",
|
|
241
|
+
{},
|
|
242
|
+
{
|
|
243
|
+
cron: "0 * * * *",
|
|
244
|
+
maxAttempts: 5,
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Retry timing is still owned by `createJobQueue()`:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
const jobs =
|
|
253
|
+
createJobQueue({
|
|
254
|
+
retryDelayMs: attempt =>
|
|
255
|
+
attempt * 5_000,
|
|
256
|
+
});
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The scheduler determines **when a recurring occurrence is created**. The queue determines **how that occurrence is retried after handler failure**.
|
|
260
|
+
|
|
261
|
+
## Deterministic scheduled run IDs
|
|
262
|
+
|
|
263
|
+
Scheduled occurrences receive deterministic queue ids:
|
|
264
|
+
|
|
265
|
+
```text
|
|
266
|
+
schedule:<schedule-id>:<scheduled-for-timestamp>
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
This provides an additional duplicate-enqueue defense if a scheduler retries the same occurrence.
|
|
270
|
+
|
|
271
|
+
Applications should avoid manually creating queue job ids with the `schedule:` prefix.
|
|
272
|
+
|
|
273
|
+
## Schedule store contract
|
|
274
|
+
|
|
275
|
+
The built-in schedule store is process-local:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import {
|
|
279
|
+
createMemoryJobScheduleStore,
|
|
280
|
+
} from "bcp/jobs";
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
It is useful for development, tests and single-process applications.
|
|
284
|
+
|
|
285
|
+
Production deployments that run multiple scheduler processes or must preserve schedules across restarts should implement `JobScheduleStore` using shared durable infrastructure.
|
|
286
|
+
|
|
287
|
+
Required operations:
|
|
288
|
+
|
|
289
|
+
```text
|
|
290
|
+
upsert
|
|
291
|
+
get
|
|
292
|
+
list
|
|
293
|
+
remove
|
|
294
|
+
acquireDue
|
|
295
|
+
complete
|
|
296
|
+
release
|
|
297
|
+
close (optional)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`acquireDue()` is the critical concurrency boundary. A durable implementation should atomically claim due schedules and assign a lease owner/expiry so two scheduler instances do not both own the same occurrence.
|
|
301
|
+
|
|
302
|
+
Conceptually:
|
|
303
|
+
|
|
304
|
+
```text
|
|
305
|
+
scheduler A ─┐
|
|
306
|
+
├─ shared JobScheduleStore ── atomic acquireDue()
|
|
307
|
+
scheduler B ─┘
|
|
308
|
+
│
|
|
309
|
+
▼
|
|
310
|
+
shared/durable queue
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Examples of possible application adapters include PostgreSQL, Redis, DynamoDB or another transactional/atomic data store. `0.2.9` intentionally does not force one provider.
|
|
314
|
+
|
|
315
|
+
## Lease behavior
|
|
316
|
+
|
|
317
|
+
A scheduler runner identifies itself with `ownerId`:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
const scheduler =
|
|
321
|
+
createJobScheduler({
|
|
322
|
+
queue: jobs,
|
|
323
|
+
store,
|
|
324
|
+
ownerId:
|
|
325
|
+
process.env.INSTANCE_ID,
|
|
326
|
+
});
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
If `ownerId` is omitted, BCP generates a unique process-local scheduler id.
|
|
330
|
+
|
|
331
|
+
A due schedule is leased for `leaseMs`. The store releases the lease after the occurrence is committed. If a scheduler dies while holding a lease, another instance may reclaim it after lease expiry.
|
|
332
|
+
|
|
333
|
+
For durable adapters, choose a lease duration longer than the expected enqueue/store round trip but short enough for acceptable recovery.
|
|
334
|
+
|
|
335
|
+
## Queue durability remains separate
|
|
336
|
+
|
|
337
|
+
A durable schedule store does not make the queue durable by itself.
|
|
338
|
+
|
|
339
|
+
For multi-instance production you will normally need both:
|
|
340
|
+
|
|
341
|
+
```text
|
|
342
|
+
shared JobScheduleStore
|
|
343
|
+
+
|
|
344
|
+
shared JobQueueAdapter
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
The built-in memory schedule store and memory queue are intentionally process-local.
|
|
348
|
+
|
|
349
|
+
## Server-only boundary
|
|
350
|
+
|
|
351
|
+
`bcp/jobs` is server-only. Do not import it from page components, client islands or other modules reachable from browser bundles.
|
|
352
|
+
|
|
353
|
+
Use job/scheduler calls from server routes, loaders/actions, server startup modules or dedicated worker processes.
|
|
354
|
+
|
|
355
|
+
## Compatibility
|
|
356
|
+
|
|
357
|
+
`0.2.9` does not intentionally break `0.2.8` Background Jobs APIs. Existing queue/worker code remains valid; scheduling is additive.
|