@chidchanun/bcp 0.2.9 → 0.2.11

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.
@@ -66,30 +66,38 @@ Related guides: [Authentication](authentication.md), [Auth Session Stores](auth-
66
66
 
67
67
  ## `bcp/jobs`
68
68
 
69
- Server-only Background Jobs and Job Scheduling APIs.
69
+ Server-only Background Jobs, Job Scheduling and Durable Jobs APIs.
70
70
 
71
- Queue APIs:
71
+ Core queue APIs:
72
72
 
73
73
  ```ts
74
74
  import {
75
75
  createJobQueue,
76
76
  createMemoryJobQueueAdapter,
77
77
  type BackgroundJobQueue,
78
+ type CleanupJobsOptions,
79
+ type DeadLetterJobRecord,
78
80
  type EnqueueJobOptions,
81
+ type HeartbeatJobOptions,
79
82
  type JobHandler,
80
83
  type JobHandlerContext,
81
84
  type JobQueueAdapter,
82
85
  type JobQueueOptions,
86
+ type JobQueueStats,
83
87
  type JobRecord,
84
88
  type JobRetryDelay,
85
89
  type JobState,
86
90
  type JobWorker,
87
91
  type MemoryJobQueueAdapter,
92
+ type ProcessNextJobOptions,
93
+ type RecoverStaleJobsOptions,
94
+ type RequeueDeadLetterOptions,
95
+ type ReserveJobOptions,
88
96
  type StartJobWorkerOptions,
89
97
  } from "bcp/jobs";
90
98
  ```
91
99
 
92
- Scheduling APIs added in `0.2.9`:
100
+ Scheduling APIs:
93
101
 
94
102
  ```ts
95
103
  import {
@@ -110,15 +118,112 @@ import {
110
118
  } from "bcp/jobs";
111
119
  ```
112
120
 
113
- `createJobQueue()` provides delayed enqueueing, retry/backoff, cancellation, manual `processNext()` execution and concurrent workers.
121
+ Durable Redis-compatible APIs added in `0.2.10`:
114
122
 
115
- `createJobScheduler()` adds recurring interval and UTC five-field cron schedules. The scheduler leases due records from `JobScheduleStore`, then enqueues normal queue jobs using deterministic occurrence ids.
123
+ ```ts
124
+ import {
125
+ createRedisJobQueueAdapter,
126
+ createRedisJobScheduleStore,
127
+ type RedisCommandClient,
128
+ type RedisJobQueueAdapter,
129
+ type RedisJobsAdapterOptions,
130
+ type RedisJobScheduleStore,
131
+ } from "bcp/jobs";
132
+ ```
133
+
134
+ ### Queue lifecycle
135
+
136
+ `createJobQueue()` supports immediate/delayed enqueue, retry/backoff, cancellation, manual `processNext()`, worker concurrency, visibility timeout, heartbeat lease renewal, stale-running recovery, DLQ inspection/requeue, terminal retention cleanup and queue statistics.
137
+
138
+ Durable adapter methods such as `heartbeat()`, `recoverStale()`, `listDeadLetters()`, `requeueDeadLetter()`, `cleanup()` and `stats()` are optional so earlier `JobQueueAdapter` implementations remain compatible.
139
+
140
+ `reserve()` receives an optional second `ReserveJobOptions` argument containing `ownerId` and `visibilityTimeoutMs`. Production durable adapters should use it to create an atomic visibility lease.
141
+
142
+ ### Scheduling
143
+
144
+ `createJobScheduler()` supports recurring interval and UTC five-field cron schedules.
145
+
146
+ The scheduler leases due records from `JobScheduleStore`, then enqueues normal jobs using deterministic occurrence IDs. `nextCronTime()` and `nextScheduleTime()` are available for tooling and tests.
147
+
148
+ ### Redis-compatible durable adapters
149
+
150
+ `createRedisJobQueueAdapter()` implements the queue contract on a shared Redis-compatible command client.
151
+
152
+ `createRedisJobScheduleStore()` implements the schedule-store contract using the same minimal client shape:
153
+
154
+ ```ts
155
+ interface RedisCommandClient {
156
+ sendCommand(
157
+ command: string[]
158
+ ): Promise<unknown>;
159
+ }
160
+ ```
161
+
162
+ BCP does not install a Redis library and does not create the connection. Applications own authentication, TLS, Cluster/Sentinel configuration and connection shutdown.
163
+
164
+ The reference adapters use atomic Lua state transitions. The default namespace `bcp:{jobs}` uses a Redis Cluster hash tag so all job/schedule keys share one slot.
165
+
166
+ The processing model is at-least-once. Handlers that perform non-idempotent external side effects should use application-level idempotency protection.
167
+
168
+ Related guides: [Background Jobs Platform](background-jobs.md), [Job Scheduling Platform](job-scheduling.md), [Durable Jobs Platform](durable-jobs.md), [Observability Platform v2](observability.md).
169
+
170
+ ## `bcp/workflow`
171
+
172
+ Server-only Workflow Orchestration APIs added in `0.2.11`.
173
+
174
+ ```ts
175
+ import {
176
+ createMemoryWorkflowStore,
177
+ createWorkflow,
178
+ type CancelWorkflowOptions,
179
+ type MemoryWorkflowStore,
180
+ type ResumeWorkflowOptions,
181
+ type StartWorkflowOptions,
182
+ type Workflow,
183
+ type WorkflowBuilder,
184
+ type WorkflowCompensationHandler,
185
+ type WorkflowOptions,
186
+ type WorkflowParallelBuilder,
187
+ type WorkflowRetryDelay,
188
+ type WorkflowRunRecord,
189
+ type WorkflowRunState,
190
+ type WorkflowStepContext,
191
+ type WorkflowStepHandler,
192
+ type WorkflowStepKind,
193
+ type WorkflowStepOptions,
194
+ type WorkflowStepRecord,
195
+ type WorkflowStepState,
196
+ type WorkflowStore,
197
+ } from "bcp/workflow";
198
+ ```
199
+
200
+ `createWorkflow()` defines a persistent server-side workflow with sequential steps, parallel groups, per-step retry policies, persisted delays and compensation handlers.
201
+
202
+ Workflow controls include:
203
+
204
+ ```text
205
+ start()
206
+ run()
207
+ get()
208
+ list()
209
+ resume()
210
+ retry()
211
+ cancel()
212
+ compensate()
213
+ close()
214
+ ```
215
+
216
+ Without a queue, workflow execution runs in the current process until it succeeds, fails, is cancelled or reaches a persisted delay.
217
+
218
+ When an existing `BackgroundJobQueue` is passed through `WorkflowOptions.queue`, workflow execution is submitted through `bcp/jobs`. Delay steps enqueue delayed continuation jobs instead of holding a long-running timer.
219
+
220
+ `WorkflowStore` is the workflow persistence boundary. Its `claim()` / `release()` methods form the run-level lease contract for shared multi-instance stores.
116
221
 
117
- The built-in queue adapter and schedule store are process-local. Durable multi-instance deployments should implement both `JobQueueAdapter` and `JobScheduleStore` against shared infrastructure. `JobQueueAdapter.reserve()` and `JobScheduleStore.acquireDue()` are the respective atomic concurrency boundaries.
222
+ `createMemoryWorkflowStore()` is intended for development and deterministic tests. Production multi-instance applications should implement a shared durable store with atomic `claim()` behavior.
118
223
 
119
- Cron evaluation in `0.2.9` uses UTC. `nextCronTime()` and `nextScheduleTime()` are available for tooling and tests.
224
+ Compensation is saga-style and executes successful compensatable steps in reverse completion order. It does not turn external services into one distributed database transaction.
120
225
 
121
- Related guides: [Background Jobs Platform](background-jobs.md), [Job Scheduling Platform](job-scheduling.md), [Observability Platform v2](observability.md).
226
+ Related guides: [Workflow Orchestration](workflow-orchestration.md), [Durable Jobs Platform](durable-jobs.md), [Observability Platform v2](observability.md).
122
227
 
123
228
  ## `bcp/observability`
124
229
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "framework": "bcp",
4
- "versionTarget": "0.2.9",
4
+ "versionTarget": "0.2.11",
5
5
  "releaseState": "unreleased",
6
6
  "sections": [
7
7
  {
@@ -57,7 +57,7 @@
57
57
  {
58
58
  "id": "runtime",
59
59
  "title": "Runtime & Infrastructure",
60
- "description": "Middleware, background jobs, scheduling, observability, logging, caching, security and production hardening.",
60
+ "description": "Middleware, durable background jobs, scheduling, workflow orchestration, observability, logging, caching, security and production hardening.",
61
61
  "pages": [
62
62
  { "route": "/docs/middleware", "source": "middleware.md", "title": "Middleware" },
63
63
  { "route": "/docs/hydration", "source": "hydration.md", "title": "Hydration" },
@@ -65,6 +65,8 @@
65
65
  { "route": "/docs/observability", "source": "observability.md", "title": "Observability Platform v2" },
66
66
  { "route": "/docs/background-jobs", "source": "background-jobs.md", "title": "Background Jobs Platform" },
67
67
  { "route": "/docs/job-scheduling", "source": "job-scheduling.md", "title": "Job Scheduling Platform" },
68
+ { "route": "/docs/durable-jobs", "source": "durable-jobs.md", "title": "Durable Jobs Platform" },
69
+ { "route": "/docs/workflow-orchestration", "source": "workflow-orchestration.md", "title": "Workflow Orchestration" },
68
70
  { "route": "/docs/caching", "source": "caching.md", "title": "Caching" },
69
71
  { "route": "/docs/security", "source": "security.md", "title": "Security" },
70
72
  { "route": "/docs/production-hardening", "source": "production-hardening.md", "title": "Production Hardening" }
@@ -110,7 +112,9 @@
110
112
  }
111
113
  ],
112
114
  "releases": [
113
- { "route": "/releases/0.2.9", "source": "releases/0.2.9.md", "version": "0.2.9", "state": "unreleased" },
115
+ { "route": "/releases/0.2.11", "source": "releases/0.2.11.md", "version": "0.2.11", "state": "unreleased" },
116
+ { "route": "/releases/0.2.10", "source": "releases/0.2.10.md", "version": "0.2.10" },
117
+ { "route": "/releases/0.2.9", "source": "releases/0.2.9.md", "version": "0.2.9" },
114
118
  { "route": "/releases/0.2.8", "source": "releases/0.2.8.md", "version": "0.2.8" },
115
119
  { "route": "/releases/0.2.7", "source": "releases/0.2.7.md", "version": "0.2.7" },
116
120
  { "route": "/releases/0.2.6", "source": "releases/0.2.6.md", "version": "0.2.6" },
@@ -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)
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "framework": "bcp",
4
- "version": "0.2.9",
4
+ "version": "0.2.11",
5
5
  "releaseState": "unreleased",
6
- "baseline": "job-scheduling-platform",
6
+ "baseline": "workflow-orchestration",
7
7
  "runtime": {
8
8
  "node": ">=24.11.0",
9
9
  "react": "19",
@@ -20,6 +20,7 @@
20
20
  "bcp/database",
21
21
  "bcp/auth",
22
22
  "bcp/jobs",
23
+ "bcp/workflow",
23
24
  "bcp/observability",
24
25
  "bcp/server",
25
26
  "bcp/server-only",
@@ -80,6 +81,25 @@
80
81
  "jobScheduleStoreContract": true,
81
82
  "schedulerLeases": true,
82
83
  "scheduledRunDeduplication": true,
84
+ "durableJobsPlatform": true,
85
+ "redisJobQueueAdapter": true,
86
+ "redisJobScheduleStore": true,
87
+ "jobVisibilityTimeout": true,
88
+ "jobHeartbeats": true,
89
+ "staleJobRecovery": true,
90
+ "deadLetterQueue": true,
91
+ "deadLetterRequeue": true,
92
+ "jobRetentionCleanup": true,
93
+ "jobQueueStatistics": true,
94
+ "workflowOrchestration": true,
95
+ "workflowStoreContract": true,
96
+ "workflowRunLeases": true,
97
+ "workflowSequentialSteps": true,
98
+ "workflowParallelSteps": true,
99
+ "workflowStepRetries": true,
100
+ "workflowDelays": true,
101
+ "workflowCompensation": true,
102
+ "workflowQueueExecution": true,
83
103
  "databaseMigrations": true,
84
104
  "databaseAdapterContract": true,
85
105
  "databasePostgresql": true,
@@ -119,7 +139,7 @@
119
139
  "s3-compatible"
120
140
  ],
121
141
  "compatibility": {
122
- "previousBaseline": "0.2.8",
142
+ "previousBaseline": "0.2.10",
123
143
  "intentionalBreakingChangesFromPreviousBaseline": false,
124
144
  "migrationGuide": "migration-0.2.md"
125
145
  },
@@ -138,7 +158,9 @@
138
158
  "observability": "observability.md",
139
159
  "backgroundJobs": "background-jobs.md",
140
160
  "jobScheduling": "job-scheduling.md",
161
+ "durableJobs": "durable-jobs.md",
162
+ "workflowOrchestration": "workflow-orchestration.md",
141
163
  "migrationGuide": "migration-0.2.md",
142
- "releaseNotes": "releases/0.2.9.md"
164
+ "releaseNotes": "releases/0.2.11.md"
143
165
  }
144
166
  }