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/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 TOKEN_BUCKET_PROCESS_SUFFIX = ":process";
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, windowResetAt) {
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, windowDispatched, windowResetAt) {
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/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]);
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/process.ts
816
+ // src/processors/token-bucket-job.processor/index.ts
878
817
  async function processTokenBucketJob(job, config, queues) {
879
818
  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 };
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
- TOKEN_BUCKET_PROCESS_SUFFIX
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 === TOKEN_BUCKET_PROCESS_SUFFIX) {
984
- return await jobConfig.process(job.data);
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
- function createWorker(queueName, connection, concurrency, processJob2) {
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