@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.
@@ -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.