bulltrackers-module 1.0.296 → 1.0.297
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/functions/computation-system/executors/StandardExecutor.js +44 -86
- package/functions/computation-system/helpers/computation_worker.js +40 -16
- package/functions/computation-system/persistence/ResultCommitter.js +77 -183
- package/functions/computation-system/persistence/RunRecorder.js +20 -10
- package/functions/generic-api/admin-api/index.js +151 -4
- package/package.json +1 -1
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Executor for "Standard" (per-user) calculations.
|
|
3
|
-
* UPDATED:
|
|
4
|
-
* UPDATED: Implements "Circuit Breaker" to fail fast on high error rates.
|
|
5
|
-
* UPDATED: Implements "Adaptive Flushing" based on V8 Heap usage.
|
|
6
|
-
* UPDATED: Manages incremental sharding states.
|
|
7
|
-
* UPDATED: Implements 'isInitialWrite' flag for robust cleanup.
|
|
3
|
+
* UPDATED: Tracks IO Operations (Reads/Writes) for Cost Analysis.
|
|
8
4
|
*/
|
|
9
5
|
const { normalizeName } = require('../utils/utils');
|
|
10
6
|
const { streamPortfolioData, streamHistoryData, getPortfolioPartRefs } = require('../utils/data_loader');
|
|
@@ -20,7 +16,7 @@ class StandardExecutor {
|
|
|
20
16
|
const dStr = date.toISOString().slice(0, 10);
|
|
21
17
|
const logger = deps.logger;
|
|
22
18
|
|
|
23
|
-
// 1. Prepare Yesterdays Data
|
|
19
|
+
// 1. Prepare Yesterdays Data (Counts as Read Ops)
|
|
24
20
|
const fullRoot = { ...rootData };
|
|
25
21
|
if (calcs.some(c => c.isHistorical)) {
|
|
26
22
|
const prev = new Date(date); prev.setUTCDate(prev.getUTCDate() - 1);
|
|
@@ -28,22 +24,17 @@ class StandardExecutor {
|
|
|
28
24
|
fullRoot.yesterdayPortfolioRefs = await getPortfolioPartRefs(config, deps, prevStr);
|
|
29
25
|
}
|
|
30
26
|
|
|
31
|
-
// 2. Initialize Instances
|
|
32
27
|
const state = {};
|
|
33
28
|
for (const c of calcs) {
|
|
34
29
|
try {
|
|
35
30
|
const inst = new c.class();
|
|
36
31
|
inst.manifest = c;
|
|
37
|
-
// Ensure internal storage exists for flushing
|
|
38
32
|
inst.results = {};
|
|
39
33
|
state[normalizeName(c.name)] = inst;
|
|
40
34
|
logger.log('INFO', `${c.name} calculation running for ${dStr}`);
|
|
41
|
-
} catch (e) {
|
|
42
|
-
logger.log('WARN', `Failed to init ${c.name}`);
|
|
43
|
-
}
|
|
35
|
+
} catch (e) { logger.log('WARN', `Failed to init ${c.name}`); }
|
|
44
36
|
}
|
|
45
37
|
|
|
46
|
-
// 3. Stream, Process & Batch Flush
|
|
47
38
|
return await StandardExecutor.streamAndProcess(dStr, state, passName, config, deps, fullRoot, rootData.portfolioRefs, rootData.historyRefs, fetchedDeps, previousFetchedDeps, skipStatusWrite);
|
|
48
39
|
}
|
|
49
40
|
|
|
@@ -54,33 +45,34 @@ class StandardExecutor {
|
|
|
54
45
|
|
|
55
46
|
if (streamingCalcs.length === 0) return { successUpdates: {}, failureReport: [] };
|
|
56
47
|
|
|
57
|
-
|
|
58
|
-
|
|
48
|
+
// [NEW] Calculate Total Read Ops for this execution context
|
|
49
|
+
// Each reference in the arrays corresponds to a document fetch
|
|
50
|
+
let totalReadOps = (portfolioRefs?.length || 0) + (historyRefs?.length || 0);
|
|
51
|
+
if (rootData.yesterdayPortfolioRefs) totalReadOps += rootData.yesterdayPortfolioRefs.length;
|
|
52
|
+
// Add +2 for Insights & Social (1 doc each)
|
|
53
|
+
totalReadOps += 2;
|
|
54
|
+
|
|
55
|
+
// Distribute read costs evenly among calculations (approximation)
|
|
56
|
+
const readOpsPerCalc = Math.ceil(totalReadOps / streamingCalcs.length);
|
|
57
|
+
|
|
59
58
|
const executionStats = {};
|
|
60
59
|
const shardIndexMap = {};
|
|
61
60
|
const aggregatedSuccess = {};
|
|
62
61
|
const aggregatedFailures = [];
|
|
63
|
-
|
|
64
|
-
// [NEW] Global Error Tracking for Circuit Breaker
|
|
65
62
|
const errorStats = { count: 0, total: 0 };
|
|
66
63
|
|
|
67
64
|
Object.keys(state).forEach(name => {
|
|
68
65
|
executionStats[name] = {
|
|
69
|
-
processedUsers: 0,
|
|
70
|
-
skippedUsers: 0,
|
|
71
|
-
timings: { setup: 0, stream: 0, processing: 0 }
|
|
66
|
+
processedUsers: 0, skippedUsers: 0, timings: { setup: 0, stream: 0, processing: 0 }
|
|
72
67
|
};
|
|
73
|
-
shardIndexMap[name]
|
|
68
|
+
shardIndexMap[name] = 0;
|
|
74
69
|
});
|
|
75
70
|
|
|
76
|
-
// Track if we have performed a flush yet (for cleanup logic)
|
|
77
71
|
let hasFlushed = false;
|
|
78
|
-
|
|
79
|
-
const startSetup = performance.now();
|
|
80
72
|
const cachedLoader = new CachedDataLoader(config, deps);
|
|
73
|
+
const startSetup = performance.now();
|
|
81
74
|
await cachedLoader.loadMappings();
|
|
82
75
|
const setupDuration = performance.now() - startSetup;
|
|
83
|
-
|
|
84
76
|
Object.keys(executionStats).forEach(name => executionStats[name].timings.setup += setupDuration);
|
|
85
77
|
|
|
86
78
|
const prevDate = new Date(dateStr + 'T00:00:00Z'); prevDate.setUTCDate(prevDate.getUTCDate() - 1);
|
|
@@ -93,8 +85,6 @@ class StandardExecutor {
|
|
|
93
85
|
const tH_iter = (needsTradingHistory) ? streamHistoryData(config, deps, dateStr, historyRefs) : null;
|
|
94
86
|
|
|
95
87
|
let yP_chunk = {}, tH_chunk = {};
|
|
96
|
-
|
|
97
|
-
const MIN_BATCH_SIZE = 1000; // Minimum to process before checking stats
|
|
98
88
|
let usersSinceLastFlush = 0;
|
|
99
89
|
|
|
100
90
|
try {
|
|
@@ -106,52 +96,26 @@ class StandardExecutor {
|
|
|
106
96
|
Object.keys(executionStats).forEach(name => executionStats[name].timings.stream += streamDuration);
|
|
107
97
|
|
|
108
98
|
const chunkSize = Object.keys(tP_chunk).length;
|
|
109
|
-
|
|
110
99
|
const startProcessing = performance.now();
|
|
111
100
|
|
|
112
|
-
|
|
113
|
-
const promises = streamingCalcs.map(calc =>
|
|
101
|
+
const batchResults = await Promise.all(streamingCalcs.map(calc =>
|
|
114
102
|
StandardExecutor.executePerUser(
|
|
115
103
|
calc, calc.manifest, dateStr, tP_chunk, yP_chunk, tH_chunk,
|
|
116
104
|
fetchedDeps, previousFetchedDeps, config, deps, cachedLoader,
|
|
117
105
|
executionStats[normalizeName(calc.manifest.name)]
|
|
118
106
|
)
|
|
119
|
-
);
|
|
107
|
+
));
|
|
120
108
|
|
|
121
|
-
const batchResults = await Promise.all(promises);
|
|
122
109
|
const procDuration = performance.now() - startProcessing;
|
|
123
|
-
|
|
124
110
|
Object.keys(executionStats).forEach(name => executionStats[name].timings.processing += procDuration);
|
|
125
111
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
errorStats.total += (r.success + r.failures);
|
|
129
|
-
errorStats.count += r.failures;
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// [NEW] Circuit Breaker: Fail fast if error rate > 10% after processing 100+ items
|
|
133
|
-
// We check total > 100 to avoid failing on the very first user if they happen to be bad.
|
|
134
|
-
if (errorStats.total > 100 && (errorStats.count / errorStats.total) > 0.10) {
|
|
135
|
-
const failRate = (errorStats.count / errorStats.total * 100).toFixed(1);
|
|
136
|
-
throw new Error(`[Circuit Breaker] High failure rate detected (${failRate}%). Aborting batch to prevent silent data loss.`);
|
|
137
|
-
}
|
|
112
|
+
batchResults.forEach(r => { errorStats.total += (r.success + r.failures); errorStats.count += r.failures; });
|
|
113
|
+
if (errorStats.total > 100 && (errorStats.count / errorStats.total) > 0.10) { throw new Error(`[Circuit Breaker] High failure rate detected.`); }
|
|
138
114
|
|
|
139
115
|
usersSinceLastFlush += chunkSize;
|
|
140
|
-
|
|
141
|
-
// [NEW] Adaptive Flushing (Memory Pressure Check)
|
|
142
116
|
const heapStats = v8.getHeapStatistics();
|
|
143
|
-
|
|
144
|
-
const MEMORY_THRESHOLD = 0.70; // 70% of available RAM
|
|
145
|
-
const COUNT_THRESHOLD = 5000;
|
|
146
|
-
|
|
147
|
-
if (usersSinceLastFlush >= COUNT_THRESHOLD || heapUsedRatio > MEMORY_THRESHOLD) {
|
|
148
|
-
const reason = heapUsedRatio > MEMORY_THRESHOLD ? `MEMORY_PRESSURE (${(heapUsedRatio*100).toFixed(0)}%)` : 'BATCH_LIMIT';
|
|
149
|
-
|
|
150
|
-
logger.log('INFO', `[${passName}] 🛁 Flushing buffer after ${usersSinceLastFlush} users. Reason: ${reason}`);
|
|
151
|
-
|
|
152
|
-
// [UPDATED] Pass isInitialWrite: true only on the first flush
|
|
117
|
+
if (usersSinceLastFlush >= 5000 || (heapStats.used_heap_size / heapStats.heap_size_limit) > 0.70) {
|
|
153
118
|
const flushResult = await StandardExecutor.flushBuffer(state, dateStr, passName, config, deps, shardIndexMap, executionStats, 'INTERMEDIATE', true, !hasFlushed);
|
|
154
|
-
|
|
155
119
|
hasFlushed = true;
|
|
156
120
|
StandardExecutor.mergeReports(aggregatedSuccess, aggregatedFailures, flushResult);
|
|
157
121
|
usersSinceLastFlush = 0;
|
|
@@ -161,22 +125,23 @@ class StandardExecutor {
|
|
|
161
125
|
if (yP_iter && yP_iter.return) await yP_iter.return();
|
|
162
126
|
if (tH_iter && tH_iter.return) await tH_iter.return();
|
|
163
127
|
}
|
|
164
|
-
|
|
165
|
-
logger.log('INFO', `[${passName}] Streaming complete. Performing final commit.`);
|
|
166
|
-
// [UPDATED] If we never flushed in the loop, this is the initial write
|
|
128
|
+
|
|
167
129
|
const finalResult = await StandardExecutor.flushBuffer(state, dateStr, passName, config, deps, shardIndexMap, executionStats, 'FINAL', skipStatusWrite, !hasFlushed);
|
|
168
|
-
|
|
169
130
|
StandardExecutor.mergeReports(aggregatedSuccess, aggregatedFailures, finalResult);
|
|
170
131
|
|
|
132
|
+
// [NEW] Inject Read Ops into the final report
|
|
133
|
+
Object.values(aggregatedSuccess).forEach(update => {
|
|
134
|
+
if (!update.metrics.io) update.metrics.io = { reads: 0, writes: 0, deletes: 0 };
|
|
135
|
+
update.metrics.io.reads = readOpsPerCalc;
|
|
136
|
+
});
|
|
137
|
+
|
|
171
138
|
return { successUpdates: aggregatedSuccess, failureReport: aggregatedFailures };
|
|
172
139
|
}
|
|
173
140
|
|
|
174
141
|
static async flushBuffer(state, dateStr, passName, config, deps, shardIndexMap, executionStats, mode, skipStatusWrite, isInitialWrite = false) {
|
|
175
142
|
const transformedState = {};
|
|
176
|
-
|
|
177
143
|
for (const [name, inst] of Object.entries(state)) {
|
|
178
144
|
const rawResult = inst.results || {};
|
|
179
|
-
|
|
180
145
|
const firstUser = Object.keys(rawResult)[0];
|
|
181
146
|
let dataToCommit = rawResult;
|
|
182
147
|
|
|
@@ -199,43 +164,40 @@ class StandardExecutor {
|
|
|
199
164
|
getResult: async () => dataToCommit,
|
|
200
165
|
_executionStats: executionStats[name]
|
|
201
166
|
};
|
|
202
|
-
|
|
203
|
-
// Clear the memory immediately after preparing the commit
|
|
204
167
|
inst.results = {};
|
|
205
168
|
}
|
|
206
169
|
|
|
207
|
-
// [UPDATED] Pass isInitialWrite to ResultCommitter
|
|
208
170
|
const result = await commitResults(transformedState, dateStr, passName, config, deps, skipStatusWrite, {
|
|
209
|
-
flushMode: mode,
|
|
210
|
-
shardIndexes: shardIndexMap,
|
|
211
|
-
isInitialWrite: isInitialWrite
|
|
171
|
+
flushMode: mode, shardIndexes: shardIndexMap, isInitialWrite: isInitialWrite
|
|
212
172
|
});
|
|
213
173
|
|
|
214
|
-
if (result.shardIndexes)
|
|
215
|
-
Object.assign(shardIndexMap, result.shardIndexes);
|
|
216
|
-
}
|
|
217
|
-
|
|
174
|
+
if (result.shardIndexes) Object.assign(shardIndexMap, result.shardIndexes);
|
|
218
175
|
return result;
|
|
219
176
|
}
|
|
220
177
|
|
|
221
178
|
static mergeReports(successAcc, failureAcc, newResult) {
|
|
222
179
|
if (!newResult) return;
|
|
223
|
-
|
|
224
180
|
for (const [name, update] of Object.entries(newResult.successUpdates)) {
|
|
225
181
|
if (!successAcc[name]) {
|
|
226
182
|
successAcc[name] = update;
|
|
227
183
|
} else {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
184
|
+
// Merge Storage metrics
|
|
185
|
+
successAcc[name].metrics.storage.sizeBytes += (update.metrics.storage.sizeBytes || 0);
|
|
186
|
+
successAcc[name].metrics.storage.keys += (update.metrics.storage.keys || 0);
|
|
187
|
+
successAcc[name].metrics.storage.shardCount = Math.max(successAcc[name].metrics.storage.shardCount, update.metrics.storage.shardCount || 1);
|
|
233
188
|
|
|
189
|
+
// [NEW] Merge IO Metrics
|
|
190
|
+
if (update.metrics.io) {
|
|
191
|
+
if (!successAcc[name].metrics.io) successAcc[name].metrics.io = { writes: 0, deletes: 0, reads: 0 };
|
|
192
|
+
successAcc[name].metrics.io.writes += (update.metrics.io.writes || 0);
|
|
193
|
+
successAcc[name].metrics.io.deletes += (update.metrics.io.deletes || 0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Merge timings
|
|
234
197
|
if (update.metrics?.execution?.timings) {
|
|
235
198
|
if (!successAcc[name].metrics.execution) successAcc[name].metrics.execution = { timings: { setup:0, stream:0, processing:0 }};
|
|
236
199
|
const tDest = successAcc[name].metrics.execution.timings;
|
|
237
200
|
const tSrc = update.metrics.execution.timings;
|
|
238
|
-
|
|
239
201
|
tDest.setup += (tSrc.setup || 0);
|
|
240
202
|
tDest.stream += (tSrc.stream || 0);
|
|
241
203
|
tDest.processing += (tSrc.processing || 0);
|
|
@@ -243,12 +205,9 @@ class StandardExecutor {
|
|
|
243
205
|
successAcc[name].hash = update.hash;
|
|
244
206
|
}
|
|
245
207
|
}
|
|
246
|
-
|
|
247
|
-
if (newResult.failureReport) {
|
|
248
|
-
failureAcc.push(...newResult.failureReport);
|
|
249
|
-
}
|
|
208
|
+
if (newResult.failureReport) failureAcc.push(...newResult.failureReport);
|
|
250
209
|
}
|
|
251
|
-
|
|
210
|
+
|
|
252
211
|
static async executePerUser(calcInstance, metadata, dateStr, portfolioData, yesterdayPortfolioData, historyData, computedDeps, prevDeps, config, deps, loader, stats) {
|
|
253
212
|
const { logger } = deps;
|
|
254
213
|
const targetUserType = metadata.userType;
|
|
@@ -256,7 +215,6 @@ class StandardExecutor {
|
|
|
256
215
|
const insights = metadata.rootDataDependencies?.includes('insights') ? { today: await loader.loadInsights(dateStr) } : null;
|
|
257
216
|
const SCHEMAS = mathLayer.SCHEMAS;
|
|
258
217
|
|
|
259
|
-
// [NEW] Track local batch success/failure
|
|
260
218
|
let chunkSuccess = 0;
|
|
261
219
|
let chunkFailures = 0;
|
|
262
220
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* FILENAME: computation-system/helpers/computation_worker.js
|
|
3
3
|
* PURPOSE: Consumes tasks, executes logic, and signals Workflow upon Batch Completion.
|
|
4
4
|
* UPDATED: Implements IAM Auth for Workflow Callbacks.
|
|
5
|
-
* UPDATED: Implements Memory Heartbeat
|
|
5
|
+
* UPDATED: Implements Peak Memory Heartbeat and Resource Tier tracking.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const { executeDispatchTask } = require('../WorkflowOrchestrator.js');
|
|
@@ -11,6 +11,7 @@ const { StructuredLogger } = require('../logger/logger');
|
|
|
11
11
|
const { recordRunAttempt } = require('../persistence/RunRecorder');
|
|
12
12
|
const https = require('https');
|
|
13
13
|
const { GoogleAuth } = require('google-auth-library');
|
|
14
|
+
const { normalizeName } = require('../utils/utils');
|
|
14
15
|
|
|
15
16
|
let calculationPackage;
|
|
16
17
|
try { calculationPackage = require('aiden-shared-calculations-unified');
|
|
@@ -20,15 +21,19 @@ const calculations = calculationPackage.calculations;
|
|
|
20
21
|
const MAX_RETRIES = 3;
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
|
-
* [
|
|
24
|
-
* This acts as a "Black Box Recorder".
|
|
25
|
-
* the last written value will remain in Firestore for the Dispatcher to analyze.
|
|
24
|
+
* [UPDATED] Heartbeat now returns a closure to get the PEAK memory.
|
|
25
|
+
* This acts as a "Black Box Recorder".
|
|
26
26
|
*/
|
|
27
27
|
function startMemoryHeartbeat(db, ledgerPath, intervalMs = 2000) {
|
|
28
|
+
let peakRss = 0;
|
|
29
|
+
|
|
28
30
|
const getMemStats = () => {
|
|
29
31
|
const mem = process.memoryUsage();
|
|
32
|
+
const rssMB = Math.round(mem.rss / 1024 / 1024);
|
|
33
|
+
if (rssMB > peakRss) peakRss = rssMB;
|
|
34
|
+
|
|
30
35
|
return {
|
|
31
|
-
rssMB:
|
|
36
|
+
rssMB: rssMB,
|
|
32
37
|
heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024),
|
|
33
38
|
timestamp: new Date()
|
|
34
39
|
};
|
|
@@ -50,7 +55,10 @@ function startMemoryHeartbeat(db, ledgerPath, intervalMs = 2000) {
|
|
|
50
55
|
// Unref so this timer doesn't prevent the process from exiting naturally
|
|
51
56
|
timer.unref();
|
|
52
57
|
|
|
53
|
-
return
|
|
58
|
+
return {
|
|
59
|
+
timer,
|
|
60
|
+
getPeak: () => peakRss
|
|
61
|
+
};
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/**
|
|
@@ -127,7 +135,9 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
127
135
|
|
|
128
136
|
if (!data || data.action !== 'RUN_COMPUTATION_DATE') { return; }
|
|
129
137
|
|
|
130
|
-
|
|
138
|
+
// [UPDATED] Extract 'resources' from payload (set by Dispatcher)
|
|
139
|
+
const { date, pass, computation, previousCategory, triggerReason, dispatchId, dependencyResultHashes, metaStatePath, resources } = data;
|
|
140
|
+
const resourceTier = resources || 'standard'; // Default to standard
|
|
131
141
|
|
|
132
142
|
if (!date || !pass || !computation) { logger.log('ERROR', `[Worker] Invalid payload.`, data); return; }
|
|
133
143
|
|
|
@@ -158,7 +168,7 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
158
168
|
} catch (dlqErr) { logger.log('FATAL', `[Worker] Failed to write to DLQ`, dlqErr); }
|
|
159
169
|
}
|
|
160
170
|
|
|
161
|
-
logger.log('INFO', `[Worker] 📥 Received Task: ${computation} (${date}) [Attempt ${retryCount}/${MAX_RETRIES}]`);
|
|
171
|
+
logger.log('INFO', `[Worker] 📥 Received Task: ${computation} (${date}) [Attempt ${retryCount}/${MAX_RETRIES}] [Tier: ${resourceTier}]`);
|
|
162
172
|
|
|
163
173
|
// 1. Update Status to IN_PROGRESS & Initialize Telemetry
|
|
164
174
|
try {
|
|
@@ -172,12 +182,13 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
172
182
|
} catch (leaseErr) {}
|
|
173
183
|
|
|
174
184
|
// 2. START HEARTBEAT (The Flight Recorder)
|
|
175
|
-
|
|
185
|
+
// [UPDATED] Using new logic to track peak
|
|
186
|
+
const heartbeatControl = startMemoryHeartbeat(db, ledgerPath, 2000);
|
|
176
187
|
|
|
177
188
|
let computationManifest;
|
|
178
189
|
try { computationManifest = getManifest(config.activeProductLines || [], calculations, runDependencies);
|
|
179
190
|
} catch (manifestError) {
|
|
180
|
-
clearInterval(
|
|
191
|
+
clearInterval(heartbeatControl.timer); // Stop if we fail early
|
|
181
192
|
logger.log('FATAL', `[Worker] Failed to load Manifest: ${manifestError.message}`);
|
|
182
193
|
return;
|
|
183
194
|
}
|
|
@@ -191,7 +202,7 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
191
202
|
const duration = Date.now() - startTime;
|
|
192
203
|
|
|
193
204
|
// STOP HEARTBEAT ON SUCCESS
|
|
194
|
-
clearInterval(
|
|
205
|
+
clearInterval(heartbeatControl.timer);
|
|
195
206
|
|
|
196
207
|
const failureReport = result?.updates?.failureReport || [];
|
|
197
208
|
const successUpdates = result?.updates?.successUpdates || {};
|
|
@@ -203,20 +214,33 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
203
214
|
else {
|
|
204
215
|
if (Object.keys(successUpdates).length > 0) { logger.log('INFO', `[Worker] ✅ Stored: ${computation}`); }
|
|
205
216
|
else { logger.log('WARN', `[Worker] ⚠️ Empty Result: ${computation}`); }
|
|
217
|
+
|
|
218
|
+
// Extract the metrics from the success update for the recorder
|
|
219
|
+
const calcUpdate = successUpdates[normalizeName(computation)] || {};
|
|
220
|
+
const finalMetrics = {
|
|
221
|
+
durationMs: duration,
|
|
222
|
+
peakMemoryMB: heartbeatControl.getPeak(),
|
|
223
|
+
io: calcUpdate.metrics?.io,
|
|
224
|
+
storage: calcUpdate.metrics?.storage,
|
|
225
|
+
execution: calcUpdate.metrics?.execution,
|
|
226
|
+
validation: calcUpdate.metrics?.validation,
|
|
227
|
+
composition: calcUpdate.composition
|
|
228
|
+
};
|
|
206
229
|
|
|
207
230
|
await db.doc(ledgerPath).update({
|
|
208
231
|
status: 'COMPLETED',
|
|
209
232
|
completedAt: new Date()
|
|
210
233
|
}).catch(() => {});
|
|
211
234
|
|
|
212
|
-
|
|
235
|
+
// [UPDATED] Pass resourceTier and metrics to recordRunAttempt
|
|
236
|
+
await recordRunAttempt(db, { date, computation, pass }, 'SUCCESS', null, finalMetrics, triggerReason, resourceTier);
|
|
213
237
|
|
|
214
238
|
const callbackUrl = await decrementAndCheck(db, metaStatePath, logger);
|
|
215
239
|
if (callbackUrl) { await triggerWorkflowCallback(callbackUrl, 'SUCCESS', logger); }
|
|
216
240
|
}
|
|
217
241
|
} catch (err) {
|
|
218
242
|
// STOP HEARTBEAT ON ERROR
|
|
219
|
-
clearInterval(
|
|
243
|
+
clearInterval(heartbeatControl.timer);
|
|
220
244
|
|
|
221
245
|
// --- ERROR HANDLING ---
|
|
222
246
|
const isDeterministicError = err.stage === 'SHARDING_LIMIT_EXCEEDED' ||
|
|
@@ -241,7 +265,7 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
241
265
|
failedAt: new Date()
|
|
242
266
|
}, { merge: true });
|
|
243
267
|
|
|
244
|
-
await recordRunAttempt(db, { date, computation, pass }, 'FAILURE', { message: err.message, stage: err.stage || 'PERMANENT_FAIL' }, { durationMs: 0 }, triggerReason);
|
|
268
|
+
await recordRunAttempt(db, { date, computation, pass }, 'FAILURE', { message: err.message, stage: err.stage || 'PERMANENT_FAIL' }, { durationMs: 0, peakMemoryMB: heartbeatControl.getPeak() }, triggerReason, resourceTier);
|
|
245
269
|
|
|
246
270
|
const callbackUrl = await decrementAndCheck(db, metaStatePath, logger);
|
|
247
271
|
if (callbackUrl) { await triggerWorkflowCallback(callbackUrl, 'SUCCESS', logger); }
|
|
@@ -252,9 +276,9 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
252
276
|
if (retryCount >= MAX_RETRIES) { throw err; }
|
|
253
277
|
|
|
254
278
|
logger.log('ERROR', `[Worker] ❌ Crash: ${computation}: ${err.message}`);
|
|
255
|
-
await recordRunAttempt(db, { date, computation, pass }, 'CRASH', { message: err.message, stack: err.stack, stage: 'SYSTEM_CRASH' }, { durationMs: 0 }, triggerReason);
|
|
279
|
+
await recordRunAttempt(db, { date, computation, pass }, 'CRASH', { message: err.message, stack: err.stack, stage: 'SYSTEM_CRASH' }, { durationMs: 0, peakMemoryMB: heartbeatControl.getPeak() }, triggerReason, resourceTier);
|
|
256
280
|
throw err;
|
|
257
281
|
}
|
|
258
282
|
}
|
|
259
283
|
|
|
260
|
-
module.exports = { handleComputationTask };
|
|
284
|
+
module.exports = { handleComputationTask };
|
|
@@ -1,32 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Handles saving computation results with observability and Smart Cleanup.
|
|
3
|
-
* UPDATED:
|
|
4
|
-
* UPDATED: Implements Content-Based Hashing (ResultHash) for dependency short-circuiting.
|
|
5
|
-
* UPDATED: Auto-enforces Weekend Mode validation.
|
|
6
|
-
* UPDATED: Implements "Initial Write" logic to wipe stale data/shards on a fresh run.
|
|
7
|
-
* UPDATED: Implements "Contract Validation" (Semantic Gates) to block logical violations.
|
|
8
|
-
* OPTIMIZED: Fetches pre-calculated 'simHash' from Registry (removes expensive simulation step).
|
|
3
|
+
* UPDATED: Tracks specific Firestore Ops (Writes/Deletes) for cost analysis.
|
|
9
4
|
*/
|
|
10
5
|
const { commitBatchInChunks, generateDataHash } = require('../utils/utils');
|
|
11
6
|
const { updateComputationStatus } = require('./StatusRepository');
|
|
12
7
|
const { batchStoreSchemas } = require('../utils/schema_capture');
|
|
13
8
|
const { generateProcessId, PROCESS_TYPES } = require('../logger/logger');
|
|
14
9
|
const { HeuristicValidator } = require('./ResultsValidator');
|
|
15
|
-
const ContractValidator = require('./ContractValidator');
|
|
10
|
+
const ContractValidator = require('./ContractValidator');
|
|
16
11
|
const validationOverrides = require('../config/validation_overrides');
|
|
17
12
|
const pLimit = require('p-limit');
|
|
18
13
|
const zlib = require('zlib');
|
|
19
14
|
|
|
20
|
-
const NON_RETRYABLE_ERRORS = [
|
|
21
|
-
'PERMISSION_DENIED', 'DATA_LOSS', 'FAILED_PRECONDITION'
|
|
22
|
-
];
|
|
23
|
-
|
|
15
|
+
const NON_RETRYABLE_ERRORS = [ 'PERMISSION_DENIED', 'DATA_LOSS', 'FAILED_PRECONDITION' ];
|
|
24
16
|
const SIMHASH_REGISTRY_COLLECTION = 'system_simhash_registry';
|
|
25
|
-
const CONTRACTS_COLLECTION = 'system_contracts';
|
|
17
|
+
const CONTRACTS_COLLECTION = 'system_contracts';
|
|
26
18
|
|
|
27
|
-
/**
|
|
28
|
-
* Commits results to Firestore.
|
|
29
|
-
*/
|
|
30
19
|
async function commitResults(stateObj, dStr, passName, config, deps, skipStatusWrite = false, options = {}) {
|
|
31
20
|
const successUpdates = {};
|
|
32
21
|
const failureReport = [];
|
|
@@ -35,17 +24,12 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
35
24
|
const { logger, db } = deps;
|
|
36
25
|
const pid = generateProcessId(PROCESS_TYPES.STORAGE, passName, dStr);
|
|
37
26
|
|
|
38
|
-
// Options defaults
|
|
39
27
|
const flushMode = options.flushMode || 'STANDARD';
|
|
40
28
|
const isInitialWrite = options.isInitialWrite === true;
|
|
41
29
|
const shardIndexes = options.shardIndexes || {};
|
|
42
30
|
const nextShardIndexes = {};
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
// [NEW] Bulk fetch contracts for all calcs in this batch to minimize latency
|
|
47
|
-
// This prevents N+1 reads during the loop
|
|
48
|
-
const contractMap = await fetchContracts(db, Object.keys(stateObj));
|
|
31
|
+
const fanOutLimit = pLimit(10);
|
|
32
|
+
const contractMap = await fetchContracts(db, Object.keys(stateObj));
|
|
49
33
|
|
|
50
34
|
for (const name in stateObj) {
|
|
51
35
|
const calc = stateObj[name];
|
|
@@ -55,43 +39,34 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
55
39
|
const runMetrics = {
|
|
56
40
|
storage: { sizeBytes: 0, isSharded: false, shardCount: 1, keys: 0 },
|
|
57
41
|
validation: { isValid: true, anomalies: [] },
|
|
58
|
-
execution: execStats
|
|
42
|
+
execution: execStats,
|
|
43
|
+
// [NEW] Track Ops
|
|
44
|
+
io: { writes: 0, deletes: 0 }
|
|
59
45
|
};
|
|
60
46
|
|
|
61
47
|
try {
|
|
62
48
|
const result = await calc.getResult();
|
|
63
|
-
const configOverrides = validationOverrides[calc.manifest.name] || {};
|
|
64
49
|
|
|
50
|
+
const configOverrides = validationOverrides[calc.manifest.name] || {};
|
|
65
51
|
const dataDeps = calc.manifest.rootDataDependencies || [];
|
|
66
52
|
const isPriceOnly = (dataDeps.length === 1 && dataDeps[0] === 'price');
|
|
67
|
-
|
|
68
53
|
let effectiveOverrides = { ...configOverrides };
|
|
69
|
-
|
|
70
54
|
if (isPriceOnly && !effectiveOverrides.weekend) {
|
|
71
|
-
effectiveOverrides.weekend = {
|
|
72
|
-
maxZeroPct: 100,
|
|
73
|
-
maxFlatlinePct: 100,
|
|
74
|
-
maxNullPct: 100
|
|
75
|
-
};
|
|
55
|
+
effectiveOverrides.weekend = { maxZeroPct: 100, maxFlatlinePct: 100, maxNullPct: 100 };
|
|
76
56
|
}
|
|
77
57
|
|
|
78
|
-
// 1. SEMANTIC GATE (CONTRACT VALIDATION) [NEW]
|
|
79
|
-
// We run this BEFORE Heuristics because it catches "Logic Bugs" vs "Data Noise"
|
|
80
58
|
const contract = contractMap[name];
|
|
81
59
|
if (contract) {
|
|
82
60
|
const contractCheck = ContractValidator.validate(result, contract);
|
|
83
61
|
if (!contractCheck.valid) {
|
|
84
|
-
// STOP THE CASCADE: Fail this specific calculation
|
|
85
62
|
runMetrics.validation.isValid = false;
|
|
86
63
|
runMetrics.validation.anomalies.push(contractCheck.reason);
|
|
87
|
-
|
|
88
64
|
const semanticError = new Error(contractCheck.reason);
|
|
89
65
|
semanticError.stage = 'SEMANTIC_GATE';
|
|
90
66
|
throw semanticError;
|
|
91
67
|
}
|
|
92
68
|
}
|
|
93
69
|
|
|
94
|
-
// 2. HEURISTIC VALIDATION (Data Integrity)
|
|
95
70
|
if (result && Object.keys(result).length > 0) {
|
|
96
71
|
const healthCheck = HeuristicValidator.analyze(calc.manifest.name, result, dStr, effectiveOverrides);
|
|
97
72
|
if (!healthCheck.valid) {
|
|
@@ -102,38 +77,25 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
102
77
|
throw validationError;
|
|
103
78
|
}
|
|
104
79
|
}
|
|
105
|
-
|
|
80
|
+
|
|
106
81
|
const isEmpty = !result || (typeof result === 'object' && Object.keys(result).length === 0);
|
|
107
82
|
const resultHash = isEmpty ? 'empty' : generateDataHash(result);
|
|
108
|
-
|
|
109
|
-
// [OPTIMIZATION] FETCH SimHash from Registry (Do NOT Calculate)
|
|
83
|
+
|
|
110
84
|
let simHash = null;
|
|
111
85
|
if (calc.manifest.hash && flushMode !== 'INTERMEDIATE') {
|
|
112
|
-
|
|
86
|
+
try {
|
|
113
87
|
const regDoc = await db.collection(SIMHASH_REGISTRY_COLLECTION).doc(calc.manifest.hash).get();
|
|
114
|
-
if (regDoc.exists)
|
|
115
|
-
|
|
116
|
-
} else {
|
|
117
|
-
logger.log('WARN', `[ResultCommitter] SimHash not found in registry for ${name}.`);
|
|
118
|
-
}
|
|
119
|
-
} catch (regErr) {
|
|
120
|
-
logger.log('WARN', `[ResultCommitter] Failed to read SimHash registry: ${regErr.message}`);
|
|
121
|
-
}
|
|
88
|
+
if (regDoc.exists) simHash = regDoc.data().simHash;
|
|
89
|
+
} catch (e) {}
|
|
122
90
|
}
|
|
123
91
|
|
|
124
92
|
if (isEmpty) {
|
|
125
|
-
if (flushMode === 'INTERMEDIATE') {
|
|
126
|
-
nextShardIndexes[name] = currentShardIndex;
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
93
|
+
if (flushMode === 'INTERMEDIATE') { nextShardIndexes[name] = currentShardIndex; continue; }
|
|
129
94
|
if (calc.manifest.hash) {
|
|
130
95
|
successUpdates[name] = {
|
|
131
|
-
hash:
|
|
132
|
-
simHash: simHash,
|
|
133
|
-
resultHash: resultHash,
|
|
96
|
+
hash: calc.manifest.hash, simHash: simHash, resultHash: resultHash,
|
|
134
97
|
dependencyResultHashes: calc.manifest.dependencyResultHashes || {},
|
|
135
|
-
category:
|
|
136
|
-
composition: calc.manifest.composition,
|
|
98
|
+
category: calc.manifest.category, composition: calc.manifest.composition,
|
|
137
99
|
metrics: runMetrics
|
|
138
100
|
};
|
|
139
101
|
}
|
|
@@ -141,7 +103,6 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
141
103
|
}
|
|
142
104
|
|
|
143
105
|
if (typeof result === 'object') runMetrics.storage.keys = Object.keys(result).length;
|
|
144
|
-
|
|
145
106
|
const resultKeys = Object.keys(result || {});
|
|
146
107
|
const isMultiDate = resultKeys.length > 0 && resultKeys.every(k => /^\d{4}-\d{2}-\d{2}$/.test(k));
|
|
147
108
|
|
|
@@ -149,73 +110,42 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
149
110
|
const datePromises = resultKeys.map((historicalDate) => fanOutLimit(async () => {
|
|
150
111
|
const dailyData = result[historicalDate];
|
|
151
112
|
if (!dailyData || Object.keys(dailyData).length === 0) return;
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
.doc(name);
|
|
159
|
-
|
|
160
|
-
await writeSingleResult(dailyData, historicalDocRef, name, historicalDate, logger, config, deps, 0, 'STANDARD', false);
|
|
113
|
+
const historicalDocRef = db.collection(config.resultsCollection).doc(historicalDate).collection(config.resultsSubcollection).doc(calc.manifest.category).collection(config.computationsSubcollection).doc(name);
|
|
114
|
+
const stats = await writeSingleResult(dailyData, historicalDocRef, name, historicalDate, logger, config, deps, 0, 'STANDARD', false);
|
|
115
|
+
|
|
116
|
+
// Aggregate IO Ops
|
|
117
|
+
runMetrics.io.writes += stats.opCounts.writes;
|
|
118
|
+
runMetrics.io.deletes += stats.opCounts.deletes;
|
|
161
119
|
}));
|
|
162
120
|
await Promise.all(datePromises);
|
|
163
121
|
|
|
164
|
-
if (calc.manifest.hash) {
|
|
165
|
-
successUpdates[name] = {
|
|
166
|
-
hash: calc.manifest.hash,
|
|
167
|
-
simHash: simHash,
|
|
168
|
-
resultHash: resultHash,
|
|
169
|
-
dependencyResultHashes: calc.manifest.dependencyResultHashes || {},
|
|
170
|
-
category: calc.manifest.category,
|
|
171
|
-
composition: calc.manifest.composition,
|
|
172
|
-
metrics: runMetrics
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
122
|
+
if (calc.manifest.hash) { successUpdates[name] = { hash: calc.manifest.hash, simHash, resultHash, dependencyResultHashes: calc.manifest.dependencyResultHashes || {}, category: calc.manifest.category, composition: calc.manifest.composition, metrics: runMetrics }; }
|
|
176
123
|
} else {
|
|
177
|
-
const mainDocRef = db.collection(config.resultsCollection)
|
|
178
|
-
.doc(dStr)
|
|
179
|
-
.collection(config.resultsSubcollection)
|
|
180
|
-
.doc(calc.manifest.category)
|
|
181
|
-
.collection(config.computationsSubcollection)
|
|
182
|
-
.doc(name);
|
|
183
|
-
|
|
124
|
+
const mainDocRef = db.collection(config.resultsCollection).doc(dStr).collection(config.resultsSubcollection).doc(calc.manifest.category).collection(config.computationsSubcollection).doc(name);
|
|
184
125
|
const writeStats = await writeSingleResult(result, mainDocRef, name, dStr, logger, config, deps, currentShardIndex, flushMode, isInitialWrite);
|
|
185
126
|
|
|
186
127
|
runMetrics.storage.sizeBytes = writeStats.totalSize;
|
|
187
128
|
runMetrics.storage.isSharded = writeStats.isSharded;
|
|
188
129
|
runMetrics.storage.shardCount = writeStats.shardCount;
|
|
130
|
+
runMetrics.io.writes += writeStats.opCounts.writes;
|
|
131
|
+
runMetrics.io.deletes += writeStats.opCounts.deletes;
|
|
189
132
|
|
|
190
133
|
nextShardIndexes[name] = writeStats.nextShardIndex;
|
|
191
|
-
|
|
192
|
-
if (calc.manifest.hash) {
|
|
193
|
-
successUpdates[name] = {
|
|
194
|
-
hash: calc.manifest.hash,
|
|
195
|
-
simHash: simHash,
|
|
196
|
-
resultHash: resultHash,
|
|
197
|
-
dependencyResultHashes: calc.manifest.dependencyResultHashes || {},
|
|
198
|
-
category: calc.manifest.category,
|
|
199
|
-
composition: calc.manifest.composition,
|
|
200
|
-
metrics: runMetrics
|
|
201
|
-
};
|
|
202
|
-
}
|
|
134
|
+
if (calc.manifest.hash) { successUpdates[name] = { hash: calc.manifest.hash, simHash, resultHash, dependencyResultHashes: calc.manifest.dependencyResultHashes || {}, category: calc.manifest.category, composition: calc.manifest.composition, metrics: runMetrics }; }
|
|
203
135
|
}
|
|
204
136
|
|
|
205
137
|
if (calc.manifest.class.getSchema && flushMode !== 'INTERMEDIATE') {
|
|
206
138
|
const { class: _cls, ...safeMetadata } = calc.manifest;
|
|
207
139
|
schemas.push({ name, category: calc.manifest.category, schema: calc.manifest.class.getSchema(), metadata: safeMetadata });
|
|
208
140
|
}
|
|
209
|
-
|
|
210
141
|
if (calc.manifest.previousCategory && calc.manifest.previousCategory !== calc.manifest.category && flushMode !== 'INTERMEDIATE') {
|
|
211
142
|
cleanupTasks.push(deleteOldCalculationData(dStr, calc.manifest.previousCategory, name, config, deps));
|
|
212
143
|
}
|
|
213
144
|
|
|
214
145
|
} catch (e) {
|
|
215
146
|
const stage = e.stage || 'EXECUTION';
|
|
216
|
-
const msg = e.message || 'Unknown error';
|
|
217
147
|
if (logger && logger.log) { logger.log('ERROR', `Commit failed for ${name} [${stage}]`, { processId: pid, error: e }); }
|
|
218
|
-
failureReport.push({ name, error: { message:
|
|
148
|
+
failureReport.push({ name, error: { message: e.message, stack: e.stack, stage }, metrics: runMetrics });
|
|
219
149
|
}
|
|
220
150
|
}
|
|
221
151
|
|
|
@@ -228,48 +158,34 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
228
158
|
return { successUpdates, failureReport, shardIndexes: nextShardIndexes };
|
|
229
159
|
}
|
|
230
160
|
|
|
231
|
-
/**
|
|
232
|
-
* [NEW] Helper to fetch contracts for a list of calculations
|
|
233
|
-
*/
|
|
234
161
|
async function fetchContracts(db, calcNames) {
|
|
235
162
|
if (!calcNames || calcNames.length === 0) return {};
|
|
236
163
|
const map = {};
|
|
237
|
-
|
|
238
|
-
// In a high-throughput system, we might cache these in memory (LRU)
|
|
239
|
-
// For now, we fetch from Firestore efficiently.
|
|
240
164
|
const refs = calcNames.map(name => db.collection(CONTRACTS_COLLECTION).doc(name));
|
|
241
|
-
|
|
242
165
|
try {
|
|
243
166
|
const snaps = await db.getAll(...refs);
|
|
244
|
-
snaps.forEach(snap => {
|
|
245
|
-
|
|
246
|
-
map[snap.id] = snap.data();
|
|
247
|
-
}
|
|
248
|
-
});
|
|
249
|
-
} catch (e) {
|
|
250
|
-
console.warn(`[ResultCommitter] Failed to fetch contracts batch: ${e.message}`);
|
|
251
|
-
}
|
|
167
|
+
snaps.forEach(snap => { if (snap.exists) map[snap.id] = snap.data(); });
|
|
168
|
+
} catch (e) {}
|
|
252
169
|
return map;
|
|
253
170
|
}
|
|
254
171
|
|
|
255
172
|
async function writeSingleResult(result, docRef, name, dateContext, logger, config, deps, startShardIndex = 0, flushMode = 'STANDARD', isInitialWrite = false) {
|
|
256
|
-
|
|
257
|
-
// Transition & Cleanup Logic
|
|
173
|
+
const opCounts = { writes: 0, deletes: 0 };
|
|
258
174
|
let wasSharded = false;
|
|
259
175
|
let shouldWipeShards = false;
|
|
260
|
-
|
|
261
|
-
// Default: Merge updates. But if Initial Write, overwrite (merge: false) to clear stale fields.
|
|
262
176
|
let rootMergeOption = !isInitialWrite;
|
|
263
177
|
|
|
264
178
|
if (isInitialWrite) {
|
|
265
179
|
try {
|
|
266
180
|
const currentSnap = await docRef.get();
|
|
181
|
+
// Note: Reads tracked implicitly by calling code or approximated here if needed.
|
|
182
|
+
// We focus on writes/deletes here.
|
|
267
183
|
if (currentSnap.exists) {
|
|
268
184
|
const d = currentSnap.data();
|
|
269
185
|
wasSharded = (d._sharded === true);
|
|
270
186
|
if (wasSharded) shouldWipeShards = true;
|
|
271
187
|
}
|
|
272
|
-
} catch (e) {
|
|
188
|
+
} catch (e) {}
|
|
273
189
|
}
|
|
274
190
|
|
|
275
191
|
// --- COMPRESSION STRATEGY ---
|
|
@@ -279,54 +195,32 @@ async function writeSingleResult(result, docRef, name, dateContext, logger, conf
|
|
|
279
195
|
|
|
280
196
|
if (rawBuffer.length > 50 * 1024) {
|
|
281
197
|
const compressedBuffer = zlib.gzipSync(rawBuffer);
|
|
282
|
-
|
|
283
198
|
if (compressedBuffer.length < 900 * 1024) {
|
|
284
|
-
logger.log('INFO', `[Compression] ${name}: Compressed ${(rawBuffer.length/1024).toFixed(0)}KB -> ${(compressedBuffer.length/1024).toFixed(0)}KB
|
|
199
|
+
logger.log('INFO', `[Compression] ${name}: Compressed ${(rawBuffer.length/1024).toFixed(0)}KB -> ${(compressedBuffer.length/1024).toFixed(0)}KB.`);
|
|
200
|
+
const compressedPayload = { _compressed: true, _completed: true, _lastUpdated: new Date().toISOString(), payload: compressedBuffer };
|
|
285
201
|
|
|
286
|
-
const compressedPayload = {
|
|
287
|
-
_compressed: true,
|
|
288
|
-
_completed: true,
|
|
289
|
-
_lastUpdated: new Date().toISOString(),
|
|
290
|
-
payload: compressedBuffer
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
// Cleanup: If it was sharded, or if we are wiping shards on initial write
|
|
294
202
|
if (shouldWipeShards) {
|
|
295
|
-
logger.log('INFO', `[Cleanup] ${name}: Wiping old shards before Compressed Write.`);
|
|
296
203
|
const updates = [];
|
|
297
204
|
const shardCol = docRef.collection('_shards');
|
|
298
205
|
const shardDocs = await shardCol.listDocuments();
|
|
299
206
|
shardDocs.forEach(d => updates.push({ type: 'DELETE', ref: d }));
|
|
300
|
-
|
|
301
|
-
// Root update with merge: false (overwrites everything)
|
|
302
207
|
updates.push({ ref: docRef, data: compressedPayload, options: { merge: false } });
|
|
303
208
|
|
|
209
|
+
opCounts.deletes += shardDocs.length;
|
|
210
|
+
opCounts.writes += 1;
|
|
211
|
+
|
|
304
212
|
await commitBatchInChunks(config, deps, updates, `${name}::Cleanup+Compress`);
|
|
305
213
|
} else {
|
|
306
|
-
// Standard update (respecting calculated rootMergeOption)
|
|
307
214
|
await docRef.set(compressedPayload, { merge: rootMergeOption });
|
|
215
|
+
opCounts.writes += 1;
|
|
308
216
|
}
|
|
309
217
|
|
|
310
|
-
return {
|
|
311
|
-
totalSize: compressedBuffer.length,
|
|
312
|
-
isSharded: false,
|
|
313
|
-
shardCount: 1,
|
|
314
|
-
nextShardIndex: startShardIndex
|
|
315
|
-
};
|
|
218
|
+
return { totalSize: compressedBuffer.length, isSharded: false, shardCount: 1, nextShardIndex: startShardIndex, opCounts };
|
|
316
219
|
}
|
|
317
220
|
}
|
|
318
|
-
} catch (compErr) {
|
|
319
|
-
logger.log('WARN', `[Compression] Failed to compress ${name}. Falling back to standard sharding.`, compErr);
|
|
320
|
-
}
|
|
321
|
-
// --- END COMPRESSION STRATEGY ---
|
|
322
|
-
|
|
323
|
-
const strategies = [
|
|
324
|
-
{ bytes: 900 * 1024, keys: null },
|
|
325
|
-
{ bytes: 450 * 1024, keys: 10000 },
|
|
326
|
-
{ bytes: 200 * 1024, keys: 2000 },
|
|
327
|
-
{ bytes: 100 * 1024, keys: 50 }
|
|
328
|
-
];
|
|
221
|
+
} catch (compErr) {}
|
|
329
222
|
|
|
223
|
+
const strategies = [ { bytes: 900 * 1024, keys: null }, { bytes: 450 * 1024, keys: 10000 }, { bytes: 200 * 1024, keys: 2000 }, { bytes: 100 * 1024, keys: 50 } ];
|
|
330
224
|
let committed = false; let lastError = null;
|
|
331
225
|
let finalStats = { totalSize: 0, isSharded: false, shardCount: 1, nextShardIndex: startShardIndex };
|
|
332
226
|
|
|
@@ -335,26 +229,27 @@ async function writeSingleResult(result, docRef, name, dateContext, logger, conf
|
|
|
335
229
|
const constraints = strategies[attempt];
|
|
336
230
|
try {
|
|
337
231
|
const updates = await prepareAutoShardedWrites(result, docRef, logger, constraints.bytes, constraints.keys, startShardIndex, flushMode);
|
|
338
|
-
|
|
339
|
-
// Inject Cleanup Ops
|
|
340
232
|
if (shouldWipeShards) {
|
|
341
|
-
logger.log('INFO', `[Cleanup] ${name}: Wiping old shards before Write (Initial).`);
|
|
342
233
|
const shardCol = docRef.collection('_shards');
|
|
343
234
|
const shardDocs = await shardCol.listDocuments();
|
|
344
|
-
// Prepend DELETEs
|
|
345
235
|
shardDocs.forEach(d => updates.unshift({ type: 'DELETE', ref: d }));
|
|
346
|
-
shouldWipeShards = false;
|
|
236
|
+
shouldWipeShards = false;
|
|
347
237
|
}
|
|
348
|
-
|
|
349
|
-
// Ensure the root document write respects our merge option
|
|
350
238
|
const rootUpdate = updates.find(u => u.ref.path === docRef.path && u.type !== 'DELETE');
|
|
351
|
-
if (rootUpdate) {
|
|
352
|
-
rootUpdate.options = { merge: rootMergeOption };
|
|
353
|
-
}
|
|
239
|
+
if (rootUpdate) { rootUpdate.options = { merge: rootMergeOption }; }
|
|
354
240
|
|
|
355
|
-
|
|
241
|
+
// Calculate Ops
|
|
242
|
+
const writes = updates.filter(u => u.type !== 'DELETE').length;
|
|
243
|
+
const deletes = updates.filter(u => u.type === 'DELETE').length;
|
|
244
|
+
|
|
245
|
+
await commitBatchInChunks(config, deps, updates, `${name}::${dateContext}`);
|
|
246
|
+
|
|
247
|
+
opCounts.writes += writes;
|
|
248
|
+
opCounts.deletes += deletes;
|
|
249
|
+
|
|
356
250
|
finalStats.totalSize = updates.reduce((acc, u) => acc + (u.data ? JSON.stringify(u.data).length : 0), 0);
|
|
357
251
|
|
|
252
|
+
const pointer = updates.find(u => u.data && (u.data._completed !== undefined || u.data._sharded !== undefined));
|
|
358
253
|
let maxIndex = startShardIndex;
|
|
359
254
|
updates.forEach(u => {
|
|
360
255
|
if (u.type === 'DELETE') return;
|
|
@@ -374,28 +269,26 @@ async function writeSingleResult(result, docRef, name, dateContext, logger, conf
|
|
|
374
269
|
finalStats.nextShardIndex = maxIndex + 1;
|
|
375
270
|
finalStats.isSharded = true;
|
|
376
271
|
}
|
|
377
|
-
|
|
378
|
-
await commitBatchInChunks(config, deps, updates, `${name}::${dateContext} (Att ${attempt+1})`);
|
|
379
|
-
if (logger && logger.logStorage) { logger.logStorage(null, name, dateContext, docRef.path, finalStats.totalSize, finalStats.isSharded); }
|
|
272
|
+
|
|
380
273
|
committed = true;
|
|
381
274
|
} catch (commitErr) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
275
|
+
lastError = commitErr;
|
|
276
|
+
const msg = commitErr.message || '';
|
|
277
|
+
const code = commitErr.code || '';
|
|
278
|
+
const isIndexError = msg.includes('too many index entries') || msg.includes('INVALID_ARGUMENT');
|
|
279
|
+
const isSizeError = msg.includes('Transaction too big') || msg.includes('payload is too large');
|
|
280
|
+
|
|
281
|
+
if (NON_RETRYABLE_ERRORS.includes(code)) {
|
|
282
|
+
logger.log('ERROR', `[SelfHealing] ${name} FATAL error: ${msg}.`);
|
|
283
|
+
throw commitErr;
|
|
284
|
+
}
|
|
285
|
+
if (isIndexError || isSizeError) {
|
|
286
|
+
logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} failed attempt ${attempt+1}/${strategies.length}. Strategy: ${JSON.stringify(constraints)}. Error: ${msg}. Retrying with stricter limits...`);
|
|
287
|
+
continue;
|
|
288
|
+
} else {
|
|
289
|
+
logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} unknown error. Retrying...`, { error: msg });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
399
292
|
}
|
|
400
293
|
}
|
|
401
294
|
if (!committed) {
|
|
@@ -404,6 +297,7 @@ async function writeSingleResult(result, docRef, name, dateContext, logger, conf
|
|
|
404
297
|
if (lastError && lastError.stack) { shardingError.stack = lastError.stack; }
|
|
405
298
|
throw shardingError;
|
|
406
299
|
}
|
|
300
|
+
finalStats.opCounts = opCounts;
|
|
407
301
|
return finalStats;
|
|
408
302
|
}
|
|
409
303
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Utility for recording computation run attempts (The Audit Logger).
|
|
3
|
-
* UPDATED: Stores 'trigger'
|
|
4
|
-
* UPDATED (IDEA 2): Stores granular timing profiles.
|
|
3
|
+
* UPDATED: Stores 'trigger', 'execution' stats, 'cost' metrics, and 'forensics'.
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
6
|
const { FieldValue } = require('../utils/utils');
|
|
@@ -17,9 +16,8 @@ function sanitizeErrorKey(message) {
|
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Records a run attempt with detailed metrics and aggregated stats.
|
|
20
|
-
* ADDED: 'triggerReason' param.
|
|
21
19
|
*/
|
|
22
|
-
async function recordRunAttempt(db, context, status, error = null, detailedMetrics = { durationMs: 0 }, triggerReason = 'Unknown') {
|
|
20
|
+
async function recordRunAttempt(db, context, status, error = null, detailedMetrics = { durationMs: 0 }, triggerReason = 'Unknown', resourceTier = 'standard') {
|
|
23
21
|
if (!db || !context) return;
|
|
24
22
|
|
|
25
23
|
const { date: targetDate, computation, pass } = context;
|
|
@@ -38,7 +36,6 @@ async function recordRunAttempt(db, context, status, error = null, detailedMetri
|
|
|
38
36
|
const anomalies = detailedMetrics.validation?.anomalies || [];
|
|
39
37
|
if (error && error.message && error.message.includes('Data Integrity')) { anomalies.push(error.message); }
|
|
40
38
|
|
|
41
|
-
// [IDEA 2] Prepare Execution Stats & Timings
|
|
42
39
|
const rawExecStats = detailedMetrics.execution || {};
|
|
43
40
|
const timings = rawExecStats.timings || {};
|
|
44
41
|
|
|
@@ -52,17 +49,28 @@ async function recordRunAttempt(db, context, status, error = null, detailedMetri
|
|
|
52
49
|
durationMs: detailedMetrics.durationMs || 0,
|
|
53
50
|
status: status,
|
|
54
51
|
|
|
55
|
-
// [NEW]
|
|
52
|
+
// [NEW] Cost & Resource Analysis
|
|
53
|
+
resourceTier: resourceTier, // 'standard' or 'high-mem'
|
|
54
|
+
peakMemoryMB: detailedMetrics.peakMemoryMB || 0,
|
|
55
|
+
|
|
56
|
+
// [NEW] IO Operations (for Cost Calc)
|
|
57
|
+
firestoreOps: {
|
|
58
|
+
reads: detailedMetrics.io?.reads || 0,
|
|
59
|
+
writes: detailedMetrics.io?.writes || 0,
|
|
60
|
+
deletes: detailedMetrics.io?.deletes || 0
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// [NEW] Code Linkage (Forensics)
|
|
64
|
+
composition: detailedMetrics.composition || null,
|
|
65
|
+
|
|
56
66
|
trigger: {
|
|
57
67
|
reason: triggerReason || 'Unknown',
|
|
58
68
|
type: (triggerReason && triggerReason.includes('Layer')) ? 'CASCADE' : ((triggerReason && triggerReason.includes('New')) ? 'INIT' : 'UPDATE')
|
|
59
69
|
},
|
|
60
70
|
|
|
61
|
-
// [IDEA 2] Enhanced Execution Stats
|
|
62
71
|
executionStats: {
|
|
63
72
|
processedUsers: rawExecStats.processedUsers || 0,
|
|
64
73
|
skippedUsers: rawExecStats.skippedUsers || 0,
|
|
65
|
-
// Explicitly break out timings for BigQuery/Analysis
|
|
66
74
|
timings: {
|
|
67
75
|
setupMs: Math.round(timings.setup || 0),
|
|
68
76
|
streamMs: Math.round(timings.stream || 0),
|
|
@@ -78,7 +86,7 @@ async function recordRunAttempt(db, context, status, error = null, detailedMetri
|
|
|
78
86
|
},
|
|
79
87
|
|
|
80
88
|
anomalies: anomalies,
|
|
81
|
-
_schemaVersion: '2.
|
|
89
|
+
_schemaVersion: '2.3' // Version Bump for Monitoring
|
|
82
90
|
};
|
|
83
91
|
|
|
84
92
|
if (error) {
|
|
@@ -90,10 +98,12 @@ async function recordRunAttempt(db, context, status, error = null, detailedMetri
|
|
|
90
98
|
};
|
|
91
99
|
}
|
|
92
100
|
|
|
101
|
+
// Aggregated Stats for Quick Dashboarding
|
|
93
102
|
const statsUpdate = {
|
|
94
103
|
lastRunAt: now,
|
|
95
104
|
lastRunStatus: status,
|
|
96
|
-
totalRuns: FieldValue.increment(1)
|
|
105
|
+
totalRuns: FieldValue.increment(1),
|
|
106
|
+
totalCostAccumulated: FieldValue.increment(0) // Placeholder for future cost adder
|
|
97
107
|
};
|
|
98
108
|
|
|
99
109
|
if (status === 'SUCCESS') { statsUpdate.successCount = FieldValue.increment(1);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* @fileoverview Admin API Router
|
|
3
3
|
* Sub-module for system observability, debugging, and visualization.
|
|
4
4
|
* Mounted at /admin within the Generic API.
|
|
5
|
+
* UPDATED: Added advanced cost, performance, and live monitoring endpoints.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
const express = require('express');
|
|
@@ -84,8 +85,6 @@ const createAdminRouter = (config, dependencies, unifiedCalculations) => {
|
|
|
84
85
|
});
|
|
85
86
|
|
|
86
87
|
// --- 2. STATUS MATRIX (Calendar / State UI) ---
|
|
87
|
-
// Returns status of ALL computations across a date range.
|
|
88
|
-
// ENHANCED: Cross-references Manifest to detect "PENDING" (Not run yet) vs "MISSING".
|
|
89
88
|
router.get('/matrix', async (req, res) => {
|
|
90
89
|
const { start, end } = req.query;
|
|
91
90
|
if (!start || !end) return res.status(400).json({ error: "Start and End dates required." });
|
|
@@ -153,7 +152,6 @@ const createAdminRouter = (config, dependencies, unifiedCalculations) => {
|
|
|
153
152
|
});
|
|
154
153
|
|
|
155
154
|
// --- 3. PIPELINE STATE (Progress Bar) ---
|
|
156
|
-
// Shows realtime status of the 5-pass system for a specific date
|
|
157
155
|
router.get('/pipeline/state', async (req, res) => {
|
|
158
156
|
const { date } = req.query;
|
|
159
157
|
if (!date) return res.status(400).json({ error: "Date required" });
|
|
@@ -352,7 +350,6 @@ const createAdminRouter = (config, dependencies, unifiedCalculations) => {
|
|
|
352
350
|
});
|
|
353
351
|
|
|
354
352
|
// --- 7. FLIGHT RECORDER (Inspection) ---
|
|
355
|
-
// Existing inspection endpoint kept for drill-down
|
|
356
353
|
router.get('/inspect/:date/:calcName', async (req, res) => {
|
|
357
354
|
const { date, calcName } = req.params;
|
|
358
355
|
try {
|
|
@@ -379,6 +376,156 @@ const createAdminRouter = (config, dependencies, unifiedCalculations) => {
|
|
|
379
376
|
}
|
|
380
377
|
});
|
|
381
378
|
|
|
379
|
+
// --- 8. COST & RESOURCE ANALYSIS ---
|
|
380
|
+
router.get('/analytics/costs', async (req, res) => {
|
|
381
|
+
const { date, days } = req.query;
|
|
382
|
+
// Default to today if no date, or range if days provided
|
|
383
|
+
const targetDate = date || new Date().toISOString().slice(0, 10);
|
|
384
|
+
|
|
385
|
+
// Simple Cost Model (Estimates)
|
|
386
|
+
const COSTS = {
|
|
387
|
+
write: 0.18 / 100000,
|
|
388
|
+
read: 0.06 / 100000,
|
|
389
|
+
delete: 0.02 / 100000,
|
|
390
|
+
compute_std_sec: 0.000023, // 1vCPU 2GB (approx)
|
|
391
|
+
compute_high_sec: 0.000092 // 2vCPU 8GB (approx)
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const auditRef = db.collection('computation_audit_logs');
|
|
396
|
+
// We scan the 'history' subcollectionGroup for the given date(s)
|
|
397
|
+
// Note: This can be expensive. In prod, you'd want aggregate counters.
|
|
398
|
+
const query = db.collectionGroup('history').where('targetDate', '==', targetDate);
|
|
399
|
+
const snap = await query.get();
|
|
400
|
+
|
|
401
|
+
let totalCost = 0;
|
|
402
|
+
const byPass = {};
|
|
403
|
+
const byCalc = {};
|
|
404
|
+
|
|
405
|
+
snap.forEach(doc => {
|
|
406
|
+
const data = doc.data();
|
|
407
|
+
const ops = data.firestoreOps || { reads: 0, writes: 0, deletes: 0 };
|
|
408
|
+
const durationSec = (data.durationMs || 0) / 1000;
|
|
409
|
+
const tier = data.resourceTier || 'standard';
|
|
410
|
+
|
|
411
|
+
const ioCost = (ops.writes * COSTS.write) + (ops.reads * COSTS.read) + (ops.deletes * COSTS.delete);
|
|
412
|
+
const computeCost = durationSec * (tier === 'high-mem' ? COSTS.compute_high_sec : COSTS.compute_std_sec);
|
|
413
|
+
const itemCost = ioCost + computeCost;
|
|
414
|
+
|
|
415
|
+
totalCost += itemCost;
|
|
416
|
+
|
|
417
|
+
// Aggregations
|
|
418
|
+
const pass = data.pass || 'unknown';
|
|
419
|
+
if (!byPass[pass]) byPass[pass] = { cost: 0, runs: 0, duration: 0 };
|
|
420
|
+
byPass[pass].cost += itemCost;
|
|
421
|
+
byPass[pass].runs++;
|
|
422
|
+
byPass[pass].duration += durationSec;
|
|
423
|
+
|
|
424
|
+
const calc = data.computationName;
|
|
425
|
+
if (!byCalc[calc]) byCalc[calc] = { cost: 0, runs: 0, ops: { r:0, w:0 } };
|
|
426
|
+
byCalc[calc].cost += itemCost;
|
|
427
|
+
byCalc[calc].runs++;
|
|
428
|
+
byCalc[calc].ops.r += ops.reads;
|
|
429
|
+
byCalc[calc].ops.w += ops.writes;
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Top 10 Expensive Calcs
|
|
433
|
+
const topCalcs = Object.entries(byCalc)
|
|
434
|
+
.sort((a, b) => b[1].cost - a[1].cost)
|
|
435
|
+
.slice(0, 10)
|
|
436
|
+
.map(([name, stats]) => ({ name, ...stats }));
|
|
437
|
+
|
|
438
|
+
res.json({
|
|
439
|
+
date: targetDate,
|
|
440
|
+
totalCostUSD: totalCost,
|
|
441
|
+
breakdown: {
|
|
442
|
+
byPass,
|
|
443
|
+
topCalculations: topCalcs
|
|
444
|
+
},
|
|
445
|
+
meta: { model: COSTS }
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// --- 9. REROUTE (OOM) ANALYSIS ---
|
|
452
|
+
router.get('/analytics/reroutes', async (req, res) => {
|
|
453
|
+
const { date } = req.query;
|
|
454
|
+
if (!date) return res.status(400).json({ error: "Date required" });
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
// Find all runs that used high-mem
|
|
458
|
+
const query = db.collectionGroup('history')
|
|
459
|
+
.where('targetDate', '==', date)
|
|
460
|
+
.where('resourceTier', '==', 'high-mem');
|
|
461
|
+
|
|
462
|
+
const snap = await query.get();
|
|
463
|
+
const reroutes = [];
|
|
464
|
+
|
|
465
|
+
snap.forEach(doc => {
|
|
466
|
+
const data = doc.data();
|
|
467
|
+
reroutes.push({
|
|
468
|
+
computation: data.computationName,
|
|
469
|
+
pass: data.pass,
|
|
470
|
+
trigger: data.trigger?.reason,
|
|
471
|
+
peakMemoryMB: data.peakMemoryMB,
|
|
472
|
+
durationMs: data.durationMs,
|
|
473
|
+
runId: data.runId
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
res.json({ count: reroutes.length, reroutes });
|
|
478
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// --- 10. LIVE DASHBOARD (Snapshot) ---
|
|
482
|
+
// Poll this endpoint to simulate a WebSocket feed
|
|
483
|
+
router.get('/live/dashboard', async (req, res) => {
|
|
484
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
485
|
+
try {
|
|
486
|
+
// Query the Ledger for Active Tasks
|
|
487
|
+
// We look at all passes for today
|
|
488
|
+
const passes = ['1', '2', '3', '4', '5'];
|
|
489
|
+
const activeTasks = [];
|
|
490
|
+
const recentFailures = [];
|
|
491
|
+
|
|
492
|
+
await Promise.all(passes.map(async (pass) => {
|
|
493
|
+
const colRef = db.collection(`computation_audit_ledger/${today}/passes/${pass}/tasks`);
|
|
494
|
+
|
|
495
|
+
// Get Running
|
|
496
|
+
const runningSnap = await colRef.where('status', 'in', ['PENDING', 'IN_PROGRESS']).get();
|
|
497
|
+
runningSnap.forEach(doc => {
|
|
498
|
+
activeTasks.push({ pass, ...doc.data() });
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Get Recent Failures (last 10 mins?? hard to query without index, just grab failures)
|
|
502
|
+
const failSnap = await colRef.where('status', '==', 'FAILED').get();
|
|
503
|
+
failSnap.forEach(doc => {
|
|
504
|
+
recentFailures.push({ pass, ...doc.data() });
|
|
505
|
+
});
|
|
506
|
+
}));
|
|
507
|
+
|
|
508
|
+
// Get Pipeline Stage (which pass is active?)
|
|
509
|
+
// We infer this by seeing which pass has pending tasks
|
|
510
|
+
let currentStage = 'IDLE';
|
|
511
|
+
for (const p of passes) {
|
|
512
|
+
const hasActive = activeTasks.some(t => t.pass === p);
|
|
513
|
+
if (hasActive) { currentStage = `PASS_${p}`; break; }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
res.json({
|
|
517
|
+
status: 'success',
|
|
518
|
+
timestamp: new Date(),
|
|
519
|
+
pipelineState: currentStage,
|
|
520
|
+
activeCount: activeTasks.length,
|
|
521
|
+
failureCount: recentFailures.length,
|
|
522
|
+
tasks: activeTasks,
|
|
523
|
+
failures: recentFailures
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
527
|
+
});
|
|
528
|
+
|
|
382
529
|
return router;
|
|
383
530
|
};
|
|
384
531
|
|