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.
@@ -1,10 +1,6 @@
1
1
  /**
2
2
  * @fileoverview Executor for "Standard" (per-user) calculations.
3
- * UPDATED: Implements Batch Flushing to prevent OOM on large datasets.
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 if needed
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
- logger.log('INFO', `[${passName}] Streaming for ${streamingCalcs.length} computations...`);
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] = 0;
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
- // [UPDATED] Collect execution results (success/failure counts)
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
- // [NEW] Update Error Stats
127
- batchResults.forEach(r => {
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
- const heapUsedRatio = heapStats.used_heap_size / heapStats.heap_size_limit;
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
- if (update.metrics?.storage) {
229
- successAcc[name].metrics.storage.sizeBytes += (update.metrics.storage.sizeBytes || 0);
230
- successAcc[name].metrics.storage.keys += (update.metrics.storage.keys || 0);
231
- successAcc[name].metrics.storage.shardCount = Math.max(successAcc[name].metrics.storage.shardCount, update.metrics.storage.shardCount || 1);
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 (Flight Recorder) for OOM detection.
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
- * [NEW] Helper: Starts a background heartbeat to track memory usage.
24
- * This acts as a "Black Box Recorder". If the worker crashes (OOM),
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: Math.round(mem.rss / 1024 / 1024), // Resident Set Size (OOM Killer Metric)
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 timer;
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
- const { date, pass, computation, previousCategory, triggerReason, dispatchId, dependencyResultHashes, metaStatePath } = data;
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
- const heartbeatTimer = startMemoryHeartbeat(db, ledgerPath, 2000);
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(heartbeatTimer); // Stop if we fail early
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(heartbeatTimer);
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
- await recordRunAttempt(db, { date, computation, pass }, 'SUCCESS', null, { durationMs: duration }, triggerReason);
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(heartbeatTimer);
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: Implements GZIP Compression for efficient storage.
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'); // [NEW]
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'; // [NEW]
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 fanOutLimit = pLimit(10);
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
- try {
86
+ try {
113
87
  const regDoc = await db.collection(SIMHASH_REGISTRY_COLLECTION).doc(calc.manifest.hash).get();
114
- if (regDoc.exists) {
115
- simHash = regDoc.data().simHash;
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: calc.manifest.hash,
132
- simHash: simHash,
133
- resultHash: resultHash,
96
+ hash: calc.manifest.hash, simHash: simHash, resultHash: resultHash,
134
97
  dependencyResultHashes: calc.manifest.dependencyResultHashes || {},
135
- category: calc.manifest.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 historicalDocRef = db.collection(config.resultsCollection)
154
- .doc(historicalDate)
155
- .collection(config.resultsSubcollection)
156
- .doc(calc.manifest.category)
157
- .collection(config.computationsSubcollection)
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: msg, stack: e.stack, stage }, metrics: runMetrics });
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
- if (snap.exists) {
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) { /* ignore read error */ }
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. Saved as Blob.`);
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; // Done for this loop
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
- const pointer = updates.find(u => u.data && (u.data._completed !== undefined || u.data._sharded !== undefined));
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
- lastError = commitErr;
383
- const msg = commitErr.message || '';
384
- const code = commitErr.code || '';
385
- const isIndexError = msg.includes('too many index entries') || msg.includes('INVALID_ARGUMENT');
386
- const isSizeError = msg.includes('Transaction too big') || msg.includes('payload is too large');
387
-
388
- if (NON_RETRYABLE_ERRORS.includes(code)) {
389
- logger.log('ERROR', `[SelfHealing] ${name} FATAL error: ${msg}.`);
390
- throw commitErr;
391
- }
392
- if (isIndexError || isSizeError) {
393
- logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} failed attempt ${attempt+1}/${strategies.length}. Strategy: ${JSON.stringify(constraints)}. Error: ${msg}. Retrying with stricter limits...`);
394
- continue;
395
- } else {
396
- logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} unknown error. Retrying...`, { error: msg });
397
- continue;
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' reason and 'execution' stats.
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] Trigger Context
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.2' // Bumped for profiler
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.296",
3
+ "version": "1.0.297",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [