ravenflow 0.3.0 → 0.4.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/README.md CHANGED
@@ -1,334 +1,192 @@
1
1
  # ravenflow
2
2
 
3
- Type-safe job queue framework for TypeScript, built on [BullMQ](https://docs.bullmq.io/) and [Redis](https://redis.io/).
3
+ Typed background jobs for Node.js, on [BullMQ](https://docs.bullmq.io). Redis holds the work in
4
+ flight; MongoDB holds the backlogs and the history.
4
5
 
5
- Define jobs as plain functions, ravenflow handles scheduling, retries, rate limiting, distributed processing, and crash recovery.
6
+ - **Types end to end.** A send is checked against the job's declaration.
7
+ - **A client and a worker.** Your API sends work, a worker process runs it, and both share one config.
8
+ - **Two triggers.** A clock, and a spool: a backlog in MongoDB drained at the job's pace.
9
+ - **What production asks for.** Chains, deduplication, retries, a rate held across the fleet, levers
10
+ to pause a job, and a record of every execution.
6
11
 
7
12
  ## Install
8
13
 
9
14
  ```bash
10
15
  npm install ravenflow
11
- # or
12
- pnpm add ravenflow
13
16
  ```
14
17
 
15
- **Requirements:** Redis 6+, Node.js 18+. MongoDB required for stateful-etl and token-bucket jobs.
18
+ Requires Node.js 20.19+, Redis and MongoDB. For development:
16
19
 
17
- ## Quick Start
18
-
19
- ```ts
20
- import {
21
- createRavenflowApp,
22
- defineConfig,
23
- defineSimpleJob,
24
- } from "ravenflow";
25
-
26
- // 1. Define your jobs
27
- const config = defineConfig({
28
- defaultQueue: "default",
29
- jobs: {
30
- sendEmail: defineSimpleJob({
31
- handler: async (job, params: { to: string; subject: string }) => {
32
- await sendEmail(params.to, params.subject);
33
- return { sent: true };
34
- },
35
- }),
36
- },
37
- });
38
-
39
- // 2. Create the app
40
- const app = createRavenflowApp(config, {
41
- redis: { host: "localhost", port: 6379 },
42
- });
43
-
44
- // 3. Start the worker
45
- await app.startWorker("default", { concurrency: 10 });
46
-
47
- // 4. Send jobs
48
- await app.jobs.sendEmail.send({ to: "user@example.com", subject: "Hello" });
49
-
50
- // Or send and wait for the result
51
- const result = await app.jobs.sendEmail.sendAndWait({ to: "user@example.com", subject: "Hello" });
52
- console.log(result); // { sent: true }
20
+ ```bash
21
+ docker run -d -p 6379:6379 redis:7
22
+ docker run -d -p 27017:27017 mongo:7
53
23
  ```
54
24
 
55
- ## Job Types
56
-
57
- ### Simple Job
25
+ ## Create and run a job
58
26
 
59
- One-shot function execution. Ideal for notifications, cleanup tasks, webhooks.
27
+ **1. Declare it.** A job is a handler from a typed payload to a typed result. The config registers
28
+ it under a name.
60
29
 
61
30
  ```ts
62
- import { defineSimpleJob } from "ravenflow";
63
-
64
- const sendEmail = defineSimpleJob({
65
- cron: "*/5 * * * *", // optional: run every 5 minutes
66
- handler: async (job, params: { to: string; text: string }) => {
67
- const result = await emailService.send(params.to, params.text);
68
- return { messageId: result.id };
31
+ // config.ts
32
+ import { defineConfig, defineJob } from 'ravenflow';
33
+
34
+ export const sendWelcomeEmail = defineJob<{ to: string; name: string }, { messageId: string }>({
35
+ handler: async ({ params, logger }) => {
36
+ logger.info({ to: params.to }, 'sending the welcome email');
37
+ const { id } = await emailTransport.send({ to: params.to, subject: `Welcome, ${params.name}` });
38
+ return { messageId: id };
69
39
  },
70
40
  });
71
- ```
72
-
73
- ### Memory ETL
74
-
75
- Extract-Transform-Load pipeline that runs entirely in-memory. Cursor-based pagination, parallel transforms with rate limiting.
76
-
77
- ```ts
78
- import { defineMemoryEtlJob } from "ravenflow";
79
-
80
- const syncUsers = defineMemoryEtlJob({
81
- cron: "0 2 * * *", // daily at 2 AM
82
-
83
- extract: async (cursor: number | null) => {
84
- const offset = cursor ?? 0;
85
- const users = await api.getUsers({ offset, limit: 100 });
86
- return {
87
- data: users,
88
- cursor: offset + 100,
89
- hasMore: users.length === 100,
90
- };
91
- },
92
-
93
- transform: async (user) => ({
94
- id: user.id,
95
- email: user.email,
96
- normalizedName: user.name.trim().toLowerCase(),
97
- }),
98
41
 
99
- load: async (users) => {
100
- await db.users.insertMany(users);
101
- },
102
-
103
- steps: {
104
- transform: {
105
- rateLimit: { perSecond: 50 }, // even spacing: 1 every 20ms
106
- },
107
- },
42
+ export const config = defineConfig({
43
+ defaultQueue: 'default',
44
+ jobs: { sendWelcomeEmail },
108
45
  });
109
46
  ```
110
47
 
111
- ### Distributed ETL
112
-
113
- Same extract/transform/load pattern, but transforms are dispatched as independent BullMQ sub-jobs for parallel processing across workers.
48
+ **2. Send it** from any process: your API, a CLI, another job.
114
49
 
115
50
  ```ts
116
- import { defineDistributedEtlJob } from "ravenflow";
117
-
118
- const processOrders = defineDistributedEtlJob({
119
- cron: "*/10 * * * *",
120
-
121
- extract: async (cursor: number | null) => {
122
- const orders = await db.orders
123
- .find({ status: "pending" })
124
- .skip(cursor ?? 0)
125
- .limit(50)
126
- .toArray();
127
- return {
128
- data: orders,
129
- cursor: (cursor ?? 0) + 50,
130
- hasMore: orders.length === 50,
131
- };
132
- },
133
-
134
- transform: async (order) => {
135
- const enriched = await paymentGateway.verify(order.paymentId);
136
- return { ...order, verified: enriched.status === "ok" };
137
- },
51
+ // client.ts
52
+ import { createRavenflowClient } from 'ravenflow';
53
+ import { config } from './config';
138
54
 
139
- load: async (orders) => {
140
- await db.processedOrders.insertMany(orders);
141
- },
142
-
143
- steps: {
144
- transform: {
145
- rateLimit: { perSecond: 10 },
146
- attempts: 3,
147
- backoff: { type: "exponential", delay: 1000 },
148
- },
149
- },
55
+ const client = createRavenflowClient(config, {
56
+ redis: { host: 'localhost', port: 6379 },
57
+ mongo: 'mongodb://localhost:27017/myapp',
150
58
  });
151
- ```
152
59
 
153
- ### Stateful ETL
60
+ await client.jobs.sendWelcomeEmail.send({ to: 'ada@example.com', name: 'Ada' });
61
+ ```
154
62
 
155
- Distributed ETL with MongoDB-backed checkpoints and automatic crash recovery. If a worker dies mid-processing, the Stall Watcher detects it and resumes from the last checkpoint.
63
+ **3. Run it** in a worker. A worker serves one queue.
156
64
 
157
65
  ```ts
158
- import { defineStatefulEtlJob } from "ravenflow";
159
-
160
- const migrateLegacyData = defineStatefulEtlJob({
161
- cron: "0 3 * * *",
162
- singletonRun: true, // only one run at a time
163
- stallTimeoutMs: 60_000, // detect stall after 60s without heartbeat
164
- maxStallRetries: 3,
165
-
166
- extract: async (cursor: string | null) => {
167
- const page = await legacyApi.fetch({ after: cursor, limit: 100 });
168
- return {
169
- data: page.items,
170
- cursor: page.nextCursor,
171
- hasMore: page.hasMore,
172
- };
173
- },
66
+ // worker.ts
67
+ import { createRavenflowWorker } from 'ravenflow';
68
+ import { config } from './config';
69
+
70
+ const worker = createRavenflowWorker(config, {
71
+ redis: { host: 'localhost', port: 6379 },
72
+ mongo: 'mongodb://localhost:27017/myapp',
73
+ queue: 'default',
74
+ concurrency: 10,
75
+ });
174
76
 
175
- transform: async (item) => {
176
- return mapToNewSchema(item);
177
- },
77
+ await worker.start();
78
+ process.once('SIGTERM', () => void worker.close());
79
+ ```
178
80
 
179
- load: async (items) => {
180
- await newDb.collection("records").insertMany(items);
181
- },
81
+ The handler receives `{ params, logger, signal, job, continueWith, deduplicationId }`. What it
82
+ returns is the job's result; what it throws fails the job.
182
83
 
183
- steps: {
184
- transform: {
185
- rateLimit: { perSecond: 5 },
186
- attempts: 3,
187
- backoff: { type: "exponential", delay: 2000 },
188
- },
189
- },
190
- });
191
- ```
84
+ A send takes options: `{ delayMs, priority, deduplicationId }`.
192
85
 
193
- **How crash recovery works:**
194
- 1. During processing, the worker sends heartbeats to MongoDB
195
- 2. A `__stall_check` cron job monitors all active runs
196
- 3. If a heartbeat goes stale (exceeds `stallTimeoutMs`), the run is claimed as "stalled"
197
- 4. A `__stall_resume` job re-enqueues the original job with the last checkpoint
198
- 5. Processing resumes from where it left off (no duplicate work)
86
+ ## Triggers
199
87
 
200
- ## Configuration
88
+ Without a trigger, a client sends the job. A trigger feeds it for you.
201
89
 
202
- ### App Options
90
+ ### A clock
203
91
 
204
92
  ```ts
205
- const app = createRavenflowApp(config, {
206
- // Required: Redis connection
207
- redis: { host: "localhost", port: 6379, password: "secret", db: 0 },
208
-
209
- // Optional: MongoDB (required for stateful-etl and token-bucket)
210
- // String form:
211
- mongo: "mongodb://localhost:27017/myapp",
212
- // Or object form:
213
- mongo: { host: "localhost", port: 27017, username: "admin", password: "secret", database: "myapp" },
214
-
215
- // Optional: job retention policy
216
- retention: {
217
- completed: 1000, // keep last 1000 completed jobs per queue
218
- failed: 5000, // keep last 5000 failed jobs per queue
219
- },
93
+ import { defineJob } from 'ravenflow';
220
94
 
221
- // Optional: logger config (pino)
222
- logger: { level: "info", pretty: true },
95
+ export const cleanupTempFiles = defineJob<void, { removed: number }>({
96
+ trigger: { cron: '0 3 * * *' },
97
+ handler: async () => ({ removed: await purgeTempDir() }),
223
98
  });
224
99
  ```
225
100
 
226
- ### Rate Limiting
101
+ One occurrence at a time across the fleet. A worker registers the schedules of its queue's jobs
102
+ when it starts. A clock has nothing to pass, so the payload must be `void` or all-optional: anything
103
+ else does not compile.
227
104
 
228
- Even-spacing rate limiter. `perSecond: 10` means 1 request every 100ms (not a burst of 10 then wait).
105
+ ### A spool
229
106
 
230
107
  ```ts
231
- steps: {
232
- extract: { rateLimit: { perMinute: 60 } }, // 1 per second
233
- transform: { rateLimit: { perSecond: 20 } }, // 1 every 50ms
234
- load: { rateLimit: { perSecond: 5 } }, // 1 every 200ms
235
- }
236
- ```
237
-
238
- ### Step Config (Distributed & Stateful ETL)
108
+ import { defineJob } from 'ravenflow';
239
109
 
240
- Each step can have its own retry strategy, rate limit, and target queue:
241
-
242
- ```ts
243
- steps: {
244
- transform: {
245
- rateLimit: { perSecond: 10 },
246
- attempts: 3,
247
- backoff: { type: "exponential", delay: 1000 },
248
- queue: "heavy-transforms", // route to a different queue
249
- },
250
- }
251
- ```
252
-
253
- ### Cron Scheduling
254
-
255
- Jobs with a `cron` field are automatically scheduled when the worker starts:
256
-
257
- ```ts
258
- defineSimpleJob({
259
- cron: "*/5 * * * *", // every 5 minutes
260
- handler: async () => { /* ... */ },
110
+ export const enrichDocument = defineJob<PendingDocument, void>({
111
+ pace: { perSecond: 10 },
112
+ trigger: { spool: { name: 'pending_documents' } },
113
+ handler: async ({ params }) => enrich(params),
261
114
  });
262
115
  ```
263
116
 
264
- ## Sending Jobs
265
-
266
117
  ```ts
267
- // Fire and forget — returns BullMQ Job object
268
- const job = await app.jobs.sendEmail.send({ to: "user@example.com", subject: "Hi" });
118
+ await client.jobs.enrichDocument.pushMany([
119
+ { url: 'https://example.com/a', tenantId: 'acme' },
120
+ { url: 'https://example.com/b', tenantId: 'globex' },
121
+ ]);
122
+ ```
269
123
 
270
- // Wait for the result
271
- const result = await app.jobs.sendEmail.sendAndWait({ to: "user@example.com", subject: "Hi" });
124
+ Items wait in MongoDB (`rav:spool-backlog:<name>`). Every 30 seconds, the worker serving the job's
125
+ queue releases at most one tick's work at the job's pace into Redis, and an item leaves the
126
+ backlog once its run ends. Options: `tickMs`, `canPull`, `expireUnclaimedAfterMs`.
272
127
 
273
- // ETL jobs don't take parameters
274
- await app.jobs.syncUsers.send();
128
+ ## Advanced
275
129
 
276
- // ETL sendAndWait returns counts
277
- const counts = await app.jobs.syncUsers.sendAndWait();
278
- // { extractedCount: 500, transformedCount: 500, loadedCount: 500 }
279
- ```
280
-
281
- ## Worker
130
+ **Chains.** A handler that returns `continueWith(nextParams)` ends its link and queues the next one,
131
+ each link a job of its own with its own retries.
282
132
 
283
133
  ```ts
284
- // Start processing jobs from a queue
285
- await app.startWorker("default", { concurrency: 20 });
286
-
287
- // Graceful shutdown
288
- await app.close();
134
+ import { defineJob } from 'ravenflow';
135
+
136
+ export const syncCatalog = defineJob<{ cursor?: string; synced?: number }, { synced: number }>({
137
+ trigger: { cron: '0 2 * * *' },
138
+ handler: async ({ params, continueWith }) => {
139
+ const page = await api.fetchPage(params.cursor);
140
+ await db.products.upsertMany(page.items);
141
+ const synced = (params.synced ?? 0) + page.items.length;
142
+ if (page.next) return continueWith({ cursor: page.next, synced });
143
+ return { synced };
144
+ },
145
+ });
289
146
  ```
290
147
 
291
- When `startWorker` is called:
292
- - Cron schedules are synced to Redis
293
- - The Stall Watcher starts automatically if stateful-etl jobs are registered
294
- - The worker begins processing jobs
148
+ **Deduplication.** `deduplicate: true` keeps one run of the job in flight at a time,
149
+ `deduplicate: (params) => id` one per id, and `send(params, { deduplicationId })` chooses for one
150
+ send. A duplicate is ignored, and `send` resolves with the job already holding the id.
295
151
 
296
- ## Config Validation
152
+ **Retries.** `attempts: 5, backoff: { type: 'exponential', delayMs: 1_000 }`. A handler runs at
153
+ least once, not exactly once: make it safe to run twice.
297
154
 
298
- Ravenflow validates your config at startup and throws clear errors:
155
+ **Pace.** `pace: { perSecond: 2 }` holds a rate for the whole job, across every worker process.
156
+ A job that comes too early waits its turn without spending an attempt.
299
157
 
300
- ```
301
- [ravenflow] Config validation failed:
302
- - "migrateLegacy" (stateful-etl) requires a mongo connection.
303
- - "rateLimiter" (token-bucket) requires a cron schedule.
304
- ```
158
+ **Levers.** Every job has `client.jobs.<name>.controls`:
305
159
 
306
- ## Companion Packages
160
+ | Lever | On | Effect |
161
+ | --- | --- | --- |
162
+ | `pauseExecution()` / `resumeExecution()` | every job | Jobs are deferred, not lost, and no attempt is spent. |
163
+ | `pauseTrigger()` / `resumeTrigger()` | a clock or a spool | Occurrences are skipped; a spool stops draining. |
164
+ | `pauseIngest()` / `resumeIngest()` | a spool | `push` throws a `SpoolIngestPausedError`. |
165
+ | `purgeBacklog()` | a spool | Drops the items never claimed. |
307
166
 
308
- - [`@ravenflow/mongo`](https://www.npmjs.com/package/@ravenflow/mongo) — Typed MongoDB collections, base repository, Zod schema integration
309
- - [`@ravenflow/http`](https://www.npmjs.com/package/@ravenflow/http) — HTTP client utilities built on undici
167
+ **Job results.** A worker writes one record per execution to MongoDB (`rav:job-results`): the
168
+ params, the result or the error and its causes, what the handler logged, how long it ran. Kept 14
169
+ days, and counted per minute into `rav:aggregated-results`.
310
170
 
311
- ## Worker metrics (pulse / throughput observability)
171
+ **Withdrawal.** A worker that cannot reach MongoDB for 30 seconds closes itself, sets
172
+ `process.exitCode` to 1 and calls `withdrawal.onWithdraw(reason)`. Let your process manager restart
173
+ it.
312
174
 
313
- `createWorker` enables BullMQ's native per-minute metrics collection by default. Every Worker constructed through ravenflow records a monotonic completed-job count + a 10_080-bucket rolling minute histogram under the `bull:<queue>:metrics:completed` Redis keys. The `@ravenflow/dashboard` Jobs-per-Second live indicator reads those keys via `queue.getMetrics('completed')`.
314
-
315
- ```ts
316
- // Default — metrics enabled, 7-day window (~10-40 KB per queue in Redis).
317
- const worker = createWorker(queueName, connection, concurrency, processJob);
318
-
319
- // Narrow the window to 1 hour.
320
- import { METRICS_TIME } from "ravenflow";
321
- const worker = createWorker(queueName, connection, concurrency, processJob, {
322
- metrics: { maxDataPoints: METRICS_TIME.ONE_HOUR },
323
- });
324
-
325
- // Fully opt out — no metrics collection, no Redis footprint.
326
- const worker = createWorker(queueName, connection, concurrency, processJob, {
327
- metrics: undefined,
328
- });
329
- ```
175
+ ## Configuration
330
176
 
331
- See `CHANGELOG.md` for the rationale and Redis cost breakdown.
177
+ | `defineConfig` | Default |
178
+ | --- | --- |
179
+ | `defaultQueue`, `jobs` | required |
180
+ | `logger: { level, pretty }` | `debug` and readable; `info` and JSON with `NODE_ENV=production` |
181
+ | `queuesRetention: { completedCount, failedCount }` | 1000 each |
182
+ | `jobResultsRetentionHours` | 336 |
183
+ | `aggregatedResultsRetentionHours` | 2160 |
184
+
185
+ | `createRavenflowWorker` | Default |
186
+ | --- | --- |
187
+ | `redis`, `mongo`, `queue` | required |
188
+ | `concurrency` | 5 |
189
+ | `withdrawal: { afterMs, onWithdraw }` | 30 000 ms |
332
190
 
333
191
  ## License
334
192