ravenflow 0.2.0 → 0.3.1
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/LICENSE +21 -0
- package/README.md +22 -0
- package/dist/index.d.mts +475 -19
- package/dist/index.d.ts +475 -19
- package/dist/index.js +1664 -150
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1628 -153
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -2
package/dist/index.js
CHANGED
|
@@ -4,10 +4,32 @@ var pino = require('pino');
|
|
|
4
4
|
var bullmq = require('bullmq');
|
|
5
5
|
var zod = require('zod');
|
|
6
6
|
var mongodb = require('mongodb');
|
|
7
|
+
var crypto = require('crypto');
|
|
8
|
+
var os = require('os');
|
|
7
9
|
|
|
8
10
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
9
11
|
|
|
12
|
+
function _interopNamespace(e) {
|
|
13
|
+
if (e && e.__esModule) return e;
|
|
14
|
+
var n = Object.create(null);
|
|
15
|
+
if (e) {
|
|
16
|
+
Object.keys(e).forEach(function (k) {
|
|
17
|
+
if (k !== 'default') {
|
|
18
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
19
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
20
|
+
enumerable: true,
|
|
21
|
+
get: function () { return e[k]; }
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
n.default = e;
|
|
27
|
+
return Object.freeze(n);
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
var pino__default = /*#__PURE__*/_interopDefault(pino);
|
|
31
|
+
var crypto__namespace = /*#__PURE__*/_interopNamespace(crypto);
|
|
32
|
+
var os__namespace = /*#__PURE__*/_interopNamespace(os);
|
|
11
33
|
|
|
12
34
|
var __defProp = Object.defineProperty;
|
|
13
35
|
var __export = (target, all) => {
|
|
@@ -88,11 +110,13 @@ async function closeQueues(queues) {
|
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
// src/processors/token-bucket-job.processor/constants.ts
|
|
91
|
-
var
|
|
113
|
+
var TOKEN_BUCKET_CONSUME_SUFFIX = ":consume";
|
|
92
114
|
var INTERVAL_MS = {
|
|
115
|
+
second: 1e3,
|
|
93
116
|
minute: 6e4,
|
|
94
117
|
hour: 36e5,
|
|
95
|
-
day: 864e5
|
|
118
|
+
day: 864e5,
|
|
119
|
+
week: 6048e5
|
|
96
120
|
};
|
|
97
121
|
|
|
98
122
|
// src/processors/constants.ts
|
|
@@ -178,9 +202,7 @@ function defineStatefulEtlJob(config) {
|
|
|
178
202
|
zod.z.object({
|
|
179
203
|
_id: zod.z.string(),
|
|
180
204
|
accumulatedCredit: zod.z.number(),
|
|
181
|
-
lastTickAt: zod.z.date()
|
|
182
|
-
windowDispatched: zod.z.number().int().nonnegative(),
|
|
183
|
-
windowResetAt: zod.z.date()
|
|
205
|
+
lastTickAt: zod.z.date()
|
|
184
206
|
});
|
|
185
207
|
function defineTokenBucketJob(config) {
|
|
186
208
|
return { ...config, type: "token-bucket" };
|
|
@@ -296,18 +318,6 @@ async function dispatchSubJob(queue, queueEvents, name, data, options) {
|
|
|
296
318
|
});
|
|
297
319
|
return await job.waitUntilFinished(queueEvents);
|
|
298
320
|
}
|
|
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
321
|
|
|
312
322
|
// src/processors/etl-context.ts
|
|
313
323
|
var EtlContext = class {
|
|
@@ -630,6 +640,7 @@ async function markResuming(runId) {
|
|
|
630
640
|
// src/mongo/token-bucket.repo.ts
|
|
631
641
|
var token_bucket_repo_exports = {};
|
|
632
642
|
__export(token_bucket_repo_exports, {
|
|
643
|
+
ensureIndexes: () => ensureIndexes,
|
|
633
644
|
getOrCreateState: () => getOrCreateState,
|
|
634
645
|
updateState: () => updateState
|
|
635
646
|
});
|
|
@@ -637,35 +648,33 @@ var COLLECTION = "token_bucket_state";
|
|
|
637
648
|
function collection2() {
|
|
638
649
|
return getDb().collection(collectionName(COLLECTION));
|
|
639
650
|
}
|
|
640
|
-
async function getOrCreateState(jobName, now
|
|
651
|
+
async function getOrCreateState(jobName, now) {
|
|
641
652
|
const result = await collection2().findOneAndUpdate(
|
|
642
653
|
{ _id: jobName },
|
|
643
654
|
{
|
|
644
655
|
$setOnInsert: {
|
|
645
656
|
_id: jobName,
|
|
646
657
|
accumulatedCredit: 0,
|
|
647
|
-
lastTickAt: now
|
|
648
|
-
windowDispatched: 0,
|
|
649
|
-
windowResetAt
|
|
658
|
+
lastTickAt: now
|
|
650
659
|
}
|
|
651
660
|
},
|
|
652
661
|
{ upsert: true, returnDocument: "after" }
|
|
653
662
|
);
|
|
654
663
|
return result;
|
|
655
664
|
}
|
|
656
|
-
async function updateState(jobName, accumulatedCredit, lastTickAt
|
|
665
|
+
async function updateState(jobName, accumulatedCredit, lastTickAt) {
|
|
657
666
|
await collection2().updateOne(
|
|
658
667
|
{ _id: jobName },
|
|
659
668
|
{
|
|
660
669
|
$set: {
|
|
661
670
|
accumulatedCredit,
|
|
662
|
-
lastTickAt
|
|
663
|
-
windowDispatched,
|
|
664
|
-
windowResetAt
|
|
671
|
+
lastTickAt
|
|
665
672
|
}
|
|
666
673
|
}
|
|
667
674
|
);
|
|
668
675
|
}
|
|
676
|
+
async function ensureIndexes() {
|
|
677
|
+
}
|
|
669
678
|
|
|
670
679
|
// src/processors/stateful-etl-job.processor/constants.ts
|
|
671
680
|
var DEFAULT_STALL_TIMEOUT_MS = 3e5;
|
|
@@ -791,128 +800,52 @@ async function ensureNotAlreadyRunning(jobName) {
|
|
|
791
800
|
}
|
|
792
801
|
}
|
|
793
802
|
|
|
794
|
-
// src/processors/token-bucket-job.processor/
|
|
795
|
-
function
|
|
796
|
-
|
|
797
|
-
const
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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]);
|
|
803
|
+
// src/processors/token-bucket-job.processor/compute-credit.ts
|
|
804
|
+
function computeCredit(input) {
|
|
805
|
+
const { now, rate, lastTickAt, accumulatedCredit, maxCarryOver, budget } = input;
|
|
806
|
+
const elapsedMs = Math.max(0, now.getTime() - lastTickAt.getTime());
|
|
807
|
+
const tickCredit = rate ? rate.amount * elapsedMs / INTERVAL_MS[rate.interval] : 0;
|
|
808
|
+
const totalCredit = accumulatedCredit + tickCredit;
|
|
809
|
+
const cappedCredit = rate ? Math.min(totalCredit, tickCredit * (1 + maxCarryOver / 100)) : totalCredit;
|
|
810
|
+
const availableCredit = budget !== void 0 ? Math.min(cappedCredit, budget) : cappedCredit;
|
|
811
|
+
const tokens = Math.floor(availableCredit);
|
|
812
|
+
const newCredit = cappedCredit - tokens;
|
|
813
|
+
return { tokens, newCredit };
|
|
875
814
|
}
|
|
876
815
|
|
|
877
|
-
// src/processors/token-bucket-job.processor/
|
|
816
|
+
// src/processors/token-bucket-job.processor/index.ts
|
|
878
817
|
async function processTokenBucketJob(job, config, queues) {
|
|
879
818
|
const logger2 = getLogger();
|
|
880
|
-
const
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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 };
|
|
819
|
+
const tickStartedAt = /* @__PURE__ */ new Date();
|
|
820
|
+
const state = await token_bucket_repo_exports.getOrCreateState(job.name, tickStartedAt);
|
|
821
|
+
const budget = config.getBudget ? await config.getBudget() : void 0;
|
|
822
|
+
const { tokens, newCredit } = computeCredit({
|
|
823
|
+
now: tickStartedAt,
|
|
824
|
+
rate: config.rate,
|
|
825
|
+
lastTickAt: state.lastTickAt,
|
|
826
|
+
accumulatedCredit: state.accumulatedCredit,
|
|
827
|
+
maxCarryOver: config.maxCarryOver ?? 100,
|
|
828
|
+
budget
|
|
829
|
+
});
|
|
830
|
+
if (tokens === 0) {
|
|
831
|
+
await token_bucket_repo_exports.updateState(job.name, newCredit, tickStartedAt);
|
|
832
|
+
logger2.debug({ jobName: job.name, newCredit }, "[token-bucket] zero tokens, skipping consume");
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
const queueData = queues[config.queue];
|
|
837
|
+
const consumeJob = await queueData.queue.add(
|
|
838
|
+
`${job.name}${TOKEN_BUCKET_CONSUME_SUFFIX}`,
|
|
839
|
+
tokens
|
|
840
|
+
);
|
|
841
|
+
const result = await consumeJob.waitUntilFinished(queueData.events, config.consumeTimeout);
|
|
842
|
+
const consumed = typeof result === "number" ? result : tokens;
|
|
843
|
+
const finalCredit = newCredit + (tokens - consumed);
|
|
844
|
+
await token_bucket_repo_exports.updateState(job.name, finalCredit, tickStartedAt);
|
|
845
|
+
logger2.info({ jobName: job.name, tokens, consumed, finalCredit }, "[token-bucket] tick complete");
|
|
846
|
+
} catch (error) {
|
|
847
|
+
logger2.warn({ jobName: job.name, error: String(error) }, "[token-bucket] consume failed, skipping state update");
|
|
848
|
+
}
|
|
916
849
|
}
|
|
917
850
|
|
|
918
851
|
// src/app/job-registry.ts
|
|
@@ -940,7 +873,7 @@ var ALL_SUFFIXES = [
|
|
|
940
873
|
ETL_EXTRACT_SUFFIX,
|
|
941
874
|
ETL_TRANSFORM_SUFFIX,
|
|
942
875
|
ETL_LOAD_SUFFIX,
|
|
943
|
-
|
|
876
|
+
TOKEN_BUCKET_CONSUME_SUFFIX
|
|
944
877
|
];
|
|
945
878
|
function resolveJobName(name, jobConfigs) {
|
|
946
879
|
for (const suffix of ALL_SUFFIXES) {
|
|
@@ -980,8 +913,8 @@ async function processJob(job, jobConfigs, queues, defaultQueue) {
|
|
|
980
913
|
}
|
|
981
914
|
}
|
|
982
915
|
if (jobConfig.type === "token-bucket") {
|
|
983
|
-
if (suffix ===
|
|
984
|
-
return await jobConfig.
|
|
916
|
+
if (suffix === TOKEN_BUCKET_CONSUME_SUFFIX) {
|
|
917
|
+
return await jobConfig.consume(job.data);
|
|
985
918
|
}
|
|
986
919
|
}
|
|
987
920
|
throw new Error(`Unexpected sub-job suffix "${suffix}" for ${baseName} (type: ${jobConfig.type})`);
|
|
@@ -1082,11 +1015,27 @@ function printWorkerBanner(config) {
|
|
|
1082
1015
|
}
|
|
1083
1016
|
|
|
1084
1017
|
// src/app/worker-manager.ts
|
|
1085
|
-
|
|
1018
|
+
var METRICS_TIME = {
|
|
1019
|
+
ONE_MINUTE: 1,
|
|
1020
|
+
FIVE_MINUTES: 5,
|
|
1021
|
+
FIFTEEN_MINUTES: 15,
|
|
1022
|
+
THIRTY_MINUTES: 30,
|
|
1023
|
+
ONE_HOUR: 60,
|
|
1024
|
+
ONE_DAY: 1440,
|
|
1025
|
+
ONE_WEEK: 10080,
|
|
1026
|
+
TWO_WEEKS: 20160,
|
|
1027
|
+
ONE_MONTH: 80640
|
|
1028
|
+
};
|
|
1029
|
+
var DEFAULT_WORKER_METRICS = Object.freeze({
|
|
1030
|
+
maxDataPoints: METRICS_TIME.ONE_WEEK
|
|
1031
|
+
});
|
|
1032
|
+
function createWorker(queueName, connection, concurrency, processJob2, workerOpts) {
|
|
1086
1033
|
const worker = new bullmq.Worker(queueName, processJob2, {
|
|
1034
|
+
...workerOpts,
|
|
1087
1035
|
connection,
|
|
1088
1036
|
autorun: false,
|
|
1089
|
-
concurrency
|
|
1037
|
+
concurrency,
|
|
1038
|
+
metrics: DEFAULT_WORKER_METRICS
|
|
1090
1039
|
});
|
|
1091
1040
|
return worker;
|
|
1092
1041
|
}
|
|
@@ -1289,6 +1238,7 @@ var StallWatcher = class {
|
|
|
1289
1238
|
for (const [jobName, config] of Object.entries(jobConfigs)) {
|
|
1290
1239
|
if (config.type === "stateful-etl") {
|
|
1291
1240
|
this.statefulEtlConfigs.set(jobName, {
|
|
1241
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic erasure at runtime; typed config-walker deferred per Story 1.8 AC #15
|
|
1292
1242
|
config,
|
|
1293
1243
|
queue: config.queue ?? defaultQueue
|
|
1294
1244
|
});
|
|
@@ -1329,7 +1279,1355 @@ var StallWatcher = class {
|
|
|
1329
1279
|
}
|
|
1330
1280
|
};
|
|
1331
1281
|
|
|
1282
|
+
// src/mongo/repositories/_base.ts
|
|
1283
|
+
var Repository = class {
|
|
1284
|
+
constructor(db2, rawCollectionName) {
|
|
1285
|
+
this.collection = db2.collection(collectionName(rawCollectionName));
|
|
1286
|
+
}
|
|
1287
|
+
async init() {
|
|
1288
|
+
for (const idx of this.indexes()) {
|
|
1289
|
+
await this.collection.createIndex(idx.keys, idx.options);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
// src/mongo/repositories/workers.repo.ts
|
|
1295
|
+
var COLLECTION2 = "workers";
|
|
1296
|
+
var WorkersRepo = class extends Repository {
|
|
1297
|
+
constructor(db2) {
|
|
1298
|
+
super(db2, COLLECTION2);
|
|
1299
|
+
}
|
|
1300
|
+
indexes() {
|
|
1301
|
+
return [
|
|
1302
|
+
{
|
|
1303
|
+
keys: { workerId: 1 },
|
|
1304
|
+
options: { unique: true, name: "workerId_unique" }
|
|
1305
|
+
},
|
|
1306
|
+
{
|
|
1307
|
+
keys: { lastHeartbeatAt: 1 },
|
|
1308
|
+
options: { name: "lastHeartbeatAt_asc" }
|
|
1309
|
+
}
|
|
1310
|
+
];
|
|
1311
|
+
}
|
|
1312
|
+
async registerWorker(input) {
|
|
1313
|
+
this.validateRegisterInput(input);
|
|
1314
|
+
const filter = { workerId: input.workerId };
|
|
1315
|
+
const update = {
|
|
1316
|
+
$currentDate: {
|
|
1317
|
+
lastHeartbeatAt: true,
|
|
1318
|
+
updatedAt: true
|
|
1319
|
+
},
|
|
1320
|
+
$setOnInsert: {
|
|
1321
|
+
workerId: input.workerId,
|
|
1322
|
+
hostname: input.hostname,
|
|
1323
|
+
pid: input.pid,
|
|
1324
|
+
queues: input.queues,
|
|
1325
|
+
startedAt: input.startedAt,
|
|
1326
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
await this.collection.updateOne(filter, update, { upsert: true });
|
|
1330
|
+
}
|
|
1331
|
+
async updateHeartbeat(workerId) {
|
|
1332
|
+
if (typeof workerId !== "string" || workerId.length === 0) {
|
|
1333
|
+
throw new Error("WorkersRepo.updateHeartbeat: workerId must be a non-empty string");
|
|
1334
|
+
}
|
|
1335
|
+
const filter = { workerId };
|
|
1336
|
+
const update = {
|
|
1337
|
+
$currentDate: { lastHeartbeatAt: true, updatedAt: true }
|
|
1338
|
+
};
|
|
1339
|
+
const result = await this.collection.updateOne(filter, update);
|
|
1340
|
+
if (result.matchedCount === 0) {
|
|
1341
|
+
getLogger().warn(
|
|
1342
|
+
{ workerId },
|
|
1343
|
+
"[workers-repo] heartbeat on missing worker record \u2014 ignoring"
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Appends or replaces a queue entry on the worker's record.
|
|
1349
|
+
*
|
|
1350
|
+
* Single-writer invariant: this method is NOT atomic under concurrent writers to the
|
|
1351
|
+
* SAME `workerId`. WorkerRegistry is a process-local singleton, and `workerId` is
|
|
1352
|
+
* unique per process by contract (default: `<hostname>:<pid>:<uuid>`). Sharing a
|
|
1353
|
+
* stable `workerId` across processes violates AC #6 and will race last-writer-wins.
|
|
1354
|
+
*/
|
|
1355
|
+
async addQueue(input) {
|
|
1356
|
+
if (typeof input.workerId !== "string" || input.workerId.length === 0) {
|
|
1357
|
+
throw new Error("WorkersRepo.addQueue: workerId must be a non-empty string");
|
|
1358
|
+
}
|
|
1359
|
+
this.validateQueueEntry(input.queue, "WorkersRepo.addQueue");
|
|
1360
|
+
const existingFilter = { workerId: input.workerId };
|
|
1361
|
+
const existing = await this.collection.findOne(existingFilter);
|
|
1362
|
+
if (existing === null) {
|
|
1363
|
+
throw new Error(
|
|
1364
|
+
`WorkersRepo.addQueue: worker not found for workerId=${input.workerId}`
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
const existingQueues = existing.queues ?? [];
|
|
1368
|
+
const matchIndex = existingQueues.findIndex((q) => q.name === input.queue.name);
|
|
1369
|
+
const newQueues = matchIndex === -1 ? [...existingQueues, input.queue] : existingQueues.map((q, i) => i === matchIndex ? input.queue : q);
|
|
1370
|
+
const update = {
|
|
1371
|
+
$set: { queues: newQueues },
|
|
1372
|
+
$currentDate: { updatedAt: true }
|
|
1373
|
+
};
|
|
1374
|
+
await this.collection.updateOne(existingFilter, update);
|
|
1375
|
+
}
|
|
1376
|
+
async deregisterWorker(workerId) {
|
|
1377
|
+
if (typeof workerId !== "string" || workerId.length === 0) {
|
|
1378
|
+
throw new Error("WorkersRepo.deregisterWorker: workerId must be a non-empty string");
|
|
1379
|
+
}
|
|
1380
|
+
const filter = { workerId };
|
|
1381
|
+
await this.collection.deleteOne(filter);
|
|
1382
|
+
}
|
|
1383
|
+
async findAlive(maxHeartbeatAgeMs) {
|
|
1384
|
+
this.validateMaxHeartbeatAgeMs(maxHeartbeatAgeMs, "WorkersRepo.findAlive");
|
|
1385
|
+
const threshold = new Date(Date.now() - maxHeartbeatAgeMs);
|
|
1386
|
+
const filter = { lastHeartbeatAt: { $gte: threshold } };
|
|
1387
|
+
return await this.collection.find(filter).sort({ lastHeartbeatAt: -1 }).toArray();
|
|
1388
|
+
}
|
|
1389
|
+
async cleanupStale(maxHeartbeatAgeMs) {
|
|
1390
|
+
this.validateMaxHeartbeatAgeMs(maxHeartbeatAgeMs, "WorkersRepo.cleanupStale");
|
|
1391
|
+
const threshold = new Date(Date.now() - maxHeartbeatAgeMs);
|
|
1392
|
+
const filter = { lastHeartbeatAt: { $lt: threshold } };
|
|
1393
|
+
const result = await this.collection.deleteMany(filter);
|
|
1394
|
+
return result.deletedCount ?? 0;
|
|
1395
|
+
}
|
|
1396
|
+
validateRegisterInput(input) {
|
|
1397
|
+
if (typeof input.workerId !== "string" || input.workerId.length === 0) {
|
|
1398
|
+
throw new Error("WorkersRepo.registerWorker: workerId must be a non-empty string");
|
|
1399
|
+
}
|
|
1400
|
+
if (typeof input.hostname !== "string" || input.hostname.length === 0) {
|
|
1401
|
+
throw new Error("WorkersRepo.registerWorker: hostname must be a non-empty string");
|
|
1402
|
+
}
|
|
1403
|
+
if (!Number.isInteger(input.pid) || input.pid < 0) {
|
|
1404
|
+
throw new Error("WorkersRepo.registerWorker: pid must be a non-negative integer");
|
|
1405
|
+
}
|
|
1406
|
+
if (!Array.isArray(input.queues) || input.queues.length === 0) {
|
|
1407
|
+
throw new Error("WorkersRepo.registerWorker: queues must be a non-empty array");
|
|
1408
|
+
}
|
|
1409
|
+
for (const q of input.queues) {
|
|
1410
|
+
this.validateQueueEntry(q, "WorkersRepo.registerWorker");
|
|
1411
|
+
}
|
|
1412
|
+
if (!(input.startedAt instanceof Date) || Number.isNaN(input.startedAt.getTime())) {
|
|
1413
|
+
throw new Error("WorkersRepo.registerWorker: startedAt must be a valid Date");
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
validateQueueEntry(q, ctx) {
|
|
1417
|
+
if (q === null || typeof q !== "object") {
|
|
1418
|
+
throw new Error(`${ctx}: queue entry must be an object`);
|
|
1419
|
+
}
|
|
1420
|
+
if (typeof q.name !== "string" || q.name.length === 0) {
|
|
1421
|
+
throw new Error(`${ctx}: queue.name must be a non-empty string`);
|
|
1422
|
+
}
|
|
1423
|
+
if (!Number.isInteger(q.concurrency) || q.concurrency <= 0) {
|
|
1424
|
+
throw new Error(`${ctx}: queue.concurrency must be a positive integer`);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
validateMaxHeartbeatAgeMs(value, ctx) {
|
|
1428
|
+
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
1429
|
+
if (!Number.isInteger(value) || value <= 0 || value > MAX_AGE_MS) {
|
|
1430
|
+
throw new Error(
|
|
1431
|
+
`${ctx}: maxHeartbeatAgeMs must be a positive integer <= ${MAX_AGE_MS}`
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
// src/mongo/repositories/_types.ts
|
|
1438
|
+
function asBulkUpdateOp(op) {
|
|
1439
|
+
return op;
|
|
1440
|
+
}
|
|
1441
|
+
function asBulkInsertOp(op) {
|
|
1442
|
+
return op;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// src/mongo/repositories/bucket-utils.ts
|
|
1446
|
+
function computeBucketStart(ts, granularity) {
|
|
1447
|
+
const result = new Date(ts);
|
|
1448
|
+
if (granularity === "hour") {
|
|
1449
|
+
result.setUTCMinutes(0, 0, 0);
|
|
1450
|
+
} else {
|
|
1451
|
+
result.setUTCHours(0, 0, 0, 0);
|
|
1452
|
+
}
|
|
1453
|
+
return result;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// src/mongo/mongo-error.ts
|
|
1457
|
+
var MONGO_DUPLICATE_KEY_CODE = 11e3;
|
|
1458
|
+
function isDuplicateKeyError(err) {
|
|
1459
|
+
if (typeof err !== "object" || err === null) return false;
|
|
1460
|
+
return err.code === MONGO_DUPLICATE_KEY_CODE;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// src/mongo/repositories/types.ts
|
|
1464
|
+
var JOB_STATS_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
1465
|
+
var JOB_ERRORS_MESSAGE_MAX_CHARS = 2e3;
|
|
1466
|
+
var JOB_ERRORS_STACK_MAX_CHARS = 8e3;
|
|
1467
|
+
var JOB_ERRORS_MAX_TOP_LIMIT = 1e3;
|
|
1468
|
+
var JOB_FAILURES_INPUT_MAX_CHARS = 1e5;
|
|
1469
|
+
var JOB_FAILURES_MESSAGE_MAX_CHARS = 2e3;
|
|
1470
|
+
var JOB_FAILURES_STACK_MAX_CHARS = 8e3;
|
|
1471
|
+
var JOB_FAILURES_LOGS_MAX_CHARS = 5e5;
|
|
1472
|
+
var JOB_FAILURES_MAX_LIST_LIMIT = 1e3;
|
|
1473
|
+
var JOB_FAILURES_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
1474
|
+
var WORKERS_DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
|
|
1475
|
+
var WORKERS_MIN_HEARTBEAT_INTERVAL_MS = 1e3;
|
|
1476
|
+
var JOB_CONFIGS_DEFAULT_ENABLED = true;
|
|
1477
|
+
|
|
1478
|
+
// src/mongo/repositories/job-stats.repo.ts
|
|
1479
|
+
var COLLECTION3 = "job_stats";
|
|
1480
|
+
var GRANULARITIES = ["hour", "day"];
|
|
1481
|
+
var MAX_GET_STATS_RESULTS = 1e4;
|
|
1482
|
+
var JobStatsRepo = class extends Repository {
|
|
1483
|
+
constructor(db2) {
|
|
1484
|
+
super(db2, COLLECTION3);
|
|
1485
|
+
}
|
|
1486
|
+
indexes() {
|
|
1487
|
+
return [
|
|
1488
|
+
{
|
|
1489
|
+
keys: { jobType: 1, bucketStart: 1, granularity: 1 },
|
|
1490
|
+
options: { unique: true, name: "jobType_bucketStart_granularity_unique" }
|
|
1491
|
+
},
|
|
1492
|
+
// NOTE (TTL migration): MongoDB's `createIndex` is a no-op when an index with the same
|
|
1493
|
+
// key spec already exists — even if `expireAfterSeconds` differs. Changing
|
|
1494
|
+
// `JOB_STATS_TTL_SECONDS` will NOT propagate to a running deployment; apply a `collMod`
|
|
1495
|
+
// (or drop-and-recreate this index) manually at deploy time.
|
|
1496
|
+
{
|
|
1497
|
+
keys: { bucketStart: 1 },
|
|
1498
|
+
options: {
|
|
1499
|
+
expireAfterSeconds: JOB_STATS_TTL_SECONDS,
|
|
1500
|
+
name: "bucketStart_ttl"
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
];
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Builds the bulk write operations for a stats increment WITHOUT executing them.
|
|
1507
|
+
* Returns TWO ops — one for the hour bucket, one for the day bucket — matching
|
|
1508
|
+
* `incrementStats`'s direct-call shape bit-for-bit. Intended to be pushed through
|
|
1509
|
+
* a `BatchWriter` by the event-hooks layer (Story 1.7). Throws synchronously on
|
|
1510
|
+
* invalid `durationMs`; otherwise no side effects.
|
|
1511
|
+
*
|
|
1512
|
+
* NOTE: `now` is captured at builder invocation time, NOT at BatchWriter flush
|
|
1513
|
+
* time — `createdAt`, `updatedAt`, and `bucketStart` all reflect the event's
|
|
1514
|
+
* wall-clock, not the flush wall-clock. Aggregations are therefore correctly
|
|
1515
|
+
* attributed to the hour/day the event occurred, even if the batch flush is
|
|
1516
|
+
* delayed by the buffer timer.
|
|
1517
|
+
*/
|
|
1518
|
+
toIncrementStatsOps(jobType, input) {
|
|
1519
|
+
this.validateDurationMs(input.durationMs, "toIncrementStatsOps");
|
|
1520
|
+
const now = /* @__PURE__ */ new Date();
|
|
1521
|
+
return GRANULARITIES.map((g) => this.buildBucketOp(jobType, g, now, input));
|
|
1522
|
+
}
|
|
1523
|
+
async incrementStats(jobType, input) {
|
|
1524
|
+
this.validateDurationMs(input.durationMs, "incrementStats");
|
|
1525
|
+
const now = /* @__PURE__ */ new Date();
|
|
1526
|
+
const results = await Promise.allSettled(
|
|
1527
|
+
GRANULARITIES.map((g) => this.upsertBucket(jobType, g, now, input))
|
|
1528
|
+
);
|
|
1529
|
+
const failures = results.map((r, i) => ({ result: r, granularity: GRANULARITIES[i] })).filter(
|
|
1530
|
+
(x) => x.result.status === "rejected"
|
|
1531
|
+
);
|
|
1532
|
+
if (failures.length === 0) return;
|
|
1533
|
+
const logger2 = getLogger();
|
|
1534
|
+
for (const { result, granularity } of failures) {
|
|
1535
|
+
logger2.error(
|
|
1536
|
+
{ jobType, granularity, err: result.reason },
|
|
1537
|
+
"JobStatsRepo.incrementStats: bucket upsert failed"
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
if (failures.length === results.length) {
|
|
1541
|
+
throw failures[0].result.reason;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
async getStats(jobType, fromDate, toDate, granularity) {
|
|
1545
|
+
if (fromDate > toDate) {
|
|
1546
|
+
throw new Error(
|
|
1547
|
+
`JobStatsRepo.getStats: fromDate (${fromDate.toISOString()}) must be <= toDate (${toDate.toISOString()})`
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
const filter = {
|
|
1551
|
+
jobType,
|
|
1552
|
+
granularity,
|
|
1553
|
+
bucketStart: { $gte: fromDate, $lte: toDate }
|
|
1554
|
+
};
|
|
1555
|
+
return await this.collection.find(filter).sort({ bucketStart: 1 }).limit(MAX_GET_STATS_RESULTS).toArray();
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Fetches every stats document in the given range + granularity, across ALL
|
|
1559
|
+
* jobTypes. Intended for dashboard views that need a cross-jobType overview
|
|
1560
|
+
* (e.g., "7-day health per job type") without a prior enumeration step.
|
|
1561
|
+
*
|
|
1562
|
+
* Result order: `bucketStart` ascending, then `jobType` ascending. Caller is
|
|
1563
|
+
* responsible for grouping by jobType client-side if desired.
|
|
1564
|
+
*
|
|
1565
|
+
* Applies the same `MAX_GET_STATS_RESULTS` cap as `getStats` — a single query
|
|
1566
|
+
* across all jobTypes × buckets × granularity is bounded, but the cap keeps
|
|
1567
|
+
* the method safe under accidental wide-range queries.
|
|
1568
|
+
*/
|
|
1569
|
+
async getStatsAllJobTypes(fromDate, toDate, granularity) {
|
|
1570
|
+
if (fromDate > toDate) {
|
|
1571
|
+
throw new Error(
|
|
1572
|
+
`JobStatsRepo.getStatsAllJobTypes: fromDate (${fromDate.toISOString()}) must be <= toDate (${toDate.toISOString()})`
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
const filter = {
|
|
1576
|
+
granularity,
|
|
1577
|
+
bucketStart: { $gte: fromDate, $lte: toDate }
|
|
1578
|
+
};
|
|
1579
|
+
return await this.collection.find(filter).sort({ bucketStart: 1, jobType: 1 }).limit(MAX_GET_STATS_RESULTS).toArray();
|
|
1580
|
+
}
|
|
1581
|
+
async upsertBucket(jobType, granularity, now, input) {
|
|
1582
|
+
const { filter, update } = this.buildBucketPayload(jobType, granularity, now, input);
|
|
1583
|
+
try {
|
|
1584
|
+
await this.collection.updateOne(filter, update, { upsert: true });
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
if (isDuplicateKeyError(err)) {
|
|
1587
|
+
await this.collection.updateOne(filter, update, { upsert: true });
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
throw err;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
buildBucketOp(jobType, granularity, now, input) {
|
|
1594
|
+
const { filter, update } = this.buildBucketPayload(jobType, granularity, now, input);
|
|
1595
|
+
return asBulkUpdateOp({
|
|
1596
|
+
updateOne: { filter, update, upsert: true }
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
buildBucketPayload(jobType, granularity, now, input) {
|
|
1600
|
+
const bucketStart = computeBucketStart(now, granularity);
|
|
1601
|
+
const filter = { jobType, bucketStart, granularity };
|
|
1602
|
+
const update = {
|
|
1603
|
+
$inc: {
|
|
1604
|
+
count: 1,
|
|
1605
|
+
success: input.success ? 1 : 0,
|
|
1606
|
+
failed: input.success ? 0 : 1,
|
|
1607
|
+
totalDurationMs: input.durationMs ?? 0
|
|
1608
|
+
},
|
|
1609
|
+
$setOnInsert: {
|
|
1610
|
+
jobType,
|
|
1611
|
+
bucketStart,
|
|
1612
|
+
granularity,
|
|
1613
|
+
createdAt: now
|
|
1614
|
+
},
|
|
1615
|
+
$set: {
|
|
1616
|
+
updatedAt: now
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
return { filter, update };
|
|
1620
|
+
}
|
|
1621
|
+
validateDurationMs(durationMs, methodName) {
|
|
1622
|
+
if (durationMs === void 0) return;
|
|
1623
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
|
1624
|
+
throw new Error(
|
|
1625
|
+
`JobStatsRepo.${methodName}: durationMs must be a finite non-negative number (got ${durationMs})`
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
var SIGNATURE_LENGTH = 16;
|
|
1631
|
+
var STACK_TOP_FRAMES = 5;
|
|
1632
|
+
var ISO_TIMESTAMP_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?/g;
|
|
1633
|
+
var UUID_REGEX = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
|
1634
|
+
var HEX_ADDRESS_REGEX = /\b0x[0-9a-f]+\b/gi;
|
|
1635
|
+
var LONG_DIGIT_RUN_REGEX = /\b\d{5,}\b/g;
|
|
1636
|
+
var STACK_FRAME_PREFIX = " at ";
|
|
1637
|
+
var STACK_LINE_COL_SUFFIX_REGEX = /:\d+:\d+(\)?)$/;
|
|
1638
|
+
function normalizeMessage(msg) {
|
|
1639
|
+
return msg.replace(ISO_TIMESTAMP_REGEX, "<ts>").replace(UUID_REGEX, "<uuid>").replace(HEX_ADDRESS_REGEX, "<hex>").replace(LONG_DIGIT_RUN_REGEX, "<n>");
|
|
1640
|
+
}
|
|
1641
|
+
function normalizeStack(stack) {
|
|
1642
|
+
const frames = [];
|
|
1643
|
+
for (const rawLine of stack.split("\n")) {
|
|
1644
|
+
if (!rawLine.startsWith(STACK_FRAME_PREFIX)) continue;
|
|
1645
|
+
const slashed = rawLine.replace(/\\/g, "/");
|
|
1646
|
+
const stripped = slashed.replace(STACK_LINE_COL_SUFFIX_REGEX, "$1");
|
|
1647
|
+
frames.push(stripped);
|
|
1648
|
+
if (frames.length === STACK_TOP_FRAMES) break;
|
|
1649
|
+
}
|
|
1650
|
+
return frames.join("\n");
|
|
1651
|
+
}
|
|
1652
|
+
function errorSignature(err) {
|
|
1653
|
+
let combined;
|
|
1654
|
+
if (err instanceof Error) {
|
|
1655
|
+
const name = err.name ?? "Error";
|
|
1656
|
+
const message = normalizeMessage(err.message ?? "");
|
|
1657
|
+
const stack = err.stack ? normalizeStack(err.stack) : "";
|
|
1658
|
+
combined = `${name}
|
|
1659
|
+
${message}
|
|
1660
|
+
${stack}`;
|
|
1661
|
+
} else {
|
|
1662
|
+
combined = normalizeMessage(String(err));
|
|
1663
|
+
}
|
|
1664
|
+
return crypto.createHash("sha256").update(combined).digest("hex").slice(0, SIGNATURE_LENGTH);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// src/monitoring/error-extraction.ts
|
|
1668
|
+
var DEFAULT_MAX_CAUSE_DEPTH = 5;
|
|
1669
|
+
var NAME_MAX_CHARS = 64;
|
|
1670
|
+
var CHAIN_SEPARATOR = "\nCaused by: ";
|
|
1671
|
+
function truncate(value, max) {
|
|
1672
|
+
return value.length <= max ? value : value.slice(0, max);
|
|
1673
|
+
}
|
|
1674
|
+
function safeGet(getter, fallback) {
|
|
1675
|
+
try {
|
|
1676
|
+
return getter();
|
|
1677
|
+
} catch {
|
|
1678
|
+
return fallback;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
function isErrorLike(value) {
|
|
1682
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1683
|
+
return safeGet(() => "message" in value, false);
|
|
1684
|
+
}
|
|
1685
|
+
function normalizeEntry(value, messageMaxChars) {
|
|
1686
|
+
const entryMax = Math.max(1, Math.floor(messageMaxChars / 2));
|
|
1687
|
+
if (isErrorLike(value)) {
|
|
1688
|
+
const rawMessage = safeGet(() => value.message, void 0);
|
|
1689
|
+
const rawName = safeGet(() => value.name, void 0);
|
|
1690
|
+
const message = truncate(
|
|
1691
|
+
typeof rawMessage === "string" ? rawMessage : safeGet(() => String(rawMessage), ""),
|
|
1692
|
+
entryMax
|
|
1693
|
+
);
|
|
1694
|
+
const name = truncate(typeof rawName === "string" ? rawName : "Error", NAME_MAX_CHARS);
|
|
1695
|
+
return { message, name };
|
|
1696
|
+
}
|
|
1697
|
+
return {
|
|
1698
|
+
message: truncate(safeGet(() => String(value), ""), entryMax),
|
|
1699
|
+
name: "NonError"
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
function walkCauseChain(top, maxDepth, messageMaxChars) {
|
|
1703
|
+
const chain = [];
|
|
1704
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
1705
|
+
if (typeof top === "object" && top !== null) seen.add(top);
|
|
1706
|
+
let current = safeGet(() => top.cause, void 0);
|
|
1707
|
+
let truncated = false;
|
|
1708
|
+
while (current !== void 0 && current !== null) {
|
|
1709
|
+
if (chain.length >= maxDepth) {
|
|
1710
|
+
truncated = true;
|
|
1711
|
+
break;
|
|
1712
|
+
}
|
|
1713
|
+
if (typeof current === "object" && current !== null) {
|
|
1714
|
+
if (seen.has(current)) {
|
|
1715
|
+
truncated = true;
|
|
1716
|
+
break;
|
|
1717
|
+
}
|
|
1718
|
+
seen.add(current);
|
|
1719
|
+
}
|
|
1720
|
+
chain.push(normalizeEntry(current, messageMaxChars));
|
|
1721
|
+
const hasCause = typeof current === "object" && current !== null && safeGet(() => "cause" in current, false);
|
|
1722
|
+
if (hasCause) {
|
|
1723
|
+
current = safeGet(() => current.cause, void 0);
|
|
1724
|
+
} else {
|
|
1725
|
+
break;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
return { chain, truncated };
|
|
1729
|
+
}
|
|
1730
|
+
function extractMessageAndStack(err, options) {
|
|
1731
|
+
const { messageMaxChars, stackMaxChars } = options;
|
|
1732
|
+
const rawMaxDepth = options.maxCauseDepth ?? DEFAULT_MAX_CAUSE_DEPTH;
|
|
1733
|
+
const maxCauseDepth = Number.isFinite(rawMaxDepth) && rawMaxDepth >= 1 ? Math.floor(rawMaxDepth) : DEFAULT_MAX_CAUSE_DEPTH;
|
|
1734
|
+
if (!(err instanceof Error)) {
|
|
1735
|
+
return {
|
|
1736
|
+
message: truncate(safeGet(() => String(err), ""), messageMaxChars),
|
|
1737
|
+
stack: "",
|
|
1738
|
+
causeChain: []
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
const topMessage = safeGet(() => {
|
|
1742
|
+
const m = err.message;
|
|
1743
|
+
return typeof m === "string" ? m : String(m ?? "");
|
|
1744
|
+
}, "");
|
|
1745
|
+
const topStack = safeGet(() => {
|
|
1746
|
+
const s = err.stack;
|
|
1747
|
+
return typeof s === "string" ? s : String(s ?? "");
|
|
1748
|
+
}, "");
|
|
1749
|
+
const { chain, truncated } = walkCauseChain(
|
|
1750
|
+
err,
|
|
1751
|
+
maxCauseDepth,
|
|
1752
|
+
messageMaxChars
|
|
1753
|
+
);
|
|
1754
|
+
if (truncated) {
|
|
1755
|
+
getLogger().warn(
|
|
1756
|
+
{ topMessage: truncate(topMessage, 120) },
|
|
1757
|
+
"extractMessageAndStack: maxCauseDepth reached, truncating cause chain."
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
let joinedMessage = topMessage;
|
|
1761
|
+
for (const entry of chain) {
|
|
1762
|
+
joinedMessage += `${CHAIN_SEPARATOR}${entry.message}`;
|
|
1763
|
+
}
|
|
1764
|
+
return {
|
|
1765
|
+
message: truncate(joinedMessage, messageMaxChars),
|
|
1766
|
+
stack: truncate(topStack, stackMaxChars),
|
|
1767
|
+
causeChain: chain
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// src/mongo/repositories/job-errors.repo.ts
|
|
1772
|
+
var COLLECTION4 = "job_errors";
|
|
1773
|
+
var JobErrorsRepo = class extends Repository {
|
|
1774
|
+
constructor(db2) {
|
|
1775
|
+
super(db2, COLLECTION4);
|
|
1776
|
+
}
|
|
1777
|
+
indexes() {
|
|
1778
|
+
return [
|
|
1779
|
+
{
|
|
1780
|
+
keys: { signature: 1 },
|
|
1781
|
+
options: { unique: true, name: "signature_unique" }
|
|
1782
|
+
},
|
|
1783
|
+
{
|
|
1784
|
+
keys: { lastSeenAt: -1 },
|
|
1785
|
+
options: { name: "lastSeenAt_desc" }
|
|
1786
|
+
},
|
|
1787
|
+
{
|
|
1788
|
+
keys: { jobType: 1, count: -1 },
|
|
1789
|
+
options: { name: "jobType_count_desc" }
|
|
1790
|
+
},
|
|
1791
|
+
{
|
|
1792
|
+
keys: { count: -1 },
|
|
1793
|
+
options: { name: "count_desc" }
|
|
1794
|
+
}
|
|
1795
|
+
];
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Records one occurrence of an error, deduplicated by its stable signature.
|
|
1799
|
+
*
|
|
1800
|
+
* Invariant: `jobType` is set **only on insert** (`$setOnInsert`). Once a signature
|
|
1801
|
+
* is first observed under jobType A, subsequent occurrences under jobType B do NOT
|
|
1802
|
+
* overwrite the stored value. The signature is the identity of the error; a signature
|
|
1803
|
+
* appearing across multiple job types indicates shared infrastructure failure (e.g.
|
|
1804
|
+
* ECONNRESET from the shared DB) and is worth surfacing on the first-observed job.
|
|
1805
|
+
*/
|
|
1806
|
+
async recordOccurrence(input) {
|
|
1807
|
+
const { filter, update } = this.buildRecordOccurrencePayload(input);
|
|
1808
|
+
try {
|
|
1809
|
+
await this.collection.updateOne(filter, update, { upsert: true });
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
if (isDuplicateKeyError(err)) {
|
|
1812
|
+
await this.collection.updateOne(filter, update, { upsert: true });
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
throw err;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Builds the bulk write operation for `recordOccurrence` WITHOUT executing it.
|
|
1820
|
+
* Returns a single upsert op matching the direct-call shape bit-for-bit.
|
|
1821
|
+
*
|
|
1822
|
+
* NOTE: `now` is captured at builder invocation time, NOT at BatchWriter flush
|
|
1823
|
+
* time — firstSeenAt / lastSeenAt reflect the event's wall-clock, not the
|
|
1824
|
+
* flush wall-clock. This preserves the semantic that these timestamps track
|
|
1825
|
+
* the error's occurrence, not the observability pipeline's delay.
|
|
1826
|
+
*/
|
|
1827
|
+
toRecordOccurrenceOp(input) {
|
|
1828
|
+
const { filter, update } = this.buildRecordOccurrencePayload(input);
|
|
1829
|
+
return asBulkUpdateOp({
|
|
1830
|
+
updateOne: { filter, update, upsert: true }
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
buildRecordOccurrencePayload(input) {
|
|
1834
|
+
const signature = errorSignature(input.error);
|
|
1835
|
+
const { message, stack, causeChain } = extractMessageAndStack(input.error, {
|
|
1836
|
+
messageMaxChars: JOB_ERRORS_MESSAGE_MAX_CHARS,
|
|
1837
|
+
stackMaxChars: JOB_ERRORS_STACK_MAX_CHARS
|
|
1838
|
+
});
|
|
1839
|
+
const now = /* @__PURE__ */ new Date();
|
|
1840
|
+
const filter = { signature };
|
|
1841
|
+
const update = {
|
|
1842
|
+
$inc: { count: 1 },
|
|
1843
|
+
$setOnInsert: {
|
|
1844
|
+
signature,
|
|
1845
|
+
jobType: input.jobType,
|
|
1846
|
+
message,
|
|
1847
|
+
stack,
|
|
1848
|
+
firstSeenAt: now,
|
|
1849
|
+
createdAt: now,
|
|
1850
|
+
...causeChain.length > 0 ? { causeChain } : {}
|
|
1851
|
+
},
|
|
1852
|
+
$set: {
|
|
1853
|
+
lastSeenAt: now,
|
|
1854
|
+
updatedAt: now
|
|
1855
|
+
}
|
|
1856
|
+
};
|
|
1857
|
+
return { filter, update };
|
|
1858
|
+
}
|
|
1859
|
+
async topErrors(options) {
|
|
1860
|
+
if (!(options.limit > 0) || options.limit > JOB_ERRORS_MAX_TOP_LIMIT) {
|
|
1861
|
+
throw new Error(
|
|
1862
|
+
`JobErrorsRepo.topErrors: limit must be in (0, ${JOB_ERRORS_MAX_TOP_LIMIT}]`
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
const filter = {
|
|
1866
|
+
...options.jobType !== void 0 ? { jobType: options.jobType } : {},
|
|
1867
|
+
...options.since !== void 0 ? { lastSeenAt: { $gte: options.since } } : {}
|
|
1868
|
+
};
|
|
1869
|
+
return await this.collection.find(filter).sort({ count: -1 }).limit(options.limit).toArray();
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
// src/mongo/repositories/job-failures.repo.ts
|
|
1874
|
+
var COLLECTION5 = "job_failures";
|
|
1875
|
+
var DEFAULT_LIST_LIMIT = 50;
|
|
1876
|
+
var JobFailuresRepo = class extends Repository {
|
|
1877
|
+
constructor(db2) {
|
|
1878
|
+
super(db2, COLLECTION5);
|
|
1879
|
+
}
|
|
1880
|
+
indexes() {
|
|
1881
|
+
return [
|
|
1882
|
+
{
|
|
1883
|
+
keys: { jobId: 1, failedAt: -1 },
|
|
1884
|
+
options: { name: "jobId_failedAt_desc" }
|
|
1885
|
+
},
|
|
1886
|
+
{
|
|
1887
|
+
keys: { jobType: 1, failedAt: -1 },
|
|
1888
|
+
options: { name: "jobType_failedAt_desc" }
|
|
1889
|
+
},
|
|
1890
|
+
// NOTE (TTL migration): MongoDB's `createIndex` is a no-op when an index with the same
|
|
1891
|
+
// key spec already exists — even if `expireAfterSeconds` differs. Changing
|
|
1892
|
+
// `JOB_FAILURES_TTL_SECONDS` will NOT propagate to a running deployment; apply a `collMod`
|
|
1893
|
+
// (or drop-and-recreate this index) manually at deploy time.
|
|
1894
|
+
{
|
|
1895
|
+
keys: { failedAt: 1 },
|
|
1896
|
+
options: {
|
|
1897
|
+
expireAfterSeconds: JOB_FAILURES_TTL_SECONDS,
|
|
1898
|
+
name: "failedAt_ttl"
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
];
|
|
1902
|
+
}
|
|
1903
|
+
async recordFailure(input) {
|
|
1904
|
+
const doc = this.buildFailureDoc(input);
|
|
1905
|
+
await this.collection.insertOne(doc);
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* Builds the bulk write operation for `recordFailure` WITHOUT executing it.
|
|
1909
|
+
* Returns a single insertOne op matching the direct-call document shape
|
|
1910
|
+
* bit-for-bit. Intended for the event-hooks layer (Story 1.7) to push through
|
|
1911
|
+
* a `BatchWriter`. Throws synchronously on invalid input using the same error
|
|
1912
|
+
* messages as `recordFailure`.
|
|
1913
|
+
*/
|
|
1914
|
+
toRecordFailureOp(input) {
|
|
1915
|
+
const doc = this.buildFailureDoc(input);
|
|
1916
|
+
const op = { insertOne: { document: doc } };
|
|
1917
|
+
return asBulkInsertOp(op);
|
|
1918
|
+
}
|
|
1919
|
+
buildFailureDoc(input) {
|
|
1920
|
+
this.validateFailureInput(input);
|
|
1921
|
+
const serializedInput = this.serializeInputValue(input.input).slice(
|
|
1922
|
+
0,
|
|
1923
|
+
JOB_FAILURES_INPUT_MAX_CHARS
|
|
1924
|
+
);
|
|
1925
|
+
const { message, stack, causeChain } = extractMessageAndStack(input.error, {
|
|
1926
|
+
messageMaxChars: JOB_FAILURES_MESSAGE_MAX_CHARS,
|
|
1927
|
+
stackMaxChars: JOB_FAILURES_STACK_MAX_CHARS
|
|
1928
|
+
});
|
|
1929
|
+
const logs = this.capLogs(input.logs);
|
|
1930
|
+
const doc = {
|
|
1931
|
+
jobId: input.jobId,
|
|
1932
|
+
jobType: input.jobType,
|
|
1933
|
+
input: serializedInput,
|
|
1934
|
+
errorMessage: message,
|
|
1935
|
+
errorStack: stack,
|
|
1936
|
+
logs,
|
|
1937
|
+
failedAt: input.failedAt,
|
|
1938
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1939
|
+
};
|
|
1940
|
+
if (causeChain.length > 0) {
|
|
1941
|
+
doc.causeChain = causeChain;
|
|
1942
|
+
}
|
|
1943
|
+
return doc;
|
|
1944
|
+
}
|
|
1945
|
+
validateFailureInput(input) {
|
|
1946
|
+
if (typeof input.jobId !== "string" || input.jobId.trim().length === 0) {
|
|
1947
|
+
throw new Error("JobFailuresRepo.recordFailure: jobId must be a non-empty string");
|
|
1948
|
+
}
|
|
1949
|
+
if (typeof input.jobType !== "string" || input.jobType.trim().length === 0) {
|
|
1950
|
+
throw new Error("JobFailuresRepo.recordFailure: jobType must be a non-empty string");
|
|
1951
|
+
}
|
|
1952
|
+
if (!(input.failedAt instanceof Date) || Number.isNaN(input.failedAt.getTime())) {
|
|
1953
|
+
throw new Error("JobFailuresRepo.recordFailure: failedAt must be a valid Date");
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
async listFailures(options) {
|
|
1957
|
+
if (options.limit !== void 0) {
|
|
1958
|
+
if (!Number.isInteger(options.limit) || options.limit <= 0 || options.limit > JOB_FAILURES_MAX_LIST_LIMIT) {
|
|
1959
|
+
throw new Error(
|
|
1960
|
+
`JobFailuresRepo.listFailures: limit must be an integer in (0, ${JOB_FAILURES_MAX_LIST_LIMIT}]`
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
if (options.skip !== void 0) {
|
|
1965
|
+
if (!Number.isInteger(options.skip) || options.skip < 0) {
|
|
1966
|
+
throw new Error(
|
|
1967
|
+
"JobFailuresRepo.listFailures: skip must be a non-negative integer"
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
const fromDate = options.fromDate ?? void 0;
|
|
1972
|
+
const toDate = options.toDate ?? void 0;
|
|
1973
|
+
if (fromDate !== void 0 && (!(fromDate instanceof Date) || Number.isNaN(fromDate.getTime()))) {
|
|
1974
|
+
throw new Error("JobFailuresRepo.listFailures: fromDate must be a valid Date");
|
|
1975
|
+
}
|
|
1976
|
+
if (toDate !== void 0 && (!(toDate instanceof Date) || Number.isNaN(toDate.getTime()))) {
|
|
1977
|
+
throw new Error("JobFailuresRepo.listFailures: toDate must be a valid Date");
|
|
1978
|
+
}
|
|
1979
|
+
if (fromDate !== void 0 && toDate !== void 0 && fromDate.getTime() > toDate.getTime()) {
|
|
1980
|
+
throw new Error("JobFailuresRepo.listFailures: fromDate must be <= toDate");
|
|
1981
|
+
}
|
|
1982
|
+
const skip = options.skip ?? 0;
|
|
1983
|
+
const limit = options.limit ?? DEFAULT_LIST_LIMIT;
|
|
1984
|
+
const filter = {};
|
|
1985
|
+
if (options.jobType !== void 0) {
|
|
1986
|
+
filter.jobType = options.jobType;
|
|
1987
|
+
}
|
|
1988
|
+
if (fromDate !== void 0 || toDate !== void 0) {
|
|
1989
|
+
const range = {};
|
|
1990
|
+
if (fromDate !== void 0) range.$gte = fromDate;
|
|
1991
|
+
if (toDate !== void 0) range.$lte = toDate;
|
|
1992
|
+
filter.failedAt = range;
|
|
1993
|
+
}
|
|
1994
|
+
const [items, total] = await Promise.all([
|
|
1995
|
+
this.collection.find(filter).sort({ failedAt: -1 }).skip(skip).limit(limit).toArray(),
|
|
1996
|
+
this.collection.countDocuments(filter)
|
|
1997
|
+
]);
|
|
1998
|
+
return { items, total };
|
|
1999
|
+
}
|
|
2000
|
+
async getFailure(jobId) {
|
|
2001
|
+
if (typeof jobId !== "string" || jobId.trim().length === 0) {
|
|
2002
|
+
throw new Error("JobFailuresRepo.getFailure: jobId must be a non-empty string");
|
|
2003
|
+
}
|
|
2004
|
+
const filter = { jobId };
|
|
2005
|
+
return await this.collection.findOne(filter, { sort: { failedAt: -1 } });
|
|
2006
|
+
}
|
|
2007
|
+
serializeInputValue(input) {
|
|
2008
|
+
if (input === void 0) return "undefined";
|
|
2009
|
+
try {
|
|
2010
|
+
const result = JSON.stringify(input, (_key, value) => {
|
|
2011
|
+
if (value instanceof Map) return { __type: "Map", entries: Array.from(value.entries()) };
|
|
2012
|
+
if (value instanceof Set) return { __type: "Set", values: Array.from(value.values()) };
|
|
2013
|
+
return value;
|
|
2014
|
+
});
|
|
2015
|
+
if (result === void 0) return "undefined";
|
|
2016
|
+
return result;
|
|
2017
|
+
} catch (err) {
|
|
2018
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2019
|
+
return `[input serialization failed: ${msg}]`;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
capLogs(logs) {
|
|
2023
|
+
if (logs === void 0) return "";
|
|
2024
|
+
if (logs.length <= JOB_FAILURES_LOGS_MAX_CHARS) return logs;
|
|
2025
|
+
const originalLen = logs.length;
|
|
2026
|
+
let tailLen = JOB_FAILURES_LOGS_MAX_CHARS;
|
|
2027
|
+
let marker = "";
|
|
2028
|
+
for (let i = 0; i < 3; i++) {
|
|
2029
|
+
marker = `[truncated \u2014 showing last ${tailLen} of ${originalLen} chars]
|
|
2030
|
+
`;
|
|
2031
|
+
if (marker.length >= JOB_FAILURES_LOGS_MAX_CHARS) {
|
|
2032
|
+
return marker.slice(0, JOB_FAILURES_LOGS_MAX_CHARS);
|
|
2033
|
+
}
|
|
2034
|
+
tailLen = JOB_FAILURES_LOGS_MAX_CHARS - marker.length;
|
|
2035
|
+
}
|
|
2036
|
+
return marker + logs.slice(-tailLen);
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
// src/mongo/repositories/job-configs.repo.ts
|
|
2041
|
+
var COLLECTION6 = "job_configs";
|
|
2042
|
+
var JobConfigsRepo = class extends Repository {
|
|
2043
|
+
constructor(db2) {
|
|
2044
|
+
super(db2, COLLECTION6);
|
|
2045
|
+
}
|
|
2046
|
+
indexes() {
|
|
2047
|
+
return [
|
|
2048
|
+
{
|
|
2049
|
+
keys: { jobType: 1 },
|
|
2050
|
+
options: { unique: true, name: "jobType_unique" }
|
|
2051
|
+
}
|
|
2052
|
+
];
|
|
2053
|
+
}
|
|
2054
|
+
async getConfig(jobType) {
|
|
2055
|
+
this.validateJobType(jobType, "JobConfigsRepo.getConfig");
|
|
2056
|
+
const filter = { jobType };
|
|
2057
|
+
const doc = await this.collection.findOne(filter);
|
|
2058
|
+
if (!doc) {
|
|
2059
|
+
return {
|
|
2060
|
+
jobType,
|
|
2061
|
+
enabled: JOB_CONFIGS_DEFAULT_ENABLED,
|
|
2062
|
+
metadata: {}
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
return {
|
|
2066
|
+
jobType: doc.jobType,
|
|
2067
|
+
enabled: doc.enabled,
|
|
2068
|
+
metadata: doc.metadata
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
async setEnabled(jobType, enabled) {
|
|
2072
|
+
this.validateJobType(jobType, "JobConfigsRepo.setEnabled");
|
|
2073
|
+
if (typeof enabled !== "boolean") {
|
|
2074
|
+
throw new Error("JobConfigsRepo.setEnabled: enabled must be a boolean");
|
|
2075
|
+
}
|
|
2076
|
+
const filter = { jobType };
|
|
2077
|
+
const update = {
|
|
2078
|
+
$set: { enabled },
|
|
2079
|
+
$setOnInsert: {
|
|
2080
|
+
jobType,
|
|
2081
|
+
metadata: {},
|
|
2082
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
2083
|
+
},
|
|
2084
|
+
$currentDate: { updatedAt: true }
|
|
2085
|
+
};
|
|
2086
|
+
const result = await this.collection.updateOne(filter, update, { upsert: true });
|
|
2087
|
+
if (result.matchedCount === 0 && (result.upsertedCount ?? 0) === 0) {
|
|
2088
|
+
throw new Error(
|
|
2089
|
+
`JobConfigsRepo.setEnabled: updateOne acknowledged no write for jobType=${jobType}`
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
async listAll() {
|
|
2094
|
+
return await this.collection.find({}).sort({ jobType: 1 }).toArray();
|
|
2095
|
+
}
|
|
2096
|
+
async isEnabled(jobType) {
|
|
2097
|
+
this.validateJobType(jobType, "JobConfigsRepo.isEnabled");
|
|
2098
|
+
const filter = { jobType };
|
|
2099
|
+
const doc = await this.collection.findOne(filter, {
|
|
2100
|
+
projection: { enabled: 1, _id: 0 }
|
|
2101
|
+
});
|
|
2102
|
+
if (!doc) {
|
|
2103
|
+
return JOB_CONFIGS_DEFAULT_ENABLED;
|
|
2104
|
+
}
|
|
2105
|
+
return doc.enabled;
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* Idempotent bulk upsert of every registered jobType + its metadata
|
|
2109
|
+
* projection (Story 5.1 D5.1-a). Called once per `RavenflowApp` instance
|
|
2110
|
+
* inside `initializeMonitoring()`.
|
|
2111
|
+
*
|
|
2112
|
+
* Semantics:
|
|
2113
|
+
* - `$setOnInsert { enabled: true, createdAt }` — the enabled flag is
|
|
2114
|
+
* set to the default ONLY on initial insert. An operator-toggled
|
|
2115
|
+
* `enabled: false` is NEVER overwritten by a subsequent boot.
|
|
2116
|
+
* - `$set { metadata }` — metadata is always refreshed from code config
|
|
2117
|
+
* (code is source of truth for non-user-writable fields).
|
|
2118
|
+
* - `$currentDate { updatedAt }` — timestamp bumps every call.
|
|
2119
|
+
*
|
|
2120
|
+
* Metadata projection (narrow + stable — see D5.1-h):
|
|
2121
|
+
* - `type`: jobConfig.type (always present in the discriminated union).
|
|
2122
|
+
* - `queue`: jobConfig.queue ?? null (null = inherits defaultQueue).
|
|
2123
|
+
* - `cron`: jobConfig.cron if present (token-bucket only today), else null.
|
|
2124
|
+
*
|
|
2125
|
+
* Additional type-specific fields (token-bucket's `rate`, distributed-etl's
|
|
2126
|
+
* `steps`) are NOT extracted — BSON can't serialize the function references
|
|
2127
|
+
* they contain, and the dashboard surface consumes only `type`/`queue`/`cron`
|
|
2128
|
+
* in V1. Future stories can widen this projection without a schema change.
|
|
2129
|
+
*
|
|
2130
|
+
* Failure mode: `bulkWrite` is the only I/O. Empty input short-circuits
|
|
2131
|
+
* because the driver rejects empty ops arrays.
|
|
2132
|
+
*/
|
|
2133
|
+
async ensureEntries(jobs) {
|
|
2134
|
+
if (jobs === null || typeof jobs !== "object") return;
|
|
2135
|
+
const entries = Object.entries(jobs);
|
|
2136
|
+
if (entries.length === 0) return;
|
|
2137
|
+
for (const [jobType] of entries) {
|
|
2138
|
+
this.validateJobType(jobType, "JobConfigsRepo.ensureEntries");
|
|
2139
|
+
}
|
|
2140
|
+
const ops = entries.map(
|
|
2141
|
+
([jobType, cfg]) => {
|
|
2142
|
+
const cron = "cron" in cfg && typeof cfg.cron === "string" && cfg.cron.length > 0 ? cfg.cron : null;
|
|
2143
|
+
const metadata = {
|
|
2144
|
+
type: cfg.type,
|
|
2145
|
+
queue: cfg.queue ?? null,
|
|
2146
|
+
cron
|
|
2147
|
+
};
|
|
2148
|
+
const update = {
|
|
2149
|
+
$setOnInsert: {
|
|
2150
|
+
jobType,
|
|
2151
|
+
enabled: JOB_CONFIGS_DEFAULT_ENABLED,
|
|
2152
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
2153
|
+
},
|
|
2154
|
+
$set: { metadata },
|
|
2155
|
+
$currentDate: { updatedAt: true }
|
|
2156
|
+
};
|
|
2157
|
+
return asBulkUpdateOp({
|
|
2158
|
+
updateOne: {
|
|
2159
|
+
filter: { jobType },
|
|
2160
|
+
update,
|
|
2161
|
+
upsert: true
|
|
2162
|
+
}
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
);
|
|
2166
|
+
await this.collection.bulkWrite(ops, { ordered: false });
|
|
2167
|
+
}
|
|
2168
|
+
validateJobType(jobType, ctx) {
|
|
2169
|
+
if (typeof jobType !== "string" || jobType.trim().length === 0) {
|
|
2170
|
+
throw new Error(`${ctx}: jobType must be a non-empty string`);
|
|
2171
|
+
}
|
|
2172
|
+
if (jobType.includes(".") || jobType.includes("$")) {
|
|
2173
|
+
throw new Error(
|
|
2174
|
+
`${ctx}: jobType cannot contain '.' or '$' (Mongo-reserved characters)`
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
2179
|
+
var WorkerRegistry = class _WorkerRegistry {
|
|
2180
|
+
constructor(repo, options = {}) {
|
|
2181
|
+
this.timer = null;
|
|
2182
|
+
this.started = false;
|
|
2183
|
+
this.registered = false;
|
|
2184
|
+
_WorkerRegistry.validateHeartbeatInterval(options.heartbeatIntervalMs);
|
|
2185
|
+
this.repo = repo;
|
|
2186
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? WORKERS_DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
2187
|
+
this.hostname = options.hostnameProvider ? options.hostnameProvider() : os__namespace.hostname();
|
|
2188
|
+
this.pid = options.pidProvider ? options.pidProvider() : process.pid;
|
|
2189
|
+
this._workerId = options.workerIdProvider ? options.workerIdProvider() : `${this.hostname}:${this.pid}:${crypto__namespace.randomUUID()}`;
|
|
2190
|
+
this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
|
|
2191
|
+
this.startedAt = this.clock();
|
|
2192
|
+
}
|
|
2193
|
+
get workerId() {
|
|
2194
|
+
return this._workerId;
|
|
2195
|
+
}
|
|
2196
|
+
async registerQueue(queue) {
|
|
2197
|
+
_WorkerRegistry.validateQueueEntry(queue);
|
|
2198
|
+
if (!this.registered) {
|
|
2199
|
+
await this.repo.registerWorker({
|
|
2200
|
+
workerId: this._workerId,
|
|
2201
|
+
hostname: this.hostname,
|
|
2202
|
+
pid: this.pid,
|
|
2203
|
+
queues: [queue],
|
|
2204
|
+
startedAt: this.startedAt
|
|
2205
|
+
});
|
|
2206
|
+
this.registered = true;
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
await this.repo.addQueue({
|
|
2210
|
+
workerId: this._workerId,
|
|
2211
|
+
queue
|
|
2212
|
+
});
|
|
2213
|
+
}
|
|
2214
|
+
async start() {
|
|
2215
|
+
if (this.started) return;
|
|
2216
|
+
if (!this.registered) {
|
|
2217
|
+
throw new Error(
|
|
2218
|
+
"WorkerRegistry.start: call registerQueue at least once before start"
|
|
2219
|
+
);
|
|
2220
|
+
}
|
|
2221
|
+
this.timer = setInterval(() => {
|
|
2222
|
+
void this.doHeartbeat();
|
|
2223
|
+
}, this.heartbeatIntervalMs);
|
|
2224
|
+
this.timer.unref();
|
|
2225
|
+
this.started = true;
|
|
2226
|
+
}
|
|
2227
|
+
async stop() {
|
|
2228
|
+
if (this.timer !== null) {
|
|
2229
|
+
clearInterval(this.timer);
|
|
2230
|
+
this.timer = null;
|
|
2231
|
+
}
|
|
2232
|
+
this.started = false;
|
|
2233
|
+
if (!this.registered) return;
|
|
2234
|
+
try {
|
|
2235
|
+
await this.repo.deregisterWorker(this._workerId);
|
|
2236
|
+
} catch (err) {
|
|
2237
|
+
getLogger().warn(
|
|
2238
|
+
{ err, workerId: this._workerId },
|
|
2239
|
+
"[worker-registry] deregister failed on stop"
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
this.registered = false;
|
|
2243
|
+
}
|
|
2244
|
+
async doHeartbeat() {
|
|
2245
|
+
try {
|
|
2246
|
+
await this.repo.updateHeartbeat(this._workerId);
|
|
2247
|
+
} catch (err) {
|
|
2248
|
+
getLogger().warn(
|
|
2249
|
+
{ err, workerId: this._workerId },
|
|
2250
|
+
"[worker-registry] heartbeat failed \u2014 will retry next tick"
|
|
2251
|
+
);
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
static validateHeartbeatInterval(value) {
|
|
2255
|
+
if (value == null) return;
|
|
2256
|
+
const MAX_HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
2257
|
+
if (!Number.isInteger(value) || value < WORKERS_MIN_HEARTBEAT_INTERVAL_MS || value > MAX_HEARTBEAT_INTERVAL_MS) {
|
|
2258
|
+
throw new Error(
|
|
2259
|
+
`[ravenflow] WorkerRegistry heartbeatIntervalMs must be an integer in [${WORKERS_MIN_HEARTBEAT_INTERVAL_MS}, ${MAX_HEARTBEAT_INTERVAL_MS}], got ${value}`
|
|
2260
|
+
);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
static validateQueueEntry(q) {
|
|
2264
|
+
if (q === null || typeof q !== "object") {
|
|
2265
|
+
throw new Error("WorkerRegistry.registerQueue: queue must be an object");
|
|
2266
|
+
}
|
|
2267
|
+
if (typeof q.name !== "string" || q.name.length === 0) {
|
|
2268
|
+
throw new Error(
|
|
2269
|
+
"WorkerRegistry.registerQueue: queue.name must be a non-empty string"
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
if (!Number.isInteger(q.concurrency) || q.concurrency <= 0) {
|
|
2273
|
+
throw new Error(
|
|
2274
|
+
"WorkerRegistry.registerQueue: queue.concurrency must be a positive integer"
|
|
2275
|
+
);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
};
|
|
2279
|
+
|
|
2280
|
+
// src/monitoring/batch-writer.types.ts
|
|
2281
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 3e4;
|
|
2282
|
+
var MIN_BUFFER_SIZE = 1e3;
|
|
2283
|
+
var MAX_BUFFER_SIZE = 5e3;
|
|
2284
|
+
var HARD_CAP_MULTIPLIER = 10;
|
|
2285
|
+
var DRAIN_RETRY_BACKOFFS_MS = [0, 1e3, 2e3, 4e3];
|
|
2286
|
+
|
|
2287
|
+
// src/monitoring/batch-writer.ts
|
|
2288
|
+
var BatchWriter = class {
|
|
2289
|
+
constructor(collection3, config) {
|
|
2290
|
+
this.buffer = [];
|
|
2291
|
+
this.timer = null;
|
|
2292
|
+
this.flushing = null;
|
|
2293
|
+
this.drained = false;
|
|
2294
|
+
this.validateConfig(config);
|
|
2295
|
+
this.collection = collection3;
|
|
2296
|
+
this.maxBufferSize = config.maxBufferSize;
|
|
2297
|
+
this.hardCapSize = config.maxBufferSize * HARD_CAP_MULTIPLIER;
|
|
2298
|
+
this.flushIntervalMs = config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
2299
|
+
this.startTimer();
|
|
2300
|
+
}
|
|
2301
|
+
push(operation) {
|
|
2302
|
+
if (this.drained) {
|
|
2303
|
+
getLogger().warn(
|
|
2304
|
+
"[batch-writer] push called after drain \u2014 operation ignored"
|
|
2305
|
+
);
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
this.buffer.push(operation);
|
|
2309
|
+
this.enforceHardCap();
|
|
2310
|
+
if (this.buffer.length >= this.maxBufferSize) {
|
|
2311
|
+
this.resetTimer();
|
|
2312
|
+
void this.flush();
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
async drain() {
|
|
2316
|
+
this.drained = true;
|
|
2317
|
+
this.stopTimer();
|
|
2318
|
+
if (this.flushing) await this.flushing;
|
|
2319
|
+
for (const backoffMs of DRAIN_RETRY_BACKOFFS_MS) {
|
|
2320
|
+
if (this.buffer.length === 0) return;
|
|
2321
|
+
if (backoffMs > 0) {
|
|
2322
|
+
await new Promise((resolve2) => setTimeout(resolve2, backoffMs));
|
|
2323
|
+
}
|
|
2324
|
+
await this.flush();
|
|
2325
|
+
}
|
|
2326
|
+
if (this.buffer.length > 0) {
|
|
2327
|
+
getLogger().error(
|
|
2328
|
+
{ unflushed: this.buffer.length },
|
|
2329
|
+
"[batch-writer] drain exhausted retries \u2014 operations lost"
|
|
2330
|
+
);
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
async flush() {
|
|
2334
|
+
if (this.flushing) return this.flushing;
|
|
2335
|
+
if (this.buffer.length === 0) return;
|
|
2336
|
+
this.flushing = this.doFlush();
|
|
2337
|
+
try {
|
|
2338
|
+
await this.flushing;
|
|
2339
|
+
} finally {
|
|
2340
|
+
this.flushing = null;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
async doFlush() {
|
|
2344
|
+
while (this.buffer.length > 0) {
|
|
2345
|
+
const snapshot = this.buffer;
|
|
2346
|
+
this.buffer = [];
|
|
2347
|
+
try {
|
|
2348
|
+
await this.collection.bulkWrite(snapshot, {
|
|
2349
|
+
ordered: false,
|
|
2350
|
+
maxTimeMS: this.flushIntervalMs
|
|
2351
|
+
});
|
|
2352
|
+
} catch (err) {
|
|
2353
|
+
if (err instanceof mongodb.MongoBulkWriteError && Array.isArray(err.writeErrors) && err.writeErrors.length > 0) {
|
|
2354
|
+
getLogger().warn(
|
|
2355
|
+
{
|
|
2356
|
+
failed: err.writeErrors.length,
|
|
2357
|
+
total: snapshot.length
|
|
2358
|
+
},
|
|
2359
|
+
"[batch-writer] bulkWrite partial failure \u2014 dropping failed ops (likely persistent errors)"
|
|
2360
|
+
);
|
|
2361
|
+
continue;
|
|
2362
|
+
}
|
|
2363
|
+
this.buffer = [...snapshot, ...this.buffer];
|
|
2364
|
+
this.enforceHardCap();
|
|
2365
|
+
getLogger().warn(
|
|
2366
|
+
{ err, count: snapshot.length },
|
|
2367
|
+
"[batch-writer] flush failed \u2014 will retry on next tick or threshold"
|
|
2368
|
+
);
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
enforceHardCap() {
|
|
2374
|
+
if (this.buffer.length <= this.hardCapSize) return;
|
|
2375
|
+
const dropCount = this.buffer.length - this.hardCapSize;
|
|
2376
|
+
this.buffer.splice(0, dropCount);
|
|
2377
|
+
getLogger().warn(
|
|
2378
|
+
{ dropCount, hardCapSize: this.hardCapSize },
|
|
2379
|
+
"[batch-writer] hard cap exceeded \u2014 dropped oldest operations"
|
|
2380
|
+
);
|
|
2381
|
+
}
|
|
2382
|
+
startTimer() {
|
|
2383
|
+
this.timer = setInterval(() => {
|
|
2384
|
+
void this.flush();
|
|
2385
|
+
}, this.flushIntervalMs);
|
|
2386
|
+
this.timer.unref();
|
|
2387
|
+
}
|
|
2388
|
+
stopTimer() {
|
|
2389
|
+
if (this.timer) {
|
|
2390
|
+
clearInterval(this.timer);
|
|
2391
|
+
this.timer = null;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
resetTimer() {
|
|
2395
|
+
this.stopTimer();
|
|
2396
|
+
this.startTimer();
|
|
2397
|
+
}
|
|
2398
|
+
validateConfig(config) {
|
|
2399
|
+
if (!Number.isInteger(config.maxBufferSize) || config.maxBufferSize < MIN_BUFFER_SIZE || config.maxBufferSize > MAX_BUFFER_SIZE) {
|
|
2400
|
+
throw new Error(
|
|
2401
|
+
`[ravenflow] BatchWriter maxBufferSize must be an integer in [${MIN_BUFFER_SIZE}, ${MAX_BUFFER_SIZE}], got ${config.maxBufferSize}`
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
if (config.flushIntervalMs !== void 0) {
|
|
2405
|
+
if (!Number.isInteger(config.flushIntervalMs) || config.flushIntervalMs <= 0) {
|
|
2406
|
+
throw new Error(
|
|
2407
|
+
`[ravenflow] BatchWriter flushIntervalMs must be a positive integer, got ${config.flushIntervalMs}`
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
};
|
|
2413
|
+
|
|
2414
|
+
// src/monitoring/log-capture.ts
|
|
2415
|
+
async function captureJobLogs(queue, jobId, maxChars) {
|
|
2416
|
+
if (typeof jobId !== "string" || jobId.length === 0) {
|
|
2417
|
+
throw new Error("captureJobLogs: jobId must be a non-empty string");
|
|
2418
|
+
}
|
|
2419
|
+
if (!Number.isInteger(maxChars) || maxChars <= 0) {
|
|
2420
|
+
throw new Error(
|
|
2421
|
+
`captureJobLogs: maxChars must be a positive integer, got ${maxChars}`
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
let logs;
|
|
2425
|
+
try {
|
|
2426
|
+
const result = await queue.getJobLogs(jobId, 0, -1, true);
|
|
2427
|
+
logs = result.logs;
|
|
2428
|
+
} catch (err) {
|
|
2429
|
+
getLogger().warn(
|
|
2430
|
+
{ err, jobId },
|
|
2431
|
+
"[log-capture] failed to fetch job logs \u2014 returning empty string"
|
|
2432
|
+
);
|
|
2433
|
+
return "";
|
|
2434
|
+
}
|
|
2435
|
+
const joined = logs.join("\n");
|
|
2436
|
+
if (joined.length <= maxChars) return joined;
|
|
2437
|
+
const originalLen = joined.length;
|
|
2438
|
+
let tailLen = maxChars;
|
|
2439
|
+
let marker = "";
|
|
2440
|
+
for (let i = 0; i < 3; i++) {
|
|
2441
|
+
marker = `[truncated \u2014 showing last ${tailLen} of ${originalLen} chars]
|
|
2442
|
+
`;
|
|
2443
|
+
if (marker.length >= maxChars) {
|
|
2444
|
+
return marker.slice(0, maxChars);
|
|
2445
|
+
}
|
|
2446
|
+
tailLen = maxChars - marker.length;
|
|
2447
|
+
}
|
|
2448
|
+
return marker + joined.slice(-tailLen);
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
// src/monitoring/event-hooks.ts
|
|
2452
|
+
function attachEventHooks(worker, options) {
|
|
2453
|
+
worker.on("completed", (job) => {
|
|
2454
|
+
void handleCompleted(job, options);
|
|
2455
|
+
});
|
|
2456
|
+
worker.on("failed", (job, error) => {
|
|
2457
|
+
void handleFailed(job, error, options);
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
async function handleCompleted(job, options) {
|
|
2461
|
+
try {
|
|
2462
|
+
const jobType = resolveTopLevelJobType(job, options.jobConfigs);
|
|
2463
|
+
if (jobType === null) return;
|
|
2464
|
+
const durationMs = computeDurationMs(job);
|
|
2465
|
+
const ops = options.jobStatsRepo.toIncrementStatsOps(jobType, {
|
|
2466
|
+
success: true,
|
|
2467
|
+
durationMs
|
|
2468
|
+
});
|
|
2469
|
+
for (const op of ops) {
|
|
2470
|
+
options.jobStatsBatchWriter.push(op);
|
|
2471
|
+
}
|
|
2472
|
+
} catch (err) {
|
|
2473
|
+
getLogger().warn(
|
|
2474
|
+
{ err, jobId: job?.id, path: "stats" },
|
|
2475
|
+
"[event-hooks] completed handler failed"
|
|
2476
|
+
);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
async function handleFailed(job, error, options) {
|
|
2480
|
+
if (job === void 0) {
|
|
2481
|
+
getLogger().warn(
|
|
2482
|
+
{ message: error?.message },
|
|
2483
|
+
"[event-hooks] failed event fired without a job \u2014 skipping"
|
|
2484
|
+
);
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
if (job.id === void 0 || job.id === null) {
|
|
2488
|
+
getLogger().warn(
|
|
2489
|
+
{ jobName: job.name },
|
|
2490
|
+
"[event-hooks] failed event fired without a job.id \u2014 skipping"
|
|
2491
|
+
);
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
try {
|
|
2495
|
+
const jobType = resolveTopLevelJobType(job, options.jobConfigs);
|
|
2496
|
+
if (jobType === null) return;
|
|
2497
|
+
const durationMs = computeDurationMs(job);
|
|
2498
|
+
const failedAt = typeof job.finishedOn === "number" && Number.isFinite(job.finishedOn) ? new Date(job.finishedOn) : /* @__PURE__ */ new Date();
|
|
2499
|
+
const jobId = String(job.id);
|
|
2500
|
+
const logsMaxChars = options.logsMaxChars ?? JOB_FAILURES_LOGS_MAX_CHARS;
|
|
2501
|
+
try {
|
|
2502
|
+
const ops = options.jobStatsRepo.toIncrementStatsOps(jobType, {
|
|
2503
|
+
success: false,
|
|
2504
|
+
durationMs
|
|
2505
|
+
});
|
|
2506
|
+
for (const op of ops) {
|
|
2507
|
+
options.jobStatsBatchWriter.push(op);
|
|
2508
|
+
}
|
|
2509
|
+
} catch (err) {
|
|
2510
|
+
getLogger().warn(
|
|
2511
|
+
{ err, jobId, path: "stats" },
|
|
2512
|
+
"[event-hooks] failed handler: stats write failed"
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
try {
|
|
2516
|
+
const op = options.jobErrorsRepo.toRecordOccurrenceOp({
|
|
2517
|
+
jobType,
|
|
2518
|
+
error
|
|
2519
|
+
});
|
|
2520
|
+
options.jobErrorsBatchWriter.push(op);
|
|
2521
|
+
} catch (err) {
|
|
2522
|
+
getLogger().warn(
|
|
2523
|
+
{ err, jobId, path: "errors" },
|
|
2524
|
+
"[event-hooks] failed handler: errors write failed"
|
|
2525
|
+
);
|
|
2526
|
+
}
|
|
2527
|
+
let logs = "";
|
|
2528
|
+
try {
|
|
2529
|
+
logs = await captureJobLogs(options.queue, jobId, logsMaxChars);
|
|
2530
|
+
} catch (err) {
|
|
2531
|
+
getLogger().warn(
|
|
2532
|
+
{ err, jobId, path: "failures" },
|
|
2533
|
+
"[event-hooks] failed handler: log capture failed \u2014 recording failure with empty logs"
|
|
2534
|
+
);
|
|
2535
|
+
}
|
|
2536
|
+
try {
|
|
2537
|
+
const op = options.jobFailuresRepo.toRecordFailureOp({
|
|
2538
|
+
jobId,
|
|
2539
|
+
jobType,
|
|
2540
|
+
input: job.data,
|
|
2541
|
+
error,
|
|
2542
|
+
failedAt,
|
|
2543
|
+
logs
|
|
2544
|
+
});
|
|
2545
|
+
options.jobFailuresBatchWriter.push(op);
|
|
2546
|
+
} catch (err) {
|
|
2547
|
+
getLogger().warn(
|
|
2548
|
+
{ err, jobId, path: "failures" },
|
|
2549
|
+
"[event-hooks] failed handler: failures write failed"
|
|
2550
|
+
);
|
|
2551
|
+
}
|
|
2552
|
+
} catch (err) {
|
|
2553
|
+
getLogger().warn(
|
|
2554
|
+
{ err, jobId: job.id, path: "unknown" },
|
|
2555
|
+
"[event-hooks] failed handler failed"
|
|
2556
|
+
);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
function computeDurationMs(job) {
|
|
2560
|
+
const processedOn = job.processedOn;
|
|
2561
|
+
const finishedOn = job.finishedOn;
|
|
2562
|
+
if (typeof processedOn !== "number" || !Number.isFinite(processedOn)) return void 0;
|
|
2563
|
+
if (typeof finishedOn !== "number" || !Number.isFinite(finishedOn)) return void 0;
|
|
2564
|
+
const delta = finishedOn - processedOn;
|
|
2565
|
+
if (delta < 0) return void 0;
|
|
2566
|
+
return delta;
|
|
2567
|
+
}
|
|
2568
|
+
function resolveTopLevelJobType(job, jobConfigs) {
|
|
2569
|
+
const { baseName, suffix } = resolveJobName(job.name, jobConfigs);
|
|
2570
|
+
if (suffix !== null) return null;
|
|
2571
|
+
if (!Object.prototype.hasOwnProperty.call(jobConfigs, baseName)) {
|
|
2572
|
+
getLogger().debug(
|
|
2573
|
+
{ jobName: job.name, jobId: job.id },
|
|
2574
|
+
"[event-hooks] skipping event for unknown jobType"
|
|
2575
|
+
);
|
|
2576
|
+
return null;
|
|
2577
|
+
}
|
|
2578
|
+
return baseName;
|
|
2579
|
+
}
|
|
2580
|
+
var DISABLED_JOB_DEFER_DELAY_MS = 6e4;
|
|
2581
|
+
async function safeEnsureEntriesAtBoot(jobConfigsRepo, jobs) {
|
|
2582
|
+
try {
|
|
2583
|
+
await jobConfigsRepo.ensureEntries(jobs);
|
|
2584
|
+
} catch (err) {
|
|
2585
|
+
getLogger().error(
|
|
2586
|
+
{ err },
|
|
2587
|
+
"[app] job_configs ensureEntries failed at boot \u2014 dashboard may show a stale job-type list"
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
async function deferIfDisabled(job, jobConfigs, jobConfigsRepo, token) {
|
|
2592
|
+
if (typeof job.name !== "string" || job.name.length === 0) return;
|
|
2593
|
+
if (jobConfigs === null || typeof jobConfigs !== "object") return;
|
|
2594
|
+
if (token === void 0) {
|
|
2595
|
+
getLogger().warn(
|
|
2596
|
+
{ jobId: job.id, jobName: job.name },
|
|
2597
|
+
"[app] deferIfDisabled invoked with undefined token \u2014 moveToDelayed may fail under lock contention"
|
|
2598
|
+
);
|
|
2599
|
+
}
|
|
2600
|
+
const { baseName } = resolveJobName(job.name, jobConfigs);
|
|
2601
|
+
if (!baseName) {
|
|
2602
|
+
getLogger().warn(
|
|
2603
|
+
{ jobId: job.id, jobName: job.name },
|
|
2604
|
+
"[app] deferIfDisabled: resolveJobName returned empty baseName \u2014 skipping gate"
|
|
2605
|
+
);
|
|
2606
|
+
return;
|
|
2607
|
+
}
|
|
2608
|
+
const enabled = await jobConfigsRepo.isEnabled(baseName);
|
|
2609
|
+
if (enabled) return;
|
|
2610
|
+
const prev = job.progress;
|
|
2611
|
+
const prevCount = typeof prev === "object" && prev !== null && !Array.isArray(prev) ? prev.disabledDeferCount : void 0;
|
|
2612
|
+
const nextCount = typeof prevCount === "number" && Number.isFinite(prevCount) ? prevCount + 1 : 1;
|
|
2613
|
+
await job.moveToDelayed(Date.now() + DISABLED_JOB_DEFER_DELAY_MS, token);
|
|
2614
|
+
try {
|
|
2615
|
+
await job.updateProgress({ disabledDeferCount: nextCount });
|
|
2616
|
+
} catch (err) {
|
|
2617
|
+
getLogger().warn(
|
|
2618
|
+
{ err, jobType: baseName, jobId: job.id },
|
|
2619
|
+
"[app] deferIfDisabled: updateProgress failed post-defer \u2014 counter may lag by one"
|
|
2620
|
+
);
|
|
2621
|
+
}
|
|
2622
|
+
getLogger().info(
|
|
2623
|
+
{ jobType: baseName, jobId: job.id, disabledDeferCount: nextCount },
|
|
2624
|
+
"[app] job deferred: disabled"
|
|
2625
|
+
);
|
|
2626
|
+
throw new bullmq.DelayedError();
|
|
2627
|
+
}
|
|
2628
|
+
|
|
1332
2629
|
// src/app/app.ts
|
|
2630
|
+
var DEFAULT_MONITORING_BATCH_BUFFER = MIN_BUFFER_SIZE;
|
|
1333
2631
|
var MONGO_REQUIRED_TYPES = /* @__PURE__ */ new Set(["stateful-etl", "token-bucket"]);
|
|
1334
2632
|
function resolveMongoUri(mongo) {
|
|
1335
2633
|
if (!mongo) return null;
|
|
@@ -1348,9 +2646,39 @@ function validateConfig(config, mongoUri) {
|
|
|
1348
2646
|
`"${jobName}" (${jc.type}) requires a mongo connection. Pass { mongo: "mongodb://..." } to createRavenflowApp().`
|
|
1349
2647
|
);
|
|
1350
2648
|
}
|
|
1351
|
-
if (jc.type === "token-bucket" && !jc.cron) {
|
|
2649
|
+
if (jc.type === "token-bucket" && !jc.cron?.trim()) {
|
|
1352
2650
|
errors.push(`"${jobName}" (token-bucket) requires a cron schedule.`);
|
|
1353
2651
|
}
|
|
2652
|
+
if (jc.type === "token-bucket" && !jc.rate && !jc.getBudget) {
|
|
2653
|
+
errors.push(
|
|
2654
|
+
`"${jobName}" (token-bucket) requires at least one of "rate" or "getBudget".`
|
|
2655
|
+
);
|
|
2656
|
+
}
|
|
2657
|
+
if (jc.type === "token-bucket" && jc.rate) {
|
|
2658
|
+
const validIntervals = /* @__PURE__ */ new Set(["second", "minute", "hour", "day", "week"]);
|
|
2659
|
+
if (!validIntervals.has(jc.rate.interval)) {
|
|
2660
|
+
errors.push(
|
|
2661
|
+
`"${jobName}" (token-bucket) has invalid rate interval "${jc.rate.interval}". Must be one of: second, minute, hour, day, week.`
|
|
2662
|
+
);
|
|
2663
|
+
}
|
|
2664
|
+
if (jc.rate.amount <= 0) {
|
|
2665
|
+
errors.push(
|
|
2666
|
+
`"${jobName}" (token-bucket) rate.amount must be a positive number.`
|
|
2667
|
+
);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
if (jc.type === "token-bucket") {
|
|
2671
|
+
if (jc.maxCarryOver !== void 0 && jc.maxCarryOver < 0) {
|
|
2672
|
+
errors.push(
|
|
2673
|
+
`"${jobName}" (token-bucket) maxCarryOver must be >= 0.`
|
|
2674
|
+
);
|
|
2675
|
+
}
|
|
2676
|
+
if (jc.consumeTimeout !== void 0 && jc.consumeTimeout <= 0) {
|
|
2677
|
+
errors.push(
|
|
2678
|
+
`"${jobName}" (token-bucket) consumeTimeout must be a positive number.`
|
|
2679
|
+
);
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
1354
2682
|
if ("steps" in jc && jc.steps) {
|
|
1355
2683
|
for (const [stepName, stepConfig] of Object.entries(jc.steps)) {
|
|
1356
2684
|
if (stepConfig?.queue && stepConfig.queue !== config.defaultQueue) {
|
|
@@ -1379,6 +2707,16 @@ var RavenflowApp = class {
|
|
|
1379
2707
|
this.defaultConcurrency = 5;
|
|
1380
2708
|
this.workers = [];
|
|
1381
2709
|
this.stallWatcher = null;
|
|
2710
|
+
this.workersRepo = null;
|
|
2711
|
+
this.workerRegistry = null;
|
|
2712
|
+
this.jobStatsRepo = null;
|
|
2713
|
+
this.jobErrorsRepo = null;
|
|
2714
|
+
this.jobFailuresRepo = null;
|
|
2715
|
+
this.jobConfigsRepo = null;
|
|
2716
|
+
this.jobStatsBatchWriter = null;
|
|
2717
|
+
this.jobErrorsBatchWriter = null;
|
|
2718
|
+
this.jobFailuresBatchWriter = null;
|
|
2719
|
+
this.monitoringInitPromise = null;
|
|
1382
2720
|
this.redisConnection = options.redis;
|
|
1383
2721
|
this.config = config;
|
|
1384
2722
|
this.mongoUri = resolveMongoUri(options.mongo);
|
|
@@ -1388,16 +2726,52 @@ var RavenflowApp = class {
|
|
|
1388
2726
|
const { jobsProxy, jobConfigs } = initJobs(config, this.queues);
|
|
1389
2727
|
this.jobs = jobsProxy;
|
|
1390
2728
|
this.jobConfigs = jobConfigs;
|
|
2729
|
+
this.monitoringOptions = {
|
|
2730
|
+
enabled: this.mongoUri !== null && options.monitoring?.enabled !== false,
|
|
2731
|
+
heartbeatIntervalMs: options.monitoring?.heartbeatIntervalMs,
|
|
2732
|
+
batchWriter: {
|
|
2733
|
+
maxBufferSize: options.monitoring?.batchWriter?.maxBufferSize ?? DEFAULT_MONITORING_BATCH_BUFFER,
|
|
2734
|
+
flushIntervalMs: options.monitoring?.batchWriter?.flushIntervalMs
|
|
2735
|
+
}
|
|
2736
|
+
};
|
|
2737
|
+
if (options.monitoring?.enabled === true && this.mongoUri === null) {
|
|
2738
|
+
getLogger().warn(
|
|
2739
|
+
{},
|
|
2740
|
+
"[ravenflow] monitoring.enabled=true was requested but no mongo connection is configured \u2014 monitoring is disabled"
|
|
2741
|
+
);
|
|
2742
|
+
}
|
|
1391
2743
|
}
|
|
1392
2744
|
async startWorker(queueName, options) {
|
|
1393
2745
|
if (this.mongoUri) {
|
|
1394
2746
|
await connectMongo(this.mongoUri);
|
|
2747
|
+
await token_bucket_repo_exports.ensureIndexes();
|
|
1395
2748
|
}
|
|
1396
2749
|
const queueData = this.queues[queueName];
|
|
1397
2750
|
if (!queueData) {
|
|
1398
2751
|
throw new Error(`Queue ${queueName} not found in config.`);
|
|
1399
2752
|
}
|
|
1400
2753
|
const concurrency = options?.concurrency ?? this.defaultConcurrency;
|
|
2754
|
+
if (this.mongoUri && this.monitoringOptions.enabled) {
|
|
2755
|
+
if (!this.workerRegistry) {
|
|
2756
|
+
if (!this.monitoringInitPromise) {
|
|
2757
|
+
this.monitoringInitPromise = this.initializeMonitoring(queueName, concurrency);
|
|
2758
|
+
}
|
|
2759
|
+
try {
|
|
2760
|
+
await this.monitoringInitPromise;
|
|
2761
|
+
} catch {
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
if (this.workerRegistry) {
|
|
2765
|
+
try {
|
|
2766
|
+
await this.workerRegistry.registerQueue({ name: queueName, concurrency });
|
|
2767
|
+
} catch (err) {
|
|
2768
|
+
getLogger().error(
|
|
2769
|
+
{ err },
|
|
2770
|
+
"[app] worker registry registerQueue failed \u2014 will retry on next startWorker call"
|
|
2771
|
+
);
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
1401
2775
|
printBanner(
|
|
1402
2776
|
queueName,
|
|
1403
2777
|
concurrency,
|
|
@@ -1421,7 +2795,16 @@ var RavenflowApp = class {
|
|
|
1421
2795
|
queueName,
|
|
1422
2796
|
this.redisConnection,
|
|
1423
2797
|
concurrency,
|
|
1424
|
-
async (job) => {
|
|
2798
|
+
async (job, token) => {
|
|
2799
|
+
const configsRepo = this.jobConfigsRepo;
|
|
2800
|
+
if (configsRepo) {
|
|
2801
|
+
await deferIfDisabled(
|
|
2802
|
+
job,
|
|
2803
|
+
this.jobConfigs,
|
|
2804
|
+
configsRepo,
|
|
2805
|
+
token
|
|
2806
|
+
);
|
|
2807
|
+
}
|
|
1425
2808
|
if (this.stallWatcher?.canProcess(job.name)) {
|
|
1426
2809
|
return this.stallWatcher.process(job);
|
|
1427
2810
|
}
|
|
@@ -1431,10 +2814,100 @@ var RavenflowApp = class {
|
|
|
1431
2814
|
setupWorkerEvents(worker, queueName);
|
|
1432
2815
|
this.workers.push(worker);
|
|
1433
2816
|
worker.run();
|
|
2817
|
+
if (this.monitoringOptions.enabled && this.jobStatsRepo && this.jobErrorsRepo && this.jobFailuresRepo && this.jobStatsBatchWriter && this.jobErrorsBatchWriter && this.jobFailuresBatchWriter) {
|
|
2818
|
+
attachEventHooks(worker, {
|
|
2819
|
+
queue: queueData.queue,
|
|
2820
|
+
jobConfigs: this.jobConfigs,
|
|
2821
|
+
jobStatsRepo: this.jobStatsRepo,
|
|
2822
|
+
jobErrorsRepo: this.jobErrorsRepo,
|
|
2823
|
+
jobFailuresRepo: this.jobFailuresRepo,
|
|
2824
|
+
jobStatsBatchWriter: this.jobStatsBatchWriter,
|
|
2825
|
+
jobErrorsBatchWriter: this.jobErrorsBatchWriter,
|
|
2826
|
+
jobFailuresBatchWriter: this.jobFailuresBatchWriter
|
|
2827
|
+
});
|
|
2828
|
+
}
|
|
1434
2829
|
if (this.stallWatcher) {
|
|
1435
2830
|
await this.stallWatcher.start();
|
|
1436
2831
|
}
|
|
1437
2832
|
}
|
|
2833
|
+
async initializeMonitoring(queueName, concurrency) {
|
|
2834
|
+
let registry = null;
|
|
2835
|
+
let statsBatchWriter = null;
|
|
2836
|
+
let errorsBatchWriter = null;
|
|
2837
|
+
let failuresBatchWriter = null;
|
|
2838
|
+
try {
|
|
2839
|
+
const db2 = getDb();
|
|
2840
|
+
const workersRepo = new WorkersRepo(db2);
|
|
2841
|
+
const statsRepo = new JobStatsRepo(db2);
|
|
2842
|
+
const errorsRepo = new JobErrorsRepo(db2);
|
|
2843
|
+
const failuresRepo = new JobFailuresRepo(db2);
|
|
2844
|
+
const configsRepo = new JobConfigsRepo(db2);
|
|
2845
|
+
await Promise.all([
|
|
2846
|
+
workersRepo.init(),
|
|
2847
|
+
statsRepo.init(),
|
|
2848
|
+
errorsRepo.init(),
|
|
2849
|
+
failuresRepo.init(),
|
|
2850
|
+
configsRepo.init()
|
|
2851
|
+
]);
|
|
2852
|
+
await safeEnsureEntriesAtBoot(
|
|
2853
|
+
configsRepo,
|
|
2854
|
+
this.config.jobs
|
|
2855
|
+
);
|
|
2856
|
+
registry = new WorkerRegistry(workersRepo, {
|
|
2857
|
+
heartbeatIntervalMs: this.monitoringOptions.heartbeatIntervalMs
|
|
2858
|
+
});
|
|
2859
|
+
await registry.registerQueue({ name: queueName, concurrency });
|
|
2860
|
+
await registry.start();
|
|
2861
|
+
statsBatchWriter = new BatchWriter(
|
|
2862
|
+
statsRepo.collection,
|
|
2863
|
+
this.monitoringOptions.batchWriter
|
|
2864
|
+
);
|
|
2865
|
+
errorsBatchWriter = new BatchWriter(
|
|
2866
|
+
errorsRepo.collection,
|
|
2867
|
+
this.monitoringOptions.batchWriter
|
|
2868
|
+
);
|
|
2869
|
+
failuresBatchWriter = new BatchWriter(
|
|
2870
|
+
failuresRepo.collection,
|
|
2871
|
+
this.monitoringOptions.batchWriter
|
|
2872
|
+
);
|
|
2873
|
+
this.workersRepo = workersRepo;
|
|
2874
|
+
this.workerRegistry = registry;
|
|
2875
|
+
this.jobStatsRepo = statsRepo;
|
|
2876
|
+
this.jobErrorsRepo = errorsRepo;
|
|
2877
|
+
this.jobFailuresRepo = failuresRepo;
|
|
2878
|
+
this.jobConfigsRepo = configsRepo;
|
|
2879
|
+
this.jobStatsBatchWriter = statsBatchWriter;
|
|
2880
|
+
this.jobErrorsBatchWriter = errorsBatchWriter;
|
|
2881
|
+
this.jobFailuresBatchWriter = failuresBatchWriter;
|
|
2882
|
+
} catch (err) {
|
|
2883
|
+
getLogger().error(
|
|
2884
|
+
{ err },
|
|
2885
|
+
"[app] monitoring setup failed \u2014 continuing without observability writes"
|
|
2886
|
+
);
|
|
2887
|
+
if (registry) {
|
|
2888
|
+
await registry.stop().catch(() => {
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
const drainables = [statsBatchWriter, errorsBatchWriter, failuresBatchWriter].filter(
|
|
2892
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous BatchWriter union; typed narrowing deferred per Story 1.8 AC #14
|
|
2893
|
+
(bw) => bw !== null
|
|
2894
|
+
);
|
|
2895
|
+
if (drainables.length > 0) {
|
|
2896
|
+
await Promise.allSettled(drainables.map((bw) => bw.drain()));
|
|
2897
|
+
}
|
|
2898
|
+
this.workerRegistry = null;
|
|
2899
|
+
this.workersRepo = null;
|
|
2900
|
+
this.jobStatsRepo = null;
|
|
2901
|
+
this.jobErrorsRepo = null;
|
|
2902
|
+
this.jobFailuresRepo = null;
|
|
2903
|
+
this.jobConfigsRepo = null;
|
|
2904
|
+
this.jobStatsBatchWriter = null;
|
|
2905
|
+
this.jobErrorsBatchWriter = null;
|
|
2906
|
+
this.jobFailuresBatchWriter = null;
|
|
2907
|
+
} finally {
|
|
2908
|
+
this.monitoringInitPromise = null;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
1438
2911
|
async close() {
|
|
1439
2912
|
const logger2 = getLogger();
|
|
1440
2913
|
logger2.info("Shutting down...");
|
|
@@ -1442,7 +2915,28 @@ var RavenflowApp = class {
|
|
|
1442
2915
|
await this.stallWatcher.stop();
|
|
1443
2916
|
this.stallWatcher = null;
|
|
1444
2917
|
}
|
|
2918
|
+
if (this.workerRegistry) {
|
|
2919
|
+
await this.workerRegistry.stop();
|
|
2920
|
+
this.workerRegistry = null;
|
|
2921
|
+
this.workersRepo = null;
|
|
2922
|
+
}
|
|
1445
2923
|
await closeWorkers(this.workers);
|
|
2924
|
+
const drainables = [
|
|
2925
|
+
this.jobStatsBatchWriter,
|
|
2926
|
+
this.jobErrorsBatchWriter,
|
|
2927
|
+
this.jobFailuresBatchWriter
|
|
2928
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous BatchWriter union; typed narrowing deferred per Story 1.8 AC #14
|
|
2929
|
+
].filter((bw) => bw !== null);
|
|
2930
|
+
if (drainables.length > 0) {
|
|
2931
|
+
await Promise.allSettled(drainables.map((bw) => bw.drain()));
|
|
2932
|
+
}
|
|
2933
|
+
this.jobStatsBatchWriter = null;
|
|
2934
|
+
this.jobErrorsBatchWriter = null;
|
|
2935
|
+
this.jobFailuresBatchWriter = null;
|
|
2936
|
+
this.jobStatsRepo = null;
|
|
2937
|
+
this.jobErrorsRepo = null;
|
|
2938
|
+
this.jobFailuresRepo = null;
|
|
2939
|
+
this.jobConfigsRepo = null;
|
|
1446
2940
|
await closeQueues(this.queues);
|
|
1447
2941
|
await closeMongo();
|
|
1448
2942
|
logger2.info("Shutdown complete.");
|
|
@@ -1454,14 +2948,34 @@ function createRavenflowApp(config, options) {
|
|
|
1454
2948
|
return new RavenflowApp(config, options);
|
|
1455
2949
|
}
|
|
1456
2950
|
|
|
2951
|
+
exports.BatchWriter = BatchWriter;
|
|
2952
|
+
exports.DEFAULT_WORKER_METRICS = DEFAULT_WORKER_METRICS;
|
|
2953
|
+
exports.DISABLED_JOB_DEFER_DELAY_MS = DISABLED_JOB_DEFER_DELAY_MS;
|
|
2954
|
+
exports.JOB_FAILURES_INPUT_MAX_CHARS = JOB_FAILURES_INPUT_MAX_CHARS;
|
|
2955
|
+
exports.JOB_FAILURES_LOGS_MAX_CHARS = JOB_FAILURES_LOGS_MAX_CHARS;
|
|
2956
|
+
exports.JOB_FAILURES_MESSAGE_MAX_CHARS = JOB_FAILURES_MESSAGE_MAX_CHARS;
|
|
2957
|
+
exports.JOB_FAILURES_STACK_MAX_CHARS = JOB_FAILURES_STACK_MAX_CHARS;
|
|
2958
|
+
exports.JobConfigsRepo = JobConfigsRepo;
|
|
2959
|
+
exports.JobErrorsRepo = JobErrorsRepo;
|
|
2960
|
+
exports.JobFailuresRepo = JobFailuresRepo;
|
|
2961
|
+
exports.JobStatsRepo = JobStatsRepo;
|
|
2962
|
+
exports.METRICS_TIME = METRICS_TIME;
|
|
1457
2963
|
exports.RavenflowApp = RavenflowApp;
|
|
2964
|
+
exports.Repository = Repository;
|
|
2965
|
+
exports.WorkerRegistry = WorkerRegistry;
|
|
2966
|
+
exports.WorkersRepo = WorkersRepo;
|
|
2967
|
+
exports.attachEventHooks = attachEventHooks;
|
|
2968
|
+
exports.captureJobLogs = captureJobLogs;
|
|
2969
|
+
exports.computeBucketStart = computeBucketStart;
|
|
1458
2970
|
exports.createRavenflowApp = createRavenflowApp;
|
|
2971
|
+
exports.deferIfDisabled = deferIfDisabled;
|
|
1459
2972
|
exports.defineConfig = defineConfig;
|
|
1460
2973
|
exports.defineDistributedEtlJob = defineDistributedEtlJob;
|
|
1461
2974
|
exports.defineMemoryEtlJob = defineMemoryEtlJob;
|
|
1462
2975
|
exports.defineSimpleJob = defineSimpleJob;
|
|
1463
2976
|
exports.defineStatefulEtlJob = defineStatefulEtlJob;
|
|
1464
2977
|
exports.defineTokenBucketJob = defineTokenBucketJob;
|
|
2978
|
+
exports.errorSignature = errorSignature;
|
|
1465
2979
|
exports.getLogger = getLogger;
|
|
1466
2980
|
exports.initLogger = initLogger;
|
|
1467
2981
|
//# sourceMappingURL=index.js.map
|