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/README.md +294 -30
- package/dist/index.d.mts +181 -47
- package/dist/index.d.ts +181 -47
- package/dist/index.js +1369 -203
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1362 -203
- package/dist/index.mjs.map +1 -1
- package/package.json +15 -11
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 =
|
|
1189
|
+
const queueData = userQueues[entry.queue];
|
|
161
1190
|
if (!queueData) {
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 (
|
|
256
|
-
|
|
1351
|
+
if (jc.type === "token-bucket" && !jc.cron) {
|
|
1352
|
+
errors.push(`"${jobName}" (token-bucket) requires a cron schedule.`);
|
|
257
1353
|
}
|
|
258
|
-
if (
|
|
259
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
1396
|
+
const queueData = this.queues[queueName];
|
|
1397
|
+
if (!queueData) {
|
|
1398
|
+
throw new Error(`Queue ${queueName} not found in config.`);
|
|
272
1399
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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,
|
|
295
|
-
return new RavenflowApp(
|
|
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
|