ravenflow 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -0
- package/dist/index.d.mts +475 -19
- package/dist/index.d.ts +475 -19
- package/dist/index.js +1664 -150
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1628 -153
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -2
package/dist/index.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
|
|
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
|
|
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
|
|
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/
|
|
789
|
-
function
|
|
790
|
-
|
|
791
|
-
const
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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/
|
|
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
|
|
875
|
-
const
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
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 ===
|
|
978
|
-
return await jobConfig.
|
|
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
|
-
|
|
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
|