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 +124 -266
- package/dist/index.d.mts +315 -625
- package/dist/index.d.ts +315 -625
- package/dist/index.js +2781 -2644
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2789 -2607
- package/dist/index.mjs.map +1 -1
- package/package.json +15 -11
- package/LICENSE +0 -21
package/README.md
CHANGED
|
@@ -1,334 +1,192 @@
|
|
|
1
1
|
# ravenflow
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
+
Requires Node.js 20.19+, Redis and MongoDB. For development:
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
##
|
|
56
|
-
|
|
57
|
-
### Simple Job
|
|
25
|
+
## Create and run a job
|
|
58
26
|
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
handler: async (
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
60
|
+
await client.jobs.sendWelcomeEmail.send({ to: 'ada@example.com', name: 'Ada' });
|
|
61
|
+
```
|
|
154
62
|
|
|
155
|
-
|
|
63
|
+
**3. Run it** in a worker. A worker serves one queue.
|
|
156
64
|
|
|
157
65
|
```ts
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
77
|
+
await worker.start();
|
|
78
|
+
process.once('SIGTERM', () => void worker.close());
|
|
79
|
+
```
|
|
178
80
|
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
+
Without a trigger, a client sends the job. A trigger feeds it for you.
|
|
201
89
|
|
|
202
|
-
###
|
|
90
|
+
### A clock
|
|
203
91
|
|
|
204
92
|
```ts
|
|
205
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
### A spool
|
|
229
106
|
|
|
230
107
|
```ts
|
|
231
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
274
|
-
await app.jobs.syncUsers.send();
|
|
128
|
+
## Advanced
|
|
275
129
|
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|