ravenflow 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -1,57 +1,25 @@
1
1
  'use strict';
2
2
 
3
- var bullmq = require('bullmq');
4
3
  var pino = require('pino');
4
+ var bullmq = require('bullmq');
5
+ var zod = require('zod');
6
+ var mongodb = require('mongodb');
5
7
 
6
8
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
7
9
 
8
10
  var pino__default = /*#__PURE__*/_interopDefault(pino);
9
11
 
10
- // src/app.ts
11
-
12
- // src/processor.ts
13
- async function processEtlJob(job, etlConfig, transformQueue, transformQueueEvents) {
14
- let currentPage = 1;
15
- let run = true;
16
- const metadata = {
17
- count: 0,
18
- pages: 0,
19
- currentPage,
20
- startedAt: /* @__PURE__ */ new Date(),
21
- endedAt: void 0
22
- };
23
- const pagination = {
24
- maxPages: Infinity};
25
- while (run && currentPage <= (pagination.maxPages || Infinity)) {
26
- metadata.currentPage = currentPage;
27
- metadata.pages = currentPage;
28
- const {
29
- data: rawElements,
30
- hasNextPage: nextPageExists = false
31
- } = await etlConfig.extract(currentPage);
32
- const processedElements = [];
33
- for (const element of rawElements) {
34
- const transformJobName = `${job.name}:transform`;
35
- const transformJob = await transformQueue.add(transformJobName, element, {
36
- attempts: etlConfig.attempts,
37
- backoff: etlConfig.backoff
38
- });
39
- const processedElement = await transformJob.waitUntilFinished(transformQueueEvents);
40
- processedElements.push(processedElement);
41
- }
42
- await etlConfig.load(processedElements);
43
- metadata.count += processedElements.length;
44
- run = nextPageExists;
45
- if (run) {
46
- currentPage++;
47
- }
48
- }
49
- return metadata;
50
- }
51
- function createLogger(options = {}) {
12
+ var __defProp = Object.defineProperty;
13
+ var __export = (target, all) => {
14
+ for (var name in all)
15
+ __defProp(target, name, { get: all[name], enumerable: true });
16
+ };
17
+ var logger = null;
18
+ function initLogger(options = {}) {
19
+ if (logger) return logger;
52
20
  const isDev = process.env.NODE_ENV !== "production";
53
21
  const usePretty = options.pretty ?? isDev;
54
- return pino__default.default({
22
+ logger = pino__default.default({
55
23
  level: options.level || (isDev ? "debug" : "info"),
56
24
  transport: usePretty ? {
57
25
  target: "pino-pretty",
@@ -62,6 +30,983 @@ function createLogger(options = {}) {
62
30
  }
63
31
  } : void 0
64
32
  });
33
+ return logger;
34
+ }
35
+ function getLogger() {
36
+ if (!logger) {
37
+ throw new Error("[ravenflow] Logger not initialized. Call initLogger() first.");
38
+ }
39
+ return logger;
40
+ }
41
+ function buildDefaultJobOptions(retention) {
42
+ if (!retention) return void 0;
43
+ const opts = {};
44
+ if (retention.completed !== void 0) {
45
+ opts.removeOnComplete = { count: retention.completed };
46
+ }
47
+ if (retention.failed !== void 0) {
48
+ opts.removeOnFail = { count: retention.failed };
49
+ }
50
+ return Object.keys(opts).length > 0 ? opts : void 0;
51
+ }
52
+ function initQueues(config, connection, retention) {
53
+ const queueNames = /* @__PURE__ */ new Set([config.defaultQueue]);
54
+ for (const job of Object.values(config.jobs)) {
55
+ if (job.queue) queueNames.add(job.queue);
56
+ }
57
+ const defaultJobOptions = buildDefaultJobOptions(retention);
58
+ const queues = {};
59
+ for (const name of queueNames) {
60
+ queues[name] = {
61
+ queue: new bullmq.Queue(name, { connection, defaultJobOptions }),
62
+ events: new bullmq.QueueEvents(name, { connection })
63
+ };
64
+ }
65
+ return queues;
66
+ }
67
+ async function sendJob(jobName, params, queues, queueName) {
68
+ const queueData = queues[queueName];
69
+ if (!queueData) {
70
+ throw new Error(`Queue ${queueName} not found`);
71
+ }
72
+ return await queueData.queue.add(jobName, params);
73
+ }
74
+ async function sendJobAndWait(jobName, params, queues, queueName) {
75
+ const queueData = queues[queueName];
76
+ if (!queueData) {
77
+ throw new Error(`Queue ${queueName} not found`);
78
+ }
79
+ const job = await queueData.queue.add(jobName, params);
80
+ return await job.waitUntilFinished(queueData.events);
81
+ }
82
+ async function closeQueues(queues) {
83
+ await Promise.all(
84
+ Object.values(queues).map(
85
+ ({ queue, events }) => Promise.all([queue.close(), events.close()])
86
+ )
87
+ );
88
+ }
89
+
90
+ // src/processors/token-bucket-job.processor/constants.ts
91
+ var TOKEN_BUCKET_PROCESS_SUFFIX = ":process";
92
+ var INTERVAL_MS = {
93
+ minute: 6e4,
94
+ hour: 36e5,
95
+ day: 864e5
96
+ };
97
+
98
+ // src/processors/constants.ts
99
+ var ETL_EXTRACT_SUFFIX = ":extract";
100
+ var ETL_TRANSFORM_SUFFIX = ":transform";
101
+ var ETL_LOAD_SUFFIX = ":load";
102
+
103
+ // src/processors/simple-job.processor.ts
104
+ async function processSimpleJob(job, jobConfig) {
105
+ return jobConfig.handler(job, job.data);
106
+ }
107
+ var zRateLimitConfig = zod.z.union([
108
+ zod.z.object({ perSecond: zod.z.number().positive() }),
109
+ zod.z.object({ perMinute: zod.z.number().positive() })
110
+ ]);
111
+ var zJobStatus = zod.z.enum([
112
+ "pending",
113
+ "in_progress",
114
+ "completed",
115
+ "failed",
116
+ "stalled"
117
+ ]);
118
+ var zBaseJob = zod.z.object({
119
+ _id: zod.z.string(),
120
+ type: zod.z.string(),
121
+ jobId: zod.z.string(),
122
+ jobName: zod.z.string(),
123
+ status: zJobStatus,
124
+ params: zod.z.unknown().nullable(),
125
+ result: zod.z.unknown().nullable(),
126
+ error: zod.z.string().nullable(),
127
+ timestamps: zod.z.object({
128
+ createdAt: zod.z.date(),
129
+ updatedAt: zod.z.date(),
130
+ startedAt: zod.z.date().nullable(),
131
+ completedAt: zod.z.date().nullable()
132
+ })
133
+ });
134
+ function defineSimpleJob(config) {
135
+ return { ...config, type: "simple" };
136
+ }
137
+ var zSimpleJob = zBaseJob.extend({
138
+ type: zod.z.literal("simple")
139
+ });
140
+ var zMemoryEtlJob = zBaseJob.extend({
141
+ type: zod.z.literal("memory-etl"),
142
+ progress: zod.z.object({
143
+ extractedCount: zod.z.number().int().nonnegative(),
144
+ transformedCount: zod.z.number().int().nonnegative(),
145
+ loadedCount: zod.z.number().int().nonnegative()
146
+ })
147
+ });
148
+ function defineMemoryEtlJob(config) {
149
+ return { ...config, type: "memory-etl" };
150
+ }
151
+ var zDistributedEtlJob = zBaseJob.extend({
152
+ type: zod.z.literal("distributed-etl"),
153
+ progress: zod.z.object({
154
+ extractedCount: zod.z.number().int().nonnegative(),
155
+ transformedCount: zod.z.number().int().nonnegative(),
156
+ loadedCount: zod.z.number().int().nonnegative()
157
+ })
158
+ });
159
+ function defineDistributedEtlJob(config) {
160
+ return { ...config, type: "distributed-etl" };
161
+ }
162
+ var zStatefulEtlJob = zBaseJob.extend({
163
+ type: zod.z.literal("stateful-etl"),
164
+ stallTimeoutMs: zod.z.number().int().positive(),
165
+ maxStallRetries: zod.z.number().int().nonnegative(),
166
+ progress: zod.z.object({
167
+ transformedCount: zod.z.number().int().nonnegative(),
168
+ extractedCount: zod.z.number().int().nonnegative(),
169
+ loadedCount: zod.z.number().int().nonnegative(),
170
+ checkpoint: zod.z.unknown().nullable(),
171
+ lastHeartbeatAt: zod.z.date().nullable(),
172
+ stallRetries: zod.z.number().int().nonnegative()
173
+ })
174
+ });
175
+ function defineStatefulEtlJob(config) {
176
+ return { ...config, type: "stateful-etl" };
177
+ }
178
+ zod.z.object({
179
+ _id: zod.z.string(),
180
+ accumulatedCredit: zod.z.number(),
181
+ lastTickAt: zod.z.date(),
182
+ windowDispatched: zod.z.number().int().nonnegative(),
183
+ windowResetAt: zod.z.date()
184
+ });
185
+ function defineTokenBucketJob(config) {
186
+ return { ...config, type: "token-bucket" };
187
+ }
188
+ zod.z.discriminatedUnion("type", [
189
+ zSimpleJob,
190
+ zMemoryEtlJob,
191
+ zDistributedEtlJob,
192
+ zStatefulEtlJob
193
+ ]);
194
+
195
+ // src/types/config.ts
196
+ function defineConfig(config) {
197
+ return config;
198
+ }
199
+
200
+ // src/rate-limiter.ts
201
+ var RateLimiter = class {
202
+ constructor() {
203
+ this.slots = /* @__PURE__ */ new Map();
204
+ }
205
+ // key → nextAvailableAt
206
+ /**
207
+ * Block until a slot is available. Combines getDelay + wait in a loop.
208
+ */
209
+ async waitForSlot(key, config) {
210
+ let delay = this.getDelay(key, config);
211
+ while (delay > 0) {
212
+ await this.wait(delay);
213
+ delay = this.getDelay(key, config);
214
+ }
215
+ }
216
+ /**
217
+ * Check if a slot is available. Returns 0 if allowed (and consumes the slot),
218
+ * or the number of ms to wait before retrying.
219
+ *
220
+ * Slots are evenly spaced: perSecond: 10 → one slot every 100ms.
221
+ */
222
+ getDelay(key, config) {
223
+ const intervalMs = resolve(config);
224
+ const now = Date.now();
225
+ const nextAvailableAt = this.slots.get(key);
226
+ if (nextAvailableAt === void 0 || now >= nextAvailableAt) {
227
+ this.slots.set(key, now + intervalMs);
228
+ return 0;
229
+ }
230
+ return nextAvailableAt - now;
231
+ }
232
+ /**
233
+ * Wait for the given number of ms.
234
+ */
235
+ wait(ms) {
236
+ if (ms <= 0) return Promise.resolve();
237
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
238
+ }
239
+ };
240
+ function resolve(config) {
241
+ zRateLimitConfig.parse(config);
242
+ if ("perSecond" in config) return 1e3 / config.perSecond;
243
+ return 6e4 / config.perMinute;
244
+ }
245
+
246
+ // src/processors/memory-etl-job.processor.ts
247
+ async function processMemoryEtlJob(job, config) {
248
+ const logger2 = getLogger();
249
+ const jobName = job.name;
250
+ const counts = { extractedCount: 0, transformedCount: 0, loadedCount: 0 };
251
+ const limiter = config.steps ? new RateLimiter() : null;
252
+ let cursor = null;
253
+ let hasMore = true;
254
+ while (hasMore) {
255
+ await applyRateLimit(limiter, jobName, "extract", config);
256
+ const page = await config.extract(cursor);
257
+ hasMore = page.hasMore;
258
+ cursor = page.cursor;
259
+ if (page.data.length === 0) continue;
260
+ counts.extractedCount += page.data.length;
261
+ const processed = await transformParallel(
262
+ page.data,
263
+ config.transform,
264
+ limiter,
265
+ jobName,
266
+ config
267
+ );
268
+ counts.transformedCount += processed.length;
269
+ await applyRateLimit(limiter, jobName, "load", config);
270
+ await config.load(processed);
271
+ counts.loadedCount += processed.length;
272
+ logger2.debug({ jobName, ...counts }, "[memory-etl] batch done");
273
+ }
274
+ logger2.info({ jobName, ...counts }, "[memory-etl] completed");
275
+ return counts;
276
+ }
277
+ async function transformParallel(batch, transform, limiter, jobName, config) {
278
+ const pending = [];
279
+ for (const item of batch) {
280
+ await applyRateLimit(limiter, jobName, "transform", config);
281
+ pending.push(transform(item));
282
+ }
283
+ return Promise.all(pending);
284
+ }
285
+ async function applyRateLimit(limiter, jobName, step, config) {
286
+ const rlConfig = config.steps?.[step]?.rateLimit;
287
+ if (!limiter || !rlConfig) return;
288
+ await limiter.waitForSlot(`ravenflow:rl:${jobName}:${step}`, rlConfig);
289
+ }
290
+
291
+ // src/processors/sub-job.ts
292
+ async function dispatchSubJob(queue, queueEvents, name, data, options) {
293
+ const job = await queue.add(name, data, {
294
+ attempts: options?.attempts,
295
+ backoff: options?.backoff
296
+ });
297
+ return await job.waitUntilFinished(queueEvents);
298
+ }
299
+ async function dispatchSubJobsFireAndForget(queue, name, items, options) {
300
+ return queue.addBulk(
301
+ items.map((data) => ({
302
+ name,
303
+ data,
304
+ opts: {
305
+ attempts: options?.attempts,
306
+ backoff: options?.backoff
307
+ }
308
+ }))
309
+ );
310
+ }
311
+
312
+ // src/processors/etl-context.ts
313
+ var EtlContext = class {
314
+ constructor(jobName, config, queues, initialCounts) {
315
+ this.jobName = jobName;
316
+ this.logger = getLogger();
317
+ this.stepsConfig = config.steps;
318
+ this.limiter = config.steps ? new RateLimiter() : null;
319
+ this.counts = initialCounts ? { ...initialCounts } : { extractedCount: 0, transformedCount: 0, loadedCount: 0 };
320
+ const defaultQueue = queues[config.queue];
321
+ this.steps = this.buildStepContexts(config, queues, defaultQueue);
322
+ }
323
+ // ---------------------------------------------------------------------------
324
+ // Rate limiting
325
+ // ---------------------------------------------------------------------------
326
+ async applyRateLimit(step) {
327
+ const config = this.stepsConfig?.[step]?.rateLimit;
328
+ if (!this.limiter || !config) return;
329
+ await this.limiter.waitForSlot(
330
+ `ravenflow:rl:${this.jobName}:${step}`,
331
+ config
332
+ );
333
+ }
334
+ // ---------------------------------------------------------------------------
335
+ // Sub-job dispatch primitives
336
+ // ---------------------------------------------------------------------------
337
+ async dispatchExtract(cursor) {
338
+ await this.applyRateLimit("extract");
339
+ const { queue, queueEvents, options } = this.steps.extract;
340
+ return dispatchSubJob(
341
+ queue,
342
+ queueEvents,
343
+ `${this.jobName}${ETL_EXTRACT_SUFFIX}`,
344
+ { cursor },
345
+ options
346
+ );
347
+ }
348
+ async dispatchTransform(item) {
349
+ const { queue, queueEvents, options } = this.steps.transform;
350
+ return dispatchSubJob(
351
+ queue,
352
+ queueEvents,
353
+ `${this.jobName}${ETL_TRANSFORM_SUFFIX}`,
354
+ item,
355
+ options
356
+ );
357
+ }
358
+ async transformParallel(rawItems) {
359
+ const pending = [];
360
+ for (const item of rawItems) {
361
+ await this.applyRateLimit("transform");
362
+ pending.push(this.dispatchTransform(item));
363
+ }
364
+ return Promise.all(pending);
365
+ }
366
+ async dispatchLoad(processedItems) {
367
+ await this.applyRateLimit("load");
368
+ const { queue, queueEvents, options } = this.steps.load;
369
+ await dispatchSubJob(
370
+ queue,
371
+ queueEvents,
372
+ `${this.jobName}${ETL_LOAD_SUFFIX}`,
373
+ { data: processedItems, jobName: this.jobName },
374
+ options
375
+ );
376
+ }
377
+ // ---------------------------------------------------------------------------
378
+ // Step context setup
379
+ // ---------------------------------------------------------------------------
380
+ buildStepContexts(config, queues, defaultQueue) {
381
+ const resolve2 = (step) => {
382
+ const q = step?.queue ? queues[step.queue] : defaultQueue;
383
+ if (!q) throw new Error(`Queue "${step?.queue}" not found`);
384
+ return {
385
+ queue: q.queue,
386
+ queueEvents: q.events,
387
+ options: {
388
+ attempts: step?.attempts ?? config.attempts,
389
+ backoff: step?.backoff ?? config.backoff
390
+ }
391
+ };
392
+ };
393
+ return {
394
+ extract: resolve2(config.steps?.extract),
395
+ transform: resolve2(config.steps?.transform),
396
+ load: resolve2(config.steps?.load)
397
+ };
398
+ }
399
+ };
400
+
401
+ // src/processors/distributed-etl-job.processor.ts
402
+ async function processDistributedEtlJob(job, config, queues) {
403
+ const ctx = new EtlContext(job.name, config, queues);
404
+ let cursor = null;
405
+ let hasMore = true;
406
+ while (hasMore) {
407
+ const page = await ctx.dispatchExtract(cursor);
408
+ hasMore = page.hasMore;
409
+ cursor = page.cursor;
410
+ if (page.data.length === 0) continue;
411
+ ctx.counts.extractedCount += page.data.length;
412
+ const processed = await ctx.transformParallel(page.data);
413
+ ctx.counts.transformedCount += processed.length;
414
+ await ctx.dispatchLoad(processed);
415
+ ctx.counts.loadedCount += processed.length;
416
+ ctx.logger.debug({ jobName: ctx.jobName, ...ctx.counts }, "[distributed-etl] batch done");
417
+ }
418
+ ctx.logger.info({ jobName: ctx.jobName, ...ctx.counts }, "[distributed-etl] completed");
419
+ return ctx.counts;
420
+ }
421
+ var COLLECTION_PREFIX = "ravenflow_";
422
+ var client = null;
423
+ var db = null;
424
+ async function connectMongo(uri) {
425
+ if (db) return db;
426
+ client = new mongodb.MongoClient(uri);
427
+ await client.connect();
428
+ db = client.db();
429
+ return db;
430
+ }
431
+ function getDb() {
432
+ if (!db) {
433
+ throw new Error("[ravenflow] MongoDB not connected. Pass a mongo URI to createRavenflowApp().");
434
+ }
435
+ return db;
436
+ }
437
+ function collectionName(name) {
438
+ return `${COLLECTION_PREFIX}${name}`;
439
+ }
440
+ async function closeMongo() {
441
+ if (client) {
442
+ await client.close();
443
+ client = null;
444
+ db = null;
445
+ }
446
+ }
447
+
448
+ // src/mongo/run.repo.ts
449
+ var run_repo_exports = {};
450
+ __export(run_repo_exports, {
451
+ claimStalledRun: () => claimStalledRun,
452
+ createRun: () => createRun,
453
+ findActiveRun: () => findActiveRun,
454
+ findById: () => findById,
455
+ findStalled: () => findStalled,
456
+ findStalledCandidates: () => findStalledCandidates,
457
+ markCompleted: () => markCompleted,
458
+ markFailed: () => markFailed,
459
+ markInProgress: () => markInProgress,
460
+ markResuming: () => markResuming,
461
+ updateCheckpoint: () => updateCheckpoint,
462
+ updateHeartbeat: () => updateHeartbeat
463
+ });
464
+ var RUNS_COLLECTION = "runs";
465
+ function collection() {
466
+ return getDb().collection(collectionName(RUNS_COLLECTION));
467
+ }
468
+ async function findActiveRun(jobName) {
469
+ return await collection().findOne(
470
+ { jobName, status: { $in: ["pending", "in_progress", "stalled"] } },
471
+ { sort: { "timestamps.createdAt": -1 } }
472
+ );
473
+ }
474
+ async function findById(runId) {
475
+ return await collection().findOne({ _id: runId });
476
+ }
477
+ async function createRun(jobName, params, jobId, stallTimeoutMs, maxStallRetries) {
478
+ const now = /* @__PURE__ */ new Date();
479
+ const id = new mongodb.ObjectId().toString();
480
+ await collection().insertOne({
481
+ _id: id,
482
+ type: "stateful-etl",
483
+ jobId,
484
+ jobName,
485
+ status: "pending",
486
+ params: params ?? null,
487
+ result: null,
488
+ error: null,
489
+ stallTimeoutMs,
490
+ maxStallRetries,
491
+ progress: {
492
+ extractedCount: 0,
493
+ transformedCount: 0,
494
+ loadedCount: 0,
495
+ checkpoint: null,
496
+ lastHeartbeatAt: null,
497
+ stallRetries: 0
498
+ },
499
+ timestamps: {
500
+ createdAt: now,
501
+ updatedAt: now,
502
+ startedAt: null,
503
+ completedAt: null
504
+ }
505
+ });
506
+ return id;
507
+ }
508
+ async function markInProgress(runId) {
509
+ const now = /* @__PURE__ */ new Date();
510
+ await collection().updateOne(
511
+ { _id: runId },
512
+ {
513
+ $set: {
514
+ status: "in_progress",
515
+ "timestamps.startedAt": now,
516
+ "timestamps.updatedAt": now,
517
+ "progress.lastHeartbeatAt": now
518
+ }
519
+ }
520
+ );
521
+ }
522
+ async function updateHeartbeat(runId) {
523
+ const now = /* @__PURE__ */ new Date();
524
+ await collection().updateOne(
525
+ { _id: runId },
526
+ {
527
+ $set: {
528
+ "progress.lastHeartbeatAt": now,
529
+ "timestamps.updatedAt": now
530
+ }
531
+ }
532
+ );
533
+ }
534
+ async function updateCheckpoint(runId, checkpoint, counts) {
535
+ await collection().updateOne(
536
+ { _id: runId },
537
+ {
538
+ $set: {
539
+ "progress.checkpoint": checkpoint,
540
+ "progress.extractedCount": counts.extractedCount,
541
+ "progress.transformedCount": counts.transformedCount,
542
+ "progress.loadedCount": counts.loadedCount,
543
+ "timestamps.updatedAt": /* @__PURE__ */ new Date()
544
+ }
545
+ }
546
+ );
547
+ }
548
+ async function markCompleted(runId) {
549
+ const now = /* @__PURE__ */ new Date();
550
+ await collection().updateOne(
551
+ { _id: runId },
552
+ {
553
+ $set: {
554
+ status: "completed",
555
+ "timestamps.completedAt": now,
556
+ "timestamps.updatedAt": now,
557
+ "progress.lastHeartbeatAt": null
558
+ }
559
+ }
560
+ );
561
+ }
562
+ async function markFailed(runId, error) {
563
+ const now = /* @__PURE__ */ new Date();
564
+ await collection().updateOne(
565
+ { _id: runId },
566
+ {
567
+ $set: {
568
+ status: "failed",
569
+ error,
570
+ "timestamps.updatedAt": now,
571
+ "progress.lastHeartbeatAt": null
572
+ }
573
+ }
574
+ );
575
+ }
576
+ async function findStalled(stallTimeoutMs) {
577
+ const threshold = new Date(Date.now() - stallTimeoutMs);
578
+ return await collection().find({
579
+ status: "in_progress",
580
+ "progress.lastHeartbeatAt": { $lt: threshold }
581
+ }).toArray();
582
+ }
583
+ async function findStalledCandidates() {
584
+ const now = /* @__PURE__ */ new Date();
585
+ return await collection().find({
586
+ status: "in_progress",
587
+ "progress.lastHeartbeatAt": { $ne: null },
588
+ $expr: {
589
+ $lt: [
590
+ "$progress.lastHeartbeatAt",
591
+ { $subtract: [now, "$stallTimeoutMs"] }
592
+ ]
593
+ }
594
+ }).toArray();
595
+ }
596
+ async function claimStalledRun(runId) {
597
+ const result = await collection().findOneAndUpdate(
598
+ {
599
+ _id: runId,
600
+ status: "in_progress"
601
+ },
602
+ {
603
+ $set: {
604
+ status: "stalled",
605
+ "progress.lastHeartbeatAt": null,
606
+ "timestamps.updatedAt": /* @__PURE__ */ new Date()
607
+ },
608
+ $inc: {
609
+ "progress.stallRetries": 1
610
+ }
611
+ },
612
+ { returnDocument: "after" }
613
+ );
614
+ return result ?? null;
615
+ }
616
+ async function markResuming(runId) {
617
+ const now = /* @__PURE__ */ new Date();
618
+ await collection().updateOne(
619
+ { _id: runId },
620
+ {
621
+ $set: {
622
+ status: "in_progress",
623
+ "progress.lastHeartbeatAt": now,
624
+ "timestamps.updatedAt": now
625
+ }
626
+ }
627
+ );
628
+ }
629
+
630
+ // src/mongo/token-bucket.repo.ts
631
+ var token_bucket_repo_exports = {};
632
+ __export(token_bucket_repo_exports, {
633
+ getOrCreateState: () => getOrCreateState,
634
+ updateState: () => updateState
635
+ });
636
+ var COLLECTION = "token_bucket_state";
637
+ function collection2() {
638
+ return getDb().collection(collectionName(COLLECTION));
639
+ }
640
+ async function getOrCreateState(jobName, now, windowResetAt) {
641
+ const result = await collection2().findOneAndUpdate(
642
+ { _id: jobName },
643
+ {
644
+ $setOnInsert: {
645
+ _id: jobName,
646
+ accumulatedCredit: 0,
647
+ lastTickAt: now,
648
+ windowDispatched: 0,
649
+ windowResetAt
650
+ }
651
+ },
652
+ { upsert: true, returnDocument: "after" }
653
+ );
654
+ return result;
655
+ }
656
+ async function updateState(jobName, accumulatedCredit, lastTickAt, windowDispatched, windowResetAt) {
657
+ await collection2().updateOne(
658
+ { _id: jobName },
659
+ {
660
+ $set: {
661
+ accumulatedCredit,
662
+ lastTickAt,
663
+ windowDispatched,
664
+ windowResetAt
665
+ }
666
+ }
667
+ );
668
+ }
669
+
670
+ // src/processors/stateful-etl-job.processor/constants.ts
671
+ var DEFAULT_STALL_TIMEOUT_MS = 3e5;
672
+ var HEARTBEAT_INTERVAL_MS = 3e4;
673
+ var DEFAULT_MAX_STALL_RETRIES = 3;
674
+
675
+ // src/processors/stateful-etl-job.processor/heartbeat.ts
676
+ function startHeartbeat(runId) {
677
+ const logger2 = getLogger();
678
+ const timer = setInterval(async () => {
679
+ try {
680
+ await run_repo_exports.updateHeartbeat(runId);
681
+ } catch (err) {
682
+ logger2.warn(
683
+ { runId, error: err },
684
+ "[stateful-etl] heartbeat update failed"
685
+ );
686
+ }
687
+ logger2.debug({ runId }, "[stateful-etl] heartbeat");
688
+ }, HEARTBEAT_INTERVAL_MS);
689
+ timer.unref();
690
+ return timer;
691
+ }
692
+
693
+ // src/processors/stateful-etl-job.processor/paginate.ts
694
+ async function paginateAndProcess(jobName, runId, jobConfig, queues, initialCursor, initialCounts) {
695
+ const ctx = new EtlContext(
696
+ jobName,
697
+ jobConfig,
698
+ queues,
699
+ initialCounts
700
+ );
701
+ let cursor = initialCursor;
702
+ let hasMore = true;
703
+ while (hasMore) {
704
+ const page = await ctx.dispatchExtract(cursor);
705
+ hasMore = page.hasMore;
706
+ cursor = page.cursor;
707
+ if (page.data.length === 0) continue;
708
+ const processed = await ctx.transformParallel(page.data);
709
+ await ctx.dispatchLoad(processed);
710
+ ctx.counts.extractedCount += page.data.length;
711
+ ctx.counts.transformedCount += processed.length;
712
+ ctx.counts.loadedCount += processed.length;
713
+ await run_repo_exports.updateCheckpoint(runId, cursor, ctx.counts);
714
+ ctx.logger.debug({ jobName, ...ctx.counts }, "[stateful-etl] batch done");
715
+ }
716
+ }
717
+
718
+ // src/processors/stateful-etl-job.processor/process.ts
719
+ function isResumeData(data) {
720
+ return typeof data === "object" && data !== null && "__ravenflow_resume" in data && data.__ravenflow_resume === true;
721
+ }
722
+ async function processStatefulEtlJob(job, jobConfig, queues) {
723
+ const logger2 = getLogger();
724
+ const jobName = job.name;
725
+ if (isResumeData(job.data)) {
726
+ return resumeRun(job.data, jobName, jobConfig, queues, logger2);
727
+ }
728
+ return freshRun(job, jobName, jobConfig, queues, logger2);
729
+ }
730
+ async function resumeRun(data, jobName, jobConfig, queues, logger2) {
731
+ const { runId, checkpoint, counts } = data;
732
+ logger2.info({ jobName, runId }, "[stateful-etl] resuming stalled run");
733
+ await run_repo_exports.markResuming(runId);
734
+ const heartbeatTimer = startHeartbeat(runId);
735
+ try {
736
+ await paginateAndProcess(
737
+ jobName,
738
+ runId,
739
+ jobConfig,
740
+ queues,
741
+ checkpoint,
742
+ counts
743
+ );
744
+ await run_repo_exports.markCompleted(runId);
745
+ logger2.info({ jobName, runId }, "[stateful-etl] resumed run completed");
746
+ } catch (error) {
747
+ const message = error instanceof Error ? error.message : String(error);
748
+ await run_repo_exports.markFailed(runId, message);
749
+ logger2.error({ jobName, runId, error: message }, "[stateful-etl] resumed run failed");
750
+ throw error;
751
+ } finally {
752
+ clearInterval(heartbeatTimer);
753
+ }
754
+ }
755
+ async function freshRun(job, jobName, jobConfig, queues, logger2) {
756
+ const params = job.data ?? null;
757
+ if (jobConfig.singletonRun) {
758
+ await ensureNotAlreadyRunning(jobName);
759
+ }
760
+ const stallTimeoutMs = jobConfig.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
761
+ const maxStallRetries = jobConfig.maxStallRetries ?? DEFAULT_MAX_STALL_RETRIES;
762
+ const runId = await run_repo_exports.createRun(jobName, params, job.id, stallTimeoutMs, maxStallRetries);
763
+ await run_repo_exports.markInProgress(runId);
764
+ const heartbeatTimer = startHeartbeat(runId);
765
+ try {
766
+ await paginateAndProcess(
767
+ jobName,
768
+ runId,
769
+ jobConfig,
770
+ queues,
771
+ null,
772
+ { extractedCount: 0, transformedCount: 0, loadedCount: 0 }
773
+ );
774
+ await run_repo_exports.markCompleted(runId);
775
+ logger2.info({ jobName }, "[stateful-etl] run completed");
776
+ } catch (error) {
777
+ const message = error instanceof Error ? error.message : String(error);
778
+ await run_repo_exports.markFailed(runId, message);
779
+ logger2.error({ jobName, error: message }, "[stateful-etl] run failed");
780
+ throw error;
781
+ } finally {
782
+ clearInterval(heartbeatTimer);
783
+ }
784
+ }
785
+ async function ensureNotAlreadyRunning(jobName) {
786
+ const activeRun = await run_repo_exports.findActiveRun(jobName);
787
+ if (activeRun) {
788
+ throw new Error(
789
+ `[stateful-etl] job "${jobName}" already has an active run (status: ${activeRun.status})`
790
+ );
791
+ }
792
+ }
793
+
794
+ // src/processors/token-bucket-job.processor/active-hours.ts
795
+ function isWithinActiveHours(config, now) {
796
+ if (!config.activeHours) return true;
797
+ const { start, end, timezone } = config.activeHours;
798
+ const hour = getHourInTimezone(now, timezone);
799
+ if (start <= end) {
800
+ return hour >= start && hour < end;
801
+ }
802
+ return hour >= start || hour < end;
803
+ }
804
+ function getActiveWindowMs(config) {
805
+ if (!config.activeHours) {
806
+ return getFullIntervalMs(config.rate.interval);
807
+ }
808
+ const { start, end } = config.activeHours;
809
+ let hours;
810
+ if (start <= end) {
811
+ hours = end - start;
812
+ } else {
813
+ hours = 24 - start + end;
814
+ }
815
+ if (hours <= 0) hours = 24;
816
+ if (config.rate.interval === "day") {
817
+ return hours * 36e5;
818
+ }
819
+ return getFullIntervalMs(config.rate.interval);
820
+ }
821
+ function getFullIntervalMs(interval) {
822
+ switch (interval) {
823
+ case "minute":
824
+ return 6e4;
825
+ case "hour":
826
+ return 36e5;
827
+ case "day":
828
+ return 864e5;
829
+ }
830
+ }
831
+ function getHourInTimezone(date, timezone) {
832
+ if (!timezone) return date.getUTCHours();
833
+ const formatted = new Intl.DateTimeFormat("en-US", {
834
+ hour: "numeric",
835
+ hour12: false,
836
+ timeZone: timezone
837
+ }).format(date);
838
+ return parseInt(formatted, 10);
839
+ }
840
+
841
+ // src/processors/token-bucket-job.processor/budget.ts
842
+ function calculateBudget(state, config, now) {
843
+ const nowMs = now.getTime();
844
+ const windowResetMs = state.windowResetAt.getTime();
845
+ let windowDispatched = state.windowDispatched;
846
+ let windowResetAt = state.windowResetAt;
847
+ if (nowMs >= windowResetMs) {
848
+ windowDispatched = 0;
849
+ windowResetAt = computeNextWindowReset(now, config.rate.interval);
850
+ }
851
+ const elapsedMs = nowMs - state.lastTickAt.getTime();
852
+ if (elapsedMs <= 0) {
853
+ return { budget: 0, newCredit: state.accumulatedCredit, newWindowDispatched: windowDispatched, newWindowResetAt: windowResetAt };
854
+ }
855
+ const activeWindowMs = getActiveWindowMs(config);
856
+ const msPerItem = activeWindowMs / config.rate.amount;
857
+ const earnedCredit = elapsedMs / msPerItem;
858
+ let credit = state.accumulatedCredit + earnedCredit;
859
+ const maxPerBeat = config.maxPerBeat ?? computeDefaultMaxPerBeat(config);
860
+ credit = Math.min(credit, maxPerBeat);
861
+ const remaining = Math.max(0, config.rate.amount - windowDispatched);
862
+ const budget = Math.min(Math.floor(credit), remaining);
863
+ return {
864
+ budget,
865
+ newCredit: credit - budget,
866
+ newWindowDispatched: windowDispatched + budget,
867
+ newWindowResetAt: windowResetAt
868
+ };
869
+ }
870
+ function computeDefaultMaxPerBeat(config) {
871
+ return Math.max(1, Math.ceil(config.rate.amount / 10));
872
+ }
873
+ function computeNextWindowReset(now, interval) {
874
+ return new Date(now.getTime() + INTERVAL_MS[interval]);
875
+ }
876
+
877
+ // src/processors/token-bucket-job.processor/process.ts
878
+ async function processTokenBucketJob(job, config, queues) {
879
+ const logger2 = getLogger();
880
+ const jobName = job.name;
881
+ const now = /* @__PURE__ */ new Date();
882
+ if (!isWithinActiveHours(config, now)) {
883
+ logger2.debug({ jobName }, "[token-bucket] outside active hours, skipping");
884
+ return { dispatched: 0 };
885
+ }
886
+ const initialWindowReset = new Date(now.getTime() + INTERVAL_MS[config.rate.interval]);
887
+ const state = await token_bucket_repo_exports.getOrCreateState(jobName, now, initialWindowReset);
888
+ const { budget, newCredit, newWindowDispatched, newWindowResetAt } = calculateBudget(state, config, now);
889
+ if (budget <= 0) {
890
+ await token_bucket_repo_exports.updateState(jobName, newCredit, now, newWindowDispatched, newWindowResetAt);
891
+ logger2.debug({ jobName, credit: newCredit }, "[token-bucket] no budget, skipping");
892
+ return { dispatched: 0 };
893
+ }
894
+ const items = await config.getItems(budget);
895
+ if (items.length === 0) {
896
+ await token_bucket_repo_exports.updateState(jobName, newCredit, now, newWindowDispatched, newWindowResetAt);
897
+ logger2.debug({ jobName }, "[token-bucket] no items to dispatch");
898
+ return { dispatched: 0 };
899
+ }
900
+ const queueData = queues[config.queue];
901
+ if (!queueData) {
902
+ throw new Error(`[token-bucket] queue "${config.queue}" not found`);
903
+ }
904
+ const subJobOptions = config.steps?.process ? { attempts: config.steps.process.attempts, backoff: config.steps.process.backoff } : void 0;
905
+ const subJobName = `${jobName}${TOKEN_BUCKET_PROCESS_SUFFIX}`;
906
+ await dispatchSubJobsFireAndForget(queueData.queue, subJobName, items, subJobOptions);
907
+ const actualDispatched = items.length;
908
+ const adjustedCredit = Math.max(0, newCredit - Math.max(0, budget - actualDispatched));
909
+ const adjustedWindowDispatched = newWindowDispatched - (budget - actualDispatched);
910
+ await token_bucket_repo_exports.updateState(jobName, adjustedCredit, now, adjustedWindowDispatched, newWindowResetAt);
911
+ logger2.info(
912
+ { jobName, dispatched: actualDispatched, credit: adjustedCredit, windowDispatched: adjustedWindowDispatched },
913
+ "[token-bucket] dispatched items"
914
+ );
915
+ return { dispatched: actualDispatched };
916
+ }
917
+
918
+ // src/app/job-registry.ts
919
+ function initJobs(config, queues) {
920
+ const jobsProxy = {};
921
+ const jobConfigs = {};
922
+ for (const [jobName, jobConfig] of Object.entries(config.jobs)) {
923
+ jobConfigs[jobName] = jobConfig;
924
+ const resolvedQueue = jobConfig.queue ?? config.defaultQueue;
925
+ jobsProxy[jobName] = {
926
+ send: async (params) => {
927
+ return sendJob(jobName, params, queues, resolvedQueue);
928
+ },
929
+ sendAndWait: async (params) => {
930
+ return sendJobAndWait(jobName, params, queues, resolvedQueue);
931
+ }
932
+ };
933
+ }
934
+ return {
935
+ jobsProxy,
936
+ jobConfigs
937
+ };
938
+ }
939
+ var ALL_SUFFIXES = [
940
+ ETL_EXTRACT_SUFFIX,
941
+ ETL_TRANSFORM_SUFFIX,
942
+ ETL_LOAD_SUFFIX,
943
+ TOKEN_BUCKET_PROCESS_SUFFIX
944
+ ];
945
+ function resolveJobName(name, jobConfigs) {
946
+ for (const suffix of ALL_SUFFIXES) {
947
+ if (!name.endsWith(suffix)) continue;
948
+ const baseName = name.slice(0, -suffix.length);
949
+ if (jobConfigs[baseName]) {
950
+ return { baseName, suffix };
951
+ }
952
+ }
953
+ return { baseName: name, suffix: null };
954
+ }
955
+ async function processJob(job, jobConfigs, queues, defaultQueue) {
956
+ const { baseName, suffix } = resolveJobName(job.name, jobConfigs);
957
+ const jobConfig = getJobConfig(baseName, jobConfigs);
958
+ const resolvedQueue = jobConfig.queue ?? defaultQueue;
959
+ if (suffix !== null) {
960
+ if (jobConfig.type === "distributed-etl") {
961
+ if (suffix === ETL_EXTRACT_SUFFIX) {
962
+ return await jobConfig.extract(job.data.cursor);
963
+ }
964
+ if (suffix === ETL_TRANSFORM_SUFFIX) {
965
+ return await jobConfig.transform(job.data);
966
+ }
967
+ if (suffix === ETL_LOAD_SUFFIX) {
968
+ return await jobConfig.load(job.data.data);
969
+ }
970
+ }
971
+ if (jobConfig.type === "stateful-etl") {
972
+ if (suffix === ETL_EXTRACT_SUFFIX) {
973
+ return await jobConfig.extract(job.data.cursor);
974
+ }
975
+ if (suffix === ETL_TRANSFORM_SUFFIX) {
976
+ return await jobConfig.transform(job.data);
977
+ }
978
+ if (suffix === ETL_LOAD_SUFFIX) {
979
+ return await jobConfig.load(job.data.data);
980
+ }
981
+ }
982
+ if (jobConfig.type === "token-bucket") {
983
+ if (suffix === TOKEN_BUCKET_PROCESS_SUFFIX) {
984
+ return await jobConfig.process(job.data);
985
+ }
986
+ }
987
+ throw new Error(`Unexpected sub-job suffix "${suffix}" for ${baseName} (type: ${jobConfig.type})`);
988
+ }
989
+ if (jobConfig.type === "simple") {
990
+ return await processSimpleJob(job, jobConfig);
991
+ }
992
+ if (jobConfig.type === "memory-etl") {
993
+ return await processMemoryEtlJob(job, jobConfig);
994
+ }
995
+ if (jobConfig.type === "distributed-etl") {
996
+ return await processDistributedEtlJob(job, { ...jobConfig, queue: resolvedQueue }, queues);
997
+ }
998
+ if (jobConfig.type === "stateful-etl") {
999
+ return await processStatefulEtlJob(job, { ...jobConfig, queue: resolvedQueue }, queues);
1000
+ }
1001
+ if (jobConfig.type === "token-bucket") {
1002
+ return await processTokenBucketJob(job, { ...jobConfig, queue: resolvedQueue }, queues);
1003
+ }
1004
+ throw new Error(`Unknown job type for: ${job.name}`);
1005
+ }
1006
+ function getJobConfig(jobName, jobConfigs) {
1007
+ const config = jobConfigs[jobName];
1008
+ if (!config) throw new Error(`Job handler not found: ${jobName}`);
1009
+ return config;
65
1010
  }
66
1011
 
67
1012
  // src/banner.ts
@@ -71,16 +1016,47 @@ var RAVENFLOW_ASCII = `
71
1016
  \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2557 \u2588\u2588\u2551
72
1017
  \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u255A\u2588\u2588\u2557 \u2588\u2588\u2554\u255D\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2588\u2557\u2588\u2588\u2551
73
1018
  \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2554\u255D \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2554\u2588\u2588\u2588\u2554\u255D
74
- \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u255D\u255A\u2550\u2550\u255D
1019
+ \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u255D\u255A\u2550\u2550\u255D
1020
+ `;
1021
+ var RAVENFLOW_ASCII_ART = ` :==++==-:::..
1022
+ :--:::.*%%%%###+====--:::::.
1023
+ ===--:+@@%%%##%##*==-=%%%###*##:
1024
+ :+===-*@@%%%######*#*#%@@%
1025
+ =++==*@@%%%#########@@@@%.
1026
+ -*+==@@@%%%######*%@@@@%*
1027
+ **+#@@%%%#######%@@@@%%*=
1028
+ =**@@%%%%#######@@@@%%%**
1029
+ **@@%%%%#%####@@@@@%%#**+
1030
+ +#@%%%%%%#%##%@@@@@%%#***-
1031
+ %%%%%%#%#%##@@@@@%%%*+***
1032
+ .@%%%%%#%%%#%@@@@%%%%+=++*:
1033
+ =%%%%%#%%%##@@@@@%%%%===++=
1034
+ #%%%%%%%%##@@@@@%%%%*-=====
1035
+ =%%%%%%%%%#@@@@@%%%%%::-===
1036
+ =%%%#%%%#%@@@@@@@%%%=.:::-:
1037
+ +******#%%%%%%%#*=: . .
75
1038
  `;
1039
+ function censorUri(uri) {
1040
+ try {
1041
+ const url = new URL(uri);
1042
+ if (url.password) url.password = "***";
1043
+ return url.toString();
1044
+ } catch {
1045
+ return uri;
1046
+ }
1047
+ }
76
1048
  function printWorkerBanner(config) {
77
- const { queueName, concurrency, redis, jobs } = config;
1049
+ const { queueName, concurrency, redis, mongo, jobs } = config;
1050
+ console.log("\n\n" + RAVENFLOW_ASCII_ART);
78
1051
  console.log("\n" + RAVENFLOW_ASCII);
79
1052
  console.log("\u2550".repeat(85));
80
1053
  console.log("\n[Config]");
81
1054
  console.log(` -> queue: ${queueName}`);
82
1055
  console.log(` -> concurrency: ${concurrency}`);
83
1056
  console.log(` -> redis: ${redis.host}:${redis.port}/${redis.db}`);
1057
+ if (mongo) {
1058
+ console.log(` -> mongo: ${censorUri(mongo)}`);
1059
+ }
84
1060
  console.log("\n[Registered Jobs]");
85
1061
  const regularJobs = [];
86
1062
  const cronJobs = [];
@@ -105,198 +1081,388 @@ function printWorkerBanner(config) {
105
1081
  console.log("Ready to process jobs...\n");
106
1082
  }
107
1083
 
108
- // src/app.ts
109
- var RavenflowApp = class {
110
- constructor(connection, config, loggerOptions) {
111
- this.defaultConcurrency = 5;
112
- this.connection = connection;
113
- this.config = config;
114
- this.logger = createLogger(loggerOptions);
115
- if (!config.queues.includes(config.defaultQueue)) {
116
- throw new Error(
117
- `Default queue ${config.defaultQueue} is not in the list of queues`
118
- );
1084
+ // src/app/worker-manager.ts
1085
+ function createWorker(queueName, connection, concurrency, processJob2) {
1086
+ const worker = new bullmq.Worker(queueName, processJob2, {
1087
+ connection,
1088
+ autorun: false,
1089
+ concurrency
1090
+ });
1091
+ return worker;
1092
+ }
1093
+ function setupWorkerEvents(worker, queueName) {
1094
+ const logger2 = getLogger();
1095
+ worker.on("active", (job) => {
1096
+ logger2.info(`[${queueName}] ${job.name} - active`);
1097
+ });
1098
+ worker.on("completed", (job) => {
1099
+ logger2.info(`[${queueName}] ${job.name} - completed`);
1100
+ });
1101
+ worker.on("failed", (job, error) => {
1102
+ logger2.error(`[${queueName}] ${job?.name} - failed: ${error.message}`);
1103
+ });
1104
+ }
1105
+ function printBanner(queueName, concurrency, connection, jobConfigs, mongoUri) {
1106
+ const redisInfo = {
1107
+ host: typeof connection === "object" && "host" in connection ? connection.host : "localhost",
1108
+ port: typeof connection === "object" && "port" in connection ? connection.port : 6379,
1109
+ db: typeof connection === "object" && "db" in connection ? connection.db : 0
1110
+ };
1111
+ printWorkerBanner({
1112
+ queueName,
1113
+ concurrency,
1114
+ redis: redisInfo,
1115
+ mongo: mongoUri,
1116
+ jobs: jobConfigs
1117
+ });
1118
+ }
1119
+ async function closeWorkers(workers) {
1120
+ await Promise.all(workers.map((w) => w.close()));
1121
+ }
1122
+
1123
+ // src/app/scheduler.ts
1124
+ async function syncJobSchedulers(queue, jobConfigs) {
1125
+ const logger2 = getLogger();
1126
+ logger2.info("[Scheduled Jobs]");
1127
+ const jobConfigsWithCron = {};
1128
+ for (const jobName in jobConfigs) {
1129
+ if (jobConfigs[jobName].cron) {
1130
+ jobConfigsWithCron[jobName] = jobConfigs[jobName];
119
1131
  }
120
- const { jobsProxy, jobConfigs } = this.initJobs(config);
121
- this.jobs = jobsProxy;
122
- this.jobConfigs = jobConfigs;
123
- this.queues = this.initQueues(config);
124
- }
125
- initQueues(config) {
126
- const queues = {};
127
- for (const queueName of config.queues) {
128
- queues[queueName] = {
129
- queue: new bullmq.Queue(queueName, {
130
- connection: this.connection
131
- }),
132
- events: new bullmq.QueueEvents(queueName, {
133
- connection: this.connection
134
- })
135
- };
1132
+ }
1133
+ const registeredJobSchedulers = await queue.getJobSchedulers();
1134
+ const existingSchedulerKeys = new Set(registeredJobSchedulers.map((s) => s.key));
1135
+ const removed = [];
1136
+ for (const jobScheduler of registeredJobSchedulers) {
1137
+ if (!jobConfigsWithCron[jobScheduler.key]) {
1138
+ removed.push(jobScheduler.key);
1139
+ await queue.removeJobScheduler(jobScheduler.key);
136
1140
  }
137
- return queues;
138
1141
  }
139
- initJobs(config) {
140
- const jobsProxy = {};
141
- const jobConfigs = {};
142
- for (const [jobName, jobConfig] of Object.entries(config.jobs)) {
143
- jobConfigs[jobName] = jobConfig;
144
- jobsProxy[jobName] = {
145
- send: async (params) => {
146
- return this.sendJob(jobName, params);
147
- }
148
- };
1142
+ const newJobs = [];
1143
+ const updated = [];
1144
+ for (const jobName in jobConfigsWithCron) {
1145
+ const jobConfig = jobConfigsWithCron[jobName];
1146
+ if (!jobConfig?.cron) continue;
1147
+ if (existingSchedulerKeys.has(jobName)) {
1148
+ updated.push(jobName);
1149
+ } else {
1150
+ newJobs.push(jobName);
149
1151
  }
150
- return {
151
- jobsProxy,
152
- jobConfigs
153
- };
1152
+ await queue.upsertJobScheduler(jobName, { pattern: jobConfig.cron });
1153
+ }
1154
+ for (const name of newJobs) {
1155
+ logger2.info(` + ${name} \u2192 ${jobConfigsWithCron[name].cron}`);
154
1156
  }
155
- async sendJob(jobName, params) {
156
- const jobConfig = this.jobConfigs[jobName];
157
- if (!jobConfig) {
158
- throw new Error(`Job ${jobName} not found`);
1157
+ for (const name of updated) {
1158
+ logger2.info(` \u21BB ${name} \u2192 ${jobConfigsWithCron[name].cron}`);
1159
+ }
1160
+ for (const name of removed) {
1161
+ logger2.info(` - ${name}`);
1162
+ }
1163
+ if (newJobs.length === 0 && updated.length === 0 && removed.length === 0) {
1164
+ logger2.info(" (no scheduled jobs)");
1165
+ }
1166
+ }
1167
+
1168
+ // src/internal-jobs/stall-watcher/types.ts
1169
+ var STALL_CHECK = "__stall_check";
1170
+ var STALL_RESUME = "__stall_resume";
1171
+
1172
+ // src/internal-jobs/stall-watcher/check-stalled-runs.ts
1173
+ async function checkStalledRuns(statefulEtlConfigs, userQueues, workerQueue) {
1174
+ const logger2 = getLogger();
1175
+ const candidates = await run_repo_exports.findStalledCandidates();
1176
+ for (const candidate of candidates) {
1177
+ const entry = statefulEtlConfigs.get(candidate.jobName);
1178
+ if (!entry) {
1179
+ logger2.warn(
1180
+ { runId: candidate._id, jobName: candidate.jobName },
1181
+ "[stall-watcher] unknown job, marking failed"
1182
+ );
1183
+ await run_repo_exports.markFailed(
1184
+ candidate._id,
1185
+ "Job config not found during stall recovery"
1186
+ );
1187
+ continue;
159
1188
  }
160
- const queueData = this.queues[jobConfig.queueName];
1189
+ const queueData = userQueues[entry.queue];
161
1190
  if (!queueData) {
162
- throw new Error(`Queue ${jobConfig.queueName} not found`);
1191
+ logger2.error(
1192
+ { queueName: entry.queue, runId: candidate._id },
1193
+ "[stall-watcher] queue not found"
1194
+ );
1195
+ await run_repo_exports.markFailed(
1196
+ candidate._id,
1197
+ `Queue "${entry.queue}" not found during stall recovery`
1198
+ );
1199
+ continue;
163
1200
  }
164
- return await queueData.queue.add(jobName, params);
165
- }
166
- startWorker(queueName, options) {
167
- const queueConfig = this.queues[queueName];
168
- if (!queueConfig) {
169
- throw new Error(`Queue ${queueName} not found in config.`);
1201
+ if (await isJobActiveInRedis(queueData.queue, candidate.jobId)) {
1202
+ logger2.debug(
1203
+ { runId: candidate._id, jobId: candidate.jobId },
1204
+ "[stall-watcher] job still active in Redis, skipping"
1205
+ );
1206
+ continue;
170
1207
  }
171
- const workerConcurrency = options?.concurrency || this.defaultConcurrency;
172
- this.printWorkerBanner(queueName, workerConcurrency);
173
- this.syncJobSchedulers(queueName);
174
- const worker = new bullmq.Worker(queueName, (job) => this.processJob(job), {
175
- connection: this.connection,
176
- autorun: false,
177
- concurrency: workerConcurrency
1208
+ const claimed = await run_repo_exports.claimStalledRun(candidate._id);
1209
+ if (!claimed) continue;
1210
+ await workerQueue.add(STALL_RESUME, {
1211
+ runId: claimed._id,
1212
+ jobName: claimed.jobName,
1213
+ queueName: entry.queue,
1214
+ maxStallRetries: claimed.maxStallRetries,
1215
+ stallRetries: claimed.progress.stallRetries,
1216
+ checkpoint: claimed.progress.checkpoint,
1217
+ extractedCount: claimed.progress.extractedCount,
1218
+ transformedCount: claimed.progress.transformedCount,
1219
+ loadedCount: claimed.progress.loadedCount
178
1220
  });
179
- this.setupWorkerEvents(worker, queueName);
180
- worker.run();
181
1221
  }
182
- printWorkerBanner(queueName, concurrency) {
183
- const redisInfo = {
184
- host: typeof this.connection === "object" && "host" in this.connection ? this.connection.host : "localhost",
185
- port: typeof this.connection === "object" && "port" in this.connection ? this.connection.port : 6379,
186
- db: typeof this.connection === "object" && "db" in this.connection ? this.connection.db : 0
187
- };
188
- printWorkerBanner({
189
- queueName,
190
- concurrency,
191
- redis: redisInfo,
192
- jobs: this.jobConfigs
193
- });
1222
+ }
1223
+ async function isJobActiveInRedis(queue, jobId) {
1224
+ try {
1225
+ const state = await queue.getJobState(jobId);
1226
+ return state === "active";
1227
+ } catch {
1228
+ return false;
194
1229
  }
195
- setupWorkerEvents(worker, queueName) {
196
- worker.on("active", (job) => {
197
- this.logger.info(`[${queueName}] ${job.name} - active`);
198
- });
199
- worker.on("completed", (job) => {
200
- this.logger.info(`[${queueName}] ${job.name} - completed`);
201
- });
202
- worker.on("failed", (job, error) => {
203
- this.logger.error(
204
- `[${queueName}] ${job?.name} - failed: ${error.message}`
205
- );
206
- });
1230
+ }
1231
+
1232
+ // src/internal-jobs/stall-watcher/resume-stalled-run.ts
1233
+ async function resumeStalledRun(data, userQueues) {
1234
+ const logger2 = getLogger();
1235
+ if (data.stallRetries > data.maxStallRetries) {
1236
+ logger2.warn(
1237
+ {
1238
+ runId: data.runId,
1239
+ jobName: data.jobName,
1240
+ stallRetries: data.stallRetries
1241
+ },
1242
+ "[stall-watcher] max stall retries exceeded, marking failed"
1243
+ );
1244
+ await run_repo_exports.markFailed(
1245
+ data.runId,
1246
+ `Stall recovery exhausted after ${data.stallRetries} retries`
1247
+ );
1248
+ return;
207
1249
  }
208
- async syncJobSchedulers(queueName) {
209
- const queueData = this.queues[queueName];
210
- if (!queueData) {
211
- throw new Error(`Queue ${queueName} not found in config.`);
212
- }
213
- const queue = queueData.queue;
214
- console.log("[Scheduled Jobs]");
215
- const jobConfigsWithCron = {};
216
- for (const jobName in this.jobConfigs) {
217
- const jobConfig = this.jobConfigs[jobName];
218
- if (jobConfig.cron) {
219
- jobConfigsWithCron[jobName] = jobConfig;
220
- }
221
- }
222
- const registeredJobSchedulers = await queue.getJobSchedulers();
223
- const existingSchedulerKeys = new Set(
224
- registeredJobSchedulers.map((s) => s.key)
1250
+ const queueData = userQueues[data.queueName];
1251
+ if (!queueData) {
1252
+ logger2.error(
1253
+ { queueName: data.queueName, runId: data.runId },
1254
+ "[stall-watcher] queue not found for resume"
225
1255
  );
226
- const removed = [];
227
- for (const jobScheduler of registeredJobSchedulers) {
228
- if (!jobConfigsWithCron[jobScheduler.key]) {
229
- removed.push(jobScheduler.key);
230
- await queue.removeJobScheduler(jobScheduler.key);
231
- }
1256
+ await run_repo_exports.markFailed(
1257
+ data.runId,
1258
+ `Queue "${data.queueName}" not found`
1259
+ );
1260
+ return;
1261
+ }
1262
+ logger2.info(
1263
+ {
1264
+ runId: data.runId,
1265
+ jobName: data.jobName,
1266
+ stallRetries: data.stallRetries,
1267
+ checkpoint: data.checkpoint
1268
+ },
1269
+ "[stall-watcher] re-enqueuing stalled run"
1270
+ );
1271
+ await queueData.queue.add(data.jobName, {
1272
+ __ravenflow_resume: true,
1273
+ runId: data.runId,
1274
+ checkpoint: data.checkpoint,
1275
+ counts: {
1276
+ extractedCount: data.extractedCount,
1277
+ transformedCount: data.transformedCount,
1278
+ loadedCount: data.loadedCount
232
1279
  }
233
- const newJobs = [];
234
- const updated = [];
235
- for (const jobName in jobConfigsWithCron) {
236
- const jobConfig = jobConfigsWithCron[jobName];
237
- if (!jobConfig?.cron) continue;
238
- if (existingSchedulerKeys.has(jobName)) {
239
- updated.push(jobName);
240
- } else {
241
- newJobs.push(jobName);
1280
+ });
1281
+ }
1282
+
1283
+ // src/internal-jobs/stall-watcher/index.ts
1284
+ var StallWatcher = class {
1285
+ constructor(jobConfigs, userQueues, defaultQueue, workerQueue) {
1286
+ this.userQueues = userQueues;
1287
+ this.workerQueue = workerQueue;
1288
+ this.statefulEtlConfigs = /* @__PURE__ */ new Map();
1289
+ for (const [jobName, config] of Object.entries(jobConfigs)) {
1290
+ if (config.type === "stateful-etl") {
1291
+ this.statefulEtlConfigs.set(jobName, {
1292
+ config,
1293
+ queue: config.queue ?? defaultQueue
1294
+ });
242
1295
  }
243
- await queue.upsertJobScheduler(jobName, { pattern: jobConfig.cron });
244
1296
  }
245
- if (newJobs.length > 0) {
246
- newJobs.forEach(
247
- (name) => console.log(` + ${name} \u2192 ${jobConfigsWithCron[name].cron}`)
248
- );
1297
+ }
1298
+ canProcess(jobName) {
1299
+ return jobName === STALL_CHECK || jobName === STALL_RESUME;
1300
+ }
1301
+ async start() {
1302
+ if (this.statefulEtlConfigs.size === 0) return;
1303
+ const logger2 = getLogger();
1304
+ logger2.info(
1305
+ { jobs: [...this.statefulEtlConfigs.keys()] },
1306
+ "[stall-watcher] starting"
1307
+ );
1308
+ await this.workerQueue.upsertJobScheduler(STALL_CHECK, {
1309
+ pattern: "* * * * *"
1310
+ });
1311
+ }
1312
+ async stop() {
1313
+ await this.workerQueue.removeJobScheduler(STALL_CHECK);
1314
+ }
1315
+ async process(job) {
1316
+ switch (job.name) {
1317
+ case STALL_CHECK:
1318
+ return checkStalledRuns(
1319
+ this.statefulEtlConfigs,
1320
+ this.userQueues,
1321
+ this.workerQueue
1322
+ );
1323
+ case STALL_RESUME:
1324
+ return resumeStalledRun(
1325
+ job.data,
1326
+ this.userQueues
1327
+ );
249
1328
  }
250
- if (updated.length > 0) {
251
- updated.forEach(
252
- (name) => console.log(` \u21BB ${name} \u2192 ${jobConfigsWithCron[name].cron}`)
1329
+ }
1330
+ };
1331
+
1332
+ // src/app/app.ts
1333
+ var MONGO_REQUIRED_TYPES = /* @__PURE__ */ new Set(["stateful-etl", "token-bucket"]);
1334
+ function resolveMongoUri(mongo) {
1335
+ if (!mongo) return null;
1336
+ if (typeof mongo === "string") return mongo;
1337
+ const { host, port = 27017, username, password, database } = mongo;
1338
+ const auth = username && password ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` : "";
1339
+ const db2 = database ? `/${database}` : "";
1340
+ return `mongodb://${auth}${host}:${port}${db2}`;
1341
+ }
1342
+ function validateConfig(config, mongoUri) {
1343
+ const errors = [];
1344
+ for (const [jobName, jobConfig] of Object.entries(config.jobs)) {
1345
+ const jc = jobConfig;
1346
+ if (MONGO_REQUIRED_TYPES.has(jc.type) && !mongoUri) {
1347
+ errors.push(
1348
+ `"${jobName}" (${jc.type}) requires a mongo connection. Pass { mongo: "mongodb://..." } to createRavenflowApp().`
253
1349
  );
254
1350
  }
255
- if (removed.length > 0) {
256
- removed.forEach((name) => console.log(` - ${name}`));
1351
+ if (jc.type === "token-bucket" && !jc.cron) {
1352
+ errors.push(`"${jobName}" (token-bucket) requires a cron schedule.`);
257
1353
  }
258
- if (newJobs.length === 0 && updated.length === 0 && removed.length === 0) {
259
- console.log(` (no scheduled jobs)`);
1354
+ if ("steps" in jc && jc.steps) {
1355
+ for (const [stepName, stepConfig] of Object.entries(jc.steps)) {
1356
+ if (stepConfig?.queue && stepConfig.queue !== config.defaultQueue) {
1357
+ const knownQueues = /* @__PURE__ */ new Set([config.defaultQueue]);
1358
+ for (const j of Object.values(config.jobs)) {
1359
+ if (j.queue) knownQueues.add(j.queue);
1360
+ }
1361
+ if (!knownQueues.has(stepConfig.queue)) {
1362
+ errors.push(
1363
+ `"${jobName}" step "${stepName}" references queue "${stepConfig.queue}" which is not declared by any job or as defaultQueue.`
1364
+ );
1365
+ }
1366
+ }
1367
+ }
260
1368
  }
261
- console.log("");
262
1369
  }
263
- async processJob(job) {
264
- const isEtlTransformJob = job.name.endsWith(":transform");
265
- const jobName = isEtlTransformJob ? job.name.slice(0, -":transform".length) : job.name;
266
- const jobConfig = this.jobConfigs[jobName];
267
- if (!jobConfig) {
268
- throw new Error(`Job handler not found: ${jobName}`);
1370
+ if (errors.length > 0) {
1371
+ throw new Error(
1372
+ `[ravenflow] Config validation failed:
1373
+ - ${errors.join("\n - ")}`
1374
+ );
1375
+ }
1376
+ }
1377
+ var RavenflowApp = class {
1378
+ constructor(config, options) {
1379
+ this.defaultConcurrency = 5;
1380
+ this.workers = [];
1381
+ this.stallWatcher = null;
1382
+ this.redisConnection = options.redis;
1383
+ this.config = config;
1384
+ this.mongoUri = resolveMongoUri(options.mongo);
1385
+ initLogger(options.logger);
1386
+ validateConfig(config, this.mongoUri);
1387
+ this.queues = initQueues(config, options.redis, options.retention);
1388
+ const { jobsProxy, jobConfigs } = initJobs(config, this.queues);
1389
+ this.jobs = jobsProxy;
1390
+ this.jobConfigs = jobConfigs;
1391
+ }
1392
+ async startWorker(queueName, options) {
1393
+ if (this.mongoUri) {
1394
+ await connectMongo(this.mongoUri);
269
1395
  }
270
- if (jobConfig.type === "job") {
271
- return await jobConfig.handler(job, job.data);
1396
+ const queueData = this.queues[queueName];
1397
+ if (!queueData) {
1398
+ throw new Error(`Queue ${queueName} not found in config.`);
272
1399
  }
273
- if (jobConfig.type === "etl" && !isEtlTransformJob) {
274
- const transformQueueName = jobConfig.transformQueue || jobConfig.queueName;
275
- const queueData = this.queues[transformQueueName];
276
- if (!queueData) {
277
- throw new Error(`Queue ${transformQueueName} not found in config.`);
278
- }
279
- return await processEtlJob(
280
- job,
281
- jobConfig,
282
- queueData.queue,
283
- queueData.events
1400
+ const concurrency = options?.concurrency ?? this.defaultConcurrency;
1401
+ printBanner(
1402
+ queueName,
1403
+ concurrency,
1404
+ this.redisConnection,
1405
+ this.jobConfigs,
1406
+ this.mongoUri
1407
+ );
1408
+ await syncJobSchedulers(queueData.queue, this.jobConfigs);
1409
+ const hasStatefulEtl = Object.values(this.jobConfigs).some(
1410
+ (c) => c.type === "stateful-etl"
1411
+ );
1412
+ if (hasStatefulEtl && !this.stallWatcher) {
1413
+ this.stallWatcher = new StallWatcher(
1414
+ this.jobConfigs,
1415
+ this.queues,
1416
+ this.config.defaultQueue,
1417
+ queueData.queue
284
1418
  );
285
1419
  }
286
- if (jobConfig.type === "etl" && isEtlTransformJob) {
287
- return await jobConfig.transform(job.data);
1420
+ const worker = createWorker(
1421
+ queueName,
1422
+ this.redisConnection,
1423
+ concurrency,
1424
+ async (job) => {
1425
+ if (this.stallWatcher?.canProcess(job.name)) {
1426
+ return this.stallWatcher.process(job);
1427
+ }
1428
+ return processJob(job, this.jobConfigs, this.queues, this.config.defaultQueue);
1429
+ }
1430
+ );
1431
+ setupWorkerEvents(worker, queueName);
1432
+ this.workers.push(worker);
1433
+ worker.run();
1434
+ if (this.stallWatcher) {
1435
+ await this.stallWatcher.start();
1436
+ }
1437
+ }
1438
+ async close() {
1439
+ const logger2 = getLogger();
1440
+ logger2.info("Shutting down...");
1441
+ if (this.stallWatcher) {
1442
+ await this.stallWatcher.stop();
1443
+ this.stallWatcher = null;
288
1444
  }
289
- throw new Error(`Unknown job type: ${jobConfig.type}`);
1445
+ await closeWorkers(this.workers);
1446
+ await closeQueues(this.queues);
1447
+ await closeMongo();
1448
+ logger2.info("Shutdown complete.");
290
1449
  }
291
1450
  };
292
1451
 
293
1452
  // src/index.ts
294
- function createRavenflowApp(config, connection, loggerOptions) {
295
- return new RavenflowApp(connection, config, loggerOptions);
1453
+ function createRavenflowApp(config, options) {
1454
+ return new RavenflowApp(config, options);
296
1455
  }
297
1456
 
298
1457
  exports.RavenflowApp = RavenflowApp;
299
- exports.createLogger = createLogger;
300
1458
  exports.createRavenflowApp = createRavenflowApp;
1459
+ exports.defineConfig = defineConfig;
1460
+ exports.defineDistributedEtlJob = defineDistributedEtlJob;
1461
+ exports.defineMemoryEtlJob = defineMemoryEtlJob;
1462
+ exports.defineSimpleJob = defineSimpleJob;
1463
+ exports.defineStatefulEtlJob = defineStatefulEtlJob;
1464
+ exports.defineTokenBucketJob = defineTokenBucketJob;
1465
+ exports.getLogger = getLogger;
1466
+ exports.initLogger = initLogger;
301
1467
  //# sourceMappingURL=index.js.map
302
1468
  //# sourceMappingURL=index.js.map