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