bulltrackers-module 1.0.264 → 1.0.265
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/WorkflowOrchestrator.js +58 -22
- package/functions/computation-system/context/ManifestBuilder.js +37 -9
- package/functions/computation-system/persistence/ResultCommitter.js +43 -156
- package/functions/computation-system/persistence/StatusRepository.js +16 -5
- package/functions/computation-system/tools/BuildReporter.js +2 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Main Orchestrator. Coordinates the topological execution.
|
|
3
|
-
* UPDATED:
|
|
3
|
+
* UPDATED: Implements Smart Audit logic to detect WHY a hash mismatch occurred.
|
|
4
4
|
*/
|
|
5
5
|
const { normalizeName, DEFINITIVE_EARLIEST_DATES } = require('./utils/utils');
|
|
6
6
|
const { checkRootDataAvailability, checkRootDependencies } = require('./data/AvailabilityChecker');
|
|
@@ -10,13 +10,13 @@ const { StandardExecutor } = require('./executor
|
|
|
10
10
|
const { MetaExecutor } = require('./executors/MetaExecutor');
|
|
11
11
|
const { generateProcessId, PROCESS_TYPES } = require('./logger/logger');
|
|
12
12
|
|
|
13
|
-
// [FIX] Split IMPOSSIBLE into semantic categories
|
|
14
13
|
const STATUS_IMPOSSIBLE_PREFIX = 'IMPOSSIBLE';
|
|
15
14
|
|
|
16
15
|
function groupByPass(manifest) { return manifest.reduce((acc, calc) => { (acc[calc.pass] = acc[calc.pass] || []).push(calc); return acc; }, {}); }
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Analyzes whether calculations should run, be skipped, or are blocked.
|
|
19
|
+
* Now performs Deep Hash Analysis to explain Re-Runs.
|
|
20
20
|
*/
|
|
21
21
|
function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus, manifestMap, prevDailyStatus = null) {
|
|
22
22
|
const report = { runnable: [], blocked: [], impossible: [], failedDependency: [], reRuns: [], skipped: [] };
|
|
@@ -28,7 +28,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
|
|
|
28
28
|
const stored = currentStatusMap[norm];
|
|
29
29
|
const depManifest = manifestMap.get(norm);
|
|
30
30
|
if (!stored) return false;
|
|
31
|
-
// [FIX] Check for any IMPOSSIBLE variant
|
|
32
31
|
if (typeof stored.hash === 'string' && stored.hash.startsWith(STATUS_IMPOSSIBLE_PREFIX)) return false;
|
|
33
32
|
if (!depManifest) return false;
|
|
34
33
|
if (stored.hash !== depManifest.hash) return false;
|
|
@@ -42,7 +41,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
|
|
|
42
41
|
const storedCategory = stored ? stored.category : null;
|
|
43
42
|
const currentHash = calc.hash;
|
|
44
43
|
|
|
45
|
-
// [FIX] Granular impossible marking
|
|
46
44
|
const markImpossible = (reason, type = 'GENERIC') => {
|
|
47
45
|
report.impossible.push({ name: cName, reason });
|
|
48
46
|
const statusHash = `${STATUS_IMPOSSIBLE_PREFIX}:${type}`;
|
|
@@ -58,7 +56,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
|
|
|
58
56
|
let migrationOldCategory = null;
|
|
59
57
|
if (storedCategory && storedCategory !== calc.category) { migrationOldCategory = storedCategory; }
|
|
60
58
|
|
|
61
|
-
// [FIX] Check for any IMPOSSIBLE variant in storage
|
|
62
59
|
if (typeof storedHash === 'string' && storedHash.startsWith(STATUS_IMPOSSIBLE_PREFIX)) {
|
|
63
60
|
report.skipped.push({ name: cName, reason: `Permanently Impossible (${storedHash})` });
|
|
64
61
|
continue;
|
|
@@ -69,7 +66,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
|
|
|
69
66
|
if (!rootCheck.canRun) {
|
|
70
67
|
const missingStr = rootCheck.missing.join(', ');
|
|
71
68
|
if (!isTargetToday) {
|
|
72
|
-
// [FIX] Mark specifically as NO_DATA
|
|
73
69
|
markImpossible(`Missing Root Data: ${missingStr} (Historical)`, 'NO_DATA');
|
|
74
70
|
} else {
|
|
75
71
|
report.blocked.push({ name: cName, reason: `Missing Root Data: ${missingStr} (Waiting)` });
|
|
@@ -83,7 +79,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
|
|
|
83
79
|
for (const dep of calc.dependencies) {
|
|
84
80
|
const normDep = normalizeName(dep);
|
|
85
81
|
const depStored = simulationStatus[normDep];
|
|
86
|
-
// [FIX] Check for any IMPOSSIBLE variant in dependencies
|
|
87
82
|
if (depStored && typeof depStored.hash === 'string' && depStored.hash.startsWith(STATUS_IMPOSSIBLE_PREFIX)) {
|
|
88
83
|
dependencyIsImpossible = true;
|
|
89
84
|
break;
|
|
@@ -93,7 +88,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
|
|
|
93
88
|
}
|
|
94
89
|
|
|
95
90
|
if (dependencyIsImpossible) {
|
|
96
|
-
// [FIX] Mark specifically as UPSTREAM failure
|
|
97
91
|
markImpossible('Dependency is Impossible', 'UPSTREAM');
|
|
98
92
|
continue;
|
|
99
93
|
}
|
|
@@ -111,44 +105,88 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
|
|
|
111
105
|
}
|
|
112
106
|
}
|
|
113
107
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
108
|
+
// --- HASH CHECK LOGIC ---
|
|
109
|
+
if (!storedHash) {
|
|
110
|
+
markRunnable(false); // New Calculation
|
|
111
|
+
}
|
|
112
|
+
else if (storedHash !== currentHash) {
|
|
113
|
+
// Smart Logic: Why did it change?
|
|
114
|
+
let changeReason = "Hash Mismatch (Unknown)";
|
|
115
|
+
const oldComp = stored.composition;
|
|
116
|
+
const newComp = calc.composition;
|
|
117
|
+
|
|
118
|
+
if (oldComp && newComp) {
|
|
119
|
+
// 1. Check Code
|
|
120
|
+
if (oldComp.code !== newComp.code) {
|
|
121
|
+
changeReason = "Code Changed";
|
|
122
|
+
}
|
|
123
|
+
// 2. Check Layers
|
|
124
|
+
else if (JSON.stringify(oldComp.layers) !== JSON.stringify(newComp.layers)) {
|
|
125
|
+
// Find specific layer
|
|
126
|
+
const changedLayers = [];
|
|
127
|
+
for(const lKey in newComp.layers) {
|
|
128
|
+
if (newComp.layers[lKey] !== oldComp.layers[lKey]) changedLayers.push(lKey);
|
|
129
|
+
}
|
|
130
|
+
changeReason = `Layer Update: [${changedLayers.join(', ')}]`;
|
|
131
|
+
}
|
|
132
|
+
// 3. Check Dependencies
|
|
133
|
+
else if (JSON.stringify(oldComp.deps) !== JSON.stringify(newComp.deps)) {
|
|
134
|
+
// Find specific dep
|
|
135
|
+
const changedDeps = [];
|
|
136
|
+
for(const dKey in newComp.deps) {
|
|
137
|
+
if (newComp.deps[dKey] !== oldComp.deps[dKey]) changedDeps.push(dKey);
|
|
138
|
+
}
|
|
139
|
+
changeReason = `Upstream Change: [${changedDeps.join(', ')}]`;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
changeReason = "Logic/Epoch Change";
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
changeReason = "Hash Mismatch (No prior composition)";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
markRunnable(true, {
|
|
149
|
+
name: cName,
|
|
150
|
+
oldHash: storedHash,
|
|
151
|
+
newHash: currentHash,
|
|
152
|
+
previousCategory: migrationOldCategory,
|
|
153
|
+
reason: changeReason // <--- Passed to Reporter
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
else if (migrationOldCategory) {
|
|
157
|
+
markRunnable(true, { name: cName, reason: 'Category Migration', previousCategory: migrationOldCategory, newCategory: calc.category });
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
report.skipped.push({ name: cName });
|
|
161
|
+
simulationStatus[cName] = { hash: currentHash, category: calc.category, composition: calc.composition };
|
|
162
|
+
}
|
|
118
163
|
}
|
|
119
164
|
return report;
|
|
120
165
|
}
|
|
121
166
|
|
|
122
167
|
/**
|
|
123
168
|
* DIRECT EXECUTION PIPELINE (For Workers)
|
|
124
|
-
* Skips analysis. Assumes the calculation is valid and runnable.
|
|
125
|
-
* [UPDATED] Accepted previousCategory argument to handle migrations.
|
|
126
169
|
*/
|
|
127
170
|
async function executeDispatchTask(dateStr, pass, targetComputation, config, dependencies, computationManifest, previousCategory = null) {
|
|
128
171
|
const { logger } = dependencies;
|
|
129
172
|
const pid = generateProcessId(PROCESS_TYPES.EXECUTOR, targetComputation, dateStr);
|
|
130
173
|
|
|
131
|
-
// 1. Get Calculation Manifest
|
|
132
174
|
const manifestMap = new Map(computationManifest.map(c => [normalizeName(c.name), c]));
|
|
133
175
|
const calcManifest = manifestMap.get(normalizeName(targetComputation));
|
|
134
176
|
|
|
135
177
|
if (!calcManifest) { throw new Error(`Calculation '${targetComputation}' not found in manifest.`); }
|
|
136
178
|
|
|
137
|
-
// [UPDATED] Attach migration context if present
|
|
138
179
|
if (previousCategory) {
|
|
139
180
|
calcManifest.previousCategory = previousCategory;
|
|
140
181
|
logger.log('INFO', `[Executor] Migration detected for ${calcManifest.name}. Old data will be cleaned from: ${previousCategory}`);
|
|
141
182
|
}
|
|
142
183
|
|
|
143
|
-
// 2. Fetch Root Data Availability
|
|
144
184
|
const rootData = await checkRootDataAvailability(dateStr, config, dependencies, DEFINITIVE_EARLIEST_DATES);
|
|
145
|
-
|
|
146
185
|
if (!rootData) {
|
|
147
|
-
logger.log('ERROR', `[Executor] FATAL: Root data check failed for ${targetComputation} on ${dateStr}
|
|
186
|
+
logger.log('ERROR', `[Executor] FATAL: Root data check failed for ${targetComputation} on ${dateStr}.`);
|
|
148
187
|
return;
|
|
149
188
|
}
|
|
150
189
|
|
|
151
|
-
// 3. Fetch Dependencies
|
|
152
190
|
const calcsToRun = [calcManifest];
|
|
153
191
|
const existingResults = await fetchExistingResults(dateStr, calcsToRun, computationManifest, config, dependencies, false);
|
|
154
192
|
|
|
@@ -160,7 +198,6 @@ async function executeDispatchTask(dateStr, pass, targetComputation, config, dep
|
|
|
160
198
|
previousResults = await fetchExistingResults(prevDateStr, calcsToRun, computationManifest, config, dependencies, true);
|
|
161
199
|
}
|
|
162
200
|
|
|
163
|
-
// 4. Execute
|
|
164
201
|
logger.log('INFO', `[Executor] Running ${calcManifest.name} for ${dateStr}`, { processId: pid });
|
|
165
202
|
let resultUpdates = {};
|
|
166
203
|
|
|
@@ -176,5 +213,4 @@ async function executeDispatchTask(dateStr, pass, targetComputation, config, dep
|
|
|
176
213
|
}
|
|
177
214
|
}
|
|
178
215
|
|
|
179
|
-
|
|
180
216
|
module.exports = { executeDispatchTask, groupByPass, analyzeDateExecution };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Dynamic Manifest Builder - Handles Topological Sort and Auto-Discovery.
|
|
3
|
+
* UPDATED: Generates Granular Hash Composition for Audit Trails.
|
|
3
4
|
*/
|
|
4
5
|
const { generateCodeHash, LEGACY_MAPPING } = require('../topology/HashManager.js');
|
|
5
6
|
const { normalizeName } = require('../utils/utils');
|
|
@@ -106,9 +107,13 @@ function buildManifest(productLinesToRun = [], calculations) {
|
|
|
106
107
|
const metadata = Class.getMetadata();
|
|
107
108
|
const dependencies = Class.getDependencies().map(normalizeName);
|
|
108
109
|
const codeStr = Class.toString();
|
|
110
|
+
const selfCodeHash = generateCodeHash(codeStr);
|
|
111
|
+
|
|
112
|
+
let compositeHashString = selfCodeHash + `|EPOCH:${SYSTEM_EPOCH}`;
|
|
109
113
|
|
|
110
|
-
let compositeHashString = generateCodeHash(codeStr) + `|EPOCH:${SYSTEM_EPOCH}`; // Here we build the hash
|
|
111
114
|
const usedDeps = [];
|
|
115
|
+
// Track layer hashes for composition analysis
|
|
116
|
+
const usedLayerHashes = {};
|
|
112
117
|
|
|
113
118
|
for (const [layerName, exportsMap] of Object.entries(LAYER_TRIGGERS)) {
|
|
114
119
|
const layerHashes = LAYER_HASHES[layerName];
|
|
@@ -118,19 +123,30 @@ function buildManifest(productLinesToRun = [], calculations) {
|
|
|
118
123
|
if (exportHash) {
|
|
119
124
|
compositeHashString += exportHash;
|
|
120
125
|
usedDeps.push(`${layerName}.${exportName}`);
|
|
126
|
+
|
|
127
|
+
// Group hashes by layer for the composition report
|
|
128
|
+
if (!usedLayerHashes[layerName]) usedLayerHashes[layerName] = '';
|
|
129
|
+
usedLayerHashes[layerName] += exportHash;
|
|
121
130
|
}
|
|
122
131
|
}
|
|
123
132
|
}
|
|
124
133
|
}
|
|
125
134
|
|
|
135
|
+
// Simplify layer hashes to one hash per layer for the report
|
|
136
|
+
const layerComposition = {};
|
|
137
|
+
for(const [lName, lStr] of Object.entries(usedLayerHashes)) {
|
|
138
|
+
layerComposition[lName] = generateCodeHash(lStr);
|
|
139
|
+
}
|
|
140
|
+
|
|
126
141
|
// Safe Mode Fallback
|
|
127
142
|
let isSafeMode = false;
|
|
128
143
|
if (usedDeps.length === 0) {
|
|
129
144
|
isSafeMode = true;
|
|
130
145
|
Object.values(LAYER_HASHES).forEach(layerObj => { Object.values(layerObj).forEach(h => compositeHashString += h); });
|
|
146
|
+
layerComposition['ALL_SAFE_MODE'] = 'ALL';
|
|
131
147
|
}
|
|
132
148
|
|
|
133
|
-
const
|
|
149
|
+
const intrinsicHash = generateCodeHash(compositeHashString);
|
|
134
150
|
|
|
135
151
|
const manifestEntry = {
|
|
136
152
|
name: normalizedName,
|
|
@@ -143,7 +159,16 @@ function buildManifest(productLinesToRun = [], calculations) {
|
|
|
143
159
|
userType: metadata.userType,
|
|
144
160
|
dependencies: dependencies,
|
|
145
161
|
pass: 0,
|
|
146
|
-
hash:
|
|
162
|
+
hash: intrinsicHash, // Will be updated with deps
|
|
163
|
+
|
|
164
|
+
// [NEW] Composition Object for Audit
|
|
165
|
+
composition: {
|
|
166
|
+
epoch: SYSTEM_EPOCH,
|
|
167
|
+
code: selfCodeHash,
|
|
168
|
+
layers: layerComposition,
|
|
169
|
+
deps: {} // Will be populated after topo sort
|
|
170
|
+
},
|
|
171
|
+
|
|
147
172
|
debugUsedLayers: isSafeMode ? ['ALL (Safe Mode)'] : usedDeps
|
|
148
173
|
};
|
|
149
174
|
|
|
@@ -174,8 +199,6 @@ function buildManifest(productLinesToRun = [], calculations) {
|
|
|
174
199
|
}
|
|
175
200
|
|
|
176
201
|
const productLineEndpoints = [];
|
|
177
|
-
|
|
178
|
-
// [UPDATE] Check if we should run ALL product lines (if empty or wildcard)
|
|
179
202
|
const runAll = !productLinesToRun || productLinesToRun.length === 0 || productLinesToRun.includes('*');
|
|
180
203
|
|
|
181
204
|
for (const [name, entry] of manifestMap.entries()) {
|
|
@@ -187,7 +210,6 @@ function buildManifest(productLinesToRun = [], calculations) {
|
|
|
187
210
|
const requiredCalcs = getDependencySet(productLineEndpoints, adjacency);
|
|
188
211
|
log.info(`Filtered down to ${requiredCalcs.size} active calculations.`);
|
|
189
212
|
|
|
190
|
-
// [LOG VERIFICATION] Final Proof of Active Lines
|
|
191
213
|
const activePackages = new Set();
|
|
192
214
|
requiredCalcs.forEach(name => {
|
|
193
215
|
const entry = manifestMap.get(name);
|
|
@@ -240,11 +262,17 @@ function buildManifest(productLinesToRun = [], calculations) {
|
|
|
240
262
|
|
|
241
263
|
// --- Cascading Hash (Phase 2) ---
|
|
242
264
|
for (const entry of sortedManifest) {
|
|
243
|
-
let dependencySignature = entry.hash;
|
|
265
|
+
let dependencySignature = entry.hash; // Start with intrinsic
|
|
266
|
+
|
|
244
267
|
if (entry.dependencies && entry.dependencies.length > 0) {
|
|
245
268
|
const depHashes = entry.dependencies.map(depName => {
|
|
246
|
-
const depEntry = filteredManifestMap.get(depName);
|
|
247
|
-
|
|
269
|
+
const depEntry = filteredManifestMap.get(depName);
|
|
270
|
+
if (depEntry) {
|
|
271
|
+
// Populate Composition
|
|
272
|
+
entry.composition.deps[depName] = depEntry.hash;
|
|
273
|
+
return depEntry.hash;
|
|
274
|
+
}
|
|
275
|
+
return '';
|
|
248
276
|
}).join('|');
|
|
249
277
|
dependencySignature += `|DEPS:${depHashes}`;
|
|
250
278
|
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Handles saving computation results with observability and Smart Cleanup.
|
|
3
|
-
* UPDATED:
|
|
4
|
-
* UPDATED: Stops retrying on non-transient errors.
|
|
5
|
-
* UPDATED: Supports Multi-Date Fan-Out (Time Machine Mode) with CONCURRENCY THROTTLING.
|
|
3
|
+
* UPDATED: Stores Hash Composition in status for audit trail.
|
|
6
4
|
*/
|
|
7
5
|
const { commitBatchInChunks } = require('./FirestoreUtils');
|
|
8
6
|
const { updateComputationStatus } = require('./StatusRepository');
|
|
@@ -10,13 +8,10 @@ const { batchStoreSchemas } = require('../utils/schema_capture');
|
|
|
10
8
|
const { generateProcessId, PROCESS_TYPES } = require('../logger/logger');
|
|
11
9
|
const { HeuristicValidator } = require('./ResultsValidator');
|
|
12
10
|
const validationOverrides = require('../config/validation_overrides');
|
|
13
|
-
const pLimit = require('p-limit');
|
|
11
|
+
const pLimit = require('p-limit');
|
|
14
12
|
|
|
15
13
|
const NON_RETRYABLE_ERRORS = [
|
|
16
|
-
'INVALID_ARGUMENT',
|
|
17
|
-
'PERMISSION_DENIED', // Auth issue
|
|
18
|
-
'DATA_LOSS', // Firestore corruption
|
|
19
|
-
'FAILED_PRECONDITION' // Transaction requirements not met
|
|
14
|
+
'INVALID_ARGUMENT', 'PERMISSION_DENIED', 'DATA_LOSS', 'FAILED_PRECONDITION'
|
|
20
15
|
];
|
|
21
16
|
|
|
22
17
|
async function commitResults(stateObj, dStr, passName, config, deps, skipStatusWrite = false) {
|
|
@@ -27,13 +22,11 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
27
22
|
const { logger, db } = deps;
|
|
28
23
|
const pid = generateProcessId(PROCESS_TYPES.STORAGE, passName, dStr);
|
|
29
24
|
|
|
30
|
-
// SAFETY LIMIT: Only allow 10 concurrent daily writes to prevent network saturation during Fan-Out
|
|
31
25
|
const fanOutLimit = pLimit(10);
|
|
32
26
|
|
|
33
27
|
for (const name in stateObj) {
|
|
34
28
|
const calc = stateObj[name];
|
|
35
29
|
|
|
36
|
-
// Prep metrics container
|
|
37
30
|
const runMetrics = {
|
|
38
31
|
storage: { sizeBytes: 0, isSharded: false, shardCount: 1, keys: 0 },
|
|
39
32
|
validation: { isValid: true, anomalies: [] }
|
|
@@ -41,7 +34,6 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
41
34
|
|
|
42
35
|
try {
|
|
43
36
|
const result = await calc.getResult();
|
|
44
|
-
|
|
45
37
|
const overrides = validationOverrides[calc.manifest.name] || {};
|
|
46
38
|
const healthCheck = HeuristicValidator.analyze(calc.manifest.name, result, overrides);
|
|
47
39
|
|
|
@@ -54,52 +46,52 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
54
46
|
const isEmpty = !result || (typeof result === 'object' && Object.keys(result).length === 0) || (typeof result === 'number' && result === 0);
|
|
55
47
|
if (isEmpty) {
|
|
56
48
|
if (calc.manifest.hash) {
|
|
57
|
-
successUpdates[name] = {
|
|
49
|
+
successUpdates[name] = {
|
|
50
|
+
hash: calc.manifest.hash,
|
|
51
|
+
category: calc.manifest.category,
|
|
52
|
+
composition: calc.manifest.composition, // <--- Added Composition
|
|
53
|
+
metrics: runMetrics
|
|
54
|
+
};
|
|
58
55
|
}
|
|
59
56
|
continue;
|
|
60
57
|
}
|
|
61
58
|
|
|
62
59
|
if (typeof result === 'object') runMetrics.storage.keys = Object.keys(result).length;
|
|
63
60
|
|
|
64
|
-
//
|
|
65
|
-
// If the result keys are ALL date strings (YYYY-MM-DD), we split the writes.
|
|
61
|
+
// ... (Fan-out logic remains same) ...
|
|
66
62
|
const resultKeys = Object.keys(result || {});
|
|
67
63
|
const isMultiDate = resultKeys.length > 0 && resultKeys.every(k => /^\d{4}-\d{2}-\d{2}$/.test(k));
|
|
68
64
|
|
|
69
65
|
if (isMultiDate) {
|
|
70
66
|
logger.log('INFO', `[ResultCommitter] 🕰️ Multi-Date Output detected for ${name} (${resultKeys.length} days). Throttled Fan-Out...`);
|
|
71
67
|
|
|
72
|
-
// Group updates by DATE. result is { "2024-01-01": { user1: ... }, "2024-01-02": { user1: ... } }
|
|
73
|
-
// We execute a fan-out commit for each date using p-limit.
|
|
74
|
-
|
|
75
68
|
const datePromises = resultKeys.map((historicalDate) => fanOutLimit(async () => {
|
|
76
69
|
const dailyData = result[historicalDate];
|
|
77
70
|
if (!dailyData || Object.keys(dailyData).length === 0) return;
|
|
78
71
|
|
|
79
72
|
const historicalDocRef = db.collection(config.resultsCollection)
|
|
80
|
-
.doc(historicalDate)
|
|
73
|
+
.doc(historicalDate)
|
|
81
74
|
.collection(config.resultsSubcollection)
|
|
82
75
|
.doc(calc.manifest.category)
|
|
83
76
|
.collection(config.computationsSubcollection)
|
|
84
77
|
.doc(name);
|
|
85
78
|
|
|
86
|
-
// Re-use the existing sharding logic for this specific date payload
|
|
87
79
|
await writeSingleResult(dailyData, historicalDocRef, name, historicalDate, logger, config, deps);
|
|
88
80
|
}));
|
|
89
81
|
|
|
90
82
|
await Promise.all(datePromises);
|
|
91
83
|
|
|
92
|
-
// Mark success for the Target Date (dStr) so the workflow continues
|
|
93
84
|
if (calc.manifest.hash) {
|
|
94
85
|
successUpdates[name] = {
|
|
95
86
|
hash: calc.manifest.hash,
|
|
96
87
|
category: calc.manifest.category,
|
|
97
|
-
|
|
88
|
+
composition: calc.manifest.composition, // <--- Added Composition
|
|
89
|
+
metrics: runMetrics
|
|
98
90
|
};
|
|
99
91
|
}
|
|
100
92
|
|
|
101
93
|
} else {
|
|
102
|
-
// --- STANDARD MODE
|
|
94
|
+
// --- STANDARD MODE ---
|
|
103
95
|
const mainDocRef = db.collection(config.resultsCollection)
|
|
104
96
|
.doc(dStr)
|
|
105
97
|
.collection(config.resultsSubcollection)
|
|
@@ -107,30 +99,27 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
107
99
|
.collection(config.computationsSubcollection)
|
|
108
100
|
.doc(name);
|
|
109
101
|
|
|
110
|
-
// Use the encapsulated write function
|
|
111
102
|
const writeStats = await writeSingleResult(result, mainDocRef, name, dStr, logger, config, deps);
|
|
112
103
|
|
|
113
104
|
runMetrics.storage.sizeBytes = writeStats.totalSize;
|
|
114
105
|
runMetrics.storage.isSharded = writeStats.isSharded;
|
|
115
106
|
runMetrics.storage.shardCount = writeStats.shardCount;
|
|
116
107
|
|
|
117
|
-
// Mark Success & Pass Metrics
|
|
118
108
|
if (calc.manifest.hash) {
|
|
119
109
|
successUpdates[name] = {
|
|
120
110
|
hash: calc.manifest.hash,
|
|
121
111
|
category: calc.manifest.category,
|
|
112
|
+
composition: calc.manifest.composition, // <--- Added Composition
|
|
122
113
|
metrics: runMetrics
|
|
123
114
|
};
|
|
124
115
|
}
|
|
125
116
|
}
|
|
126
117
|
|
|
127
|
-
// Capture Schema
|
|
128
118
|
if (calc.manifest.class.getSchema) {
|
|
129
119
|
const { class: _cls, ...safeMetadata } = calc.manifest;
|
|
130
120
|
schemas.push({ name, category: calc.manifest.category, schema: calc.manifest.class.getSchema(), metadata: safeMetadata });
|
|
131
121
|
}
|
|
132
122
|
|
|
133
|
-
// Cleanup Migration
|
|
134
123
|
if (calc.manifest.previousCategory && calc.manifest.previousCategory !== calc.manifest.category) {
|
|
135
124
|
cleanupTasks.push(deleteOldCalculationData(dStr, calc.manifest.previousCategory, name, config, deps));
|
|
136
125
|
}
|
|
@@ -144,7 +133,7 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
144
133
|
failureReport.push({
|
|
145
134
|
name,
|
|
146
135
|
error: { message: msg, stack: e.stack, stage },
|
|
147
|
-
metrics: runMetrics
|
|
136
|
+
metrics: runMetrics
|
|
148
137
|
});
|
|
149
138
|
}
|
|
150
139
|
}
|
|
@@ -156,181 +145,79 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
156
145
|
return { successUpdates, failureReport };
|
|
157
146
|
}
|
|
158
147
|
|
|
159
|
-
/**
|
|
160
|
-
* Encapsulated write logic for reuse in Fan-Out.
|
|
161
|
-
* Handles sharding strategy and retries.
|
|
162
|
-
*/
|
|
163
148
|
async function writeSingleResult(result, docRef, name, dateContext, logger, config, deps) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
{ bytes: 900 * 1024, keys: null }, // Attempt 1: Standard
|
|
167
|
-
{ bytes: 450 * 1024, keys: 10000 }, // Attempt 2: High Index usage
|
|
168
|
-
{ bytes: 200 * 1024, keys: 2000 } // Attempt 3: Extreme fragmentation
|
|
169
|
-
];
|
|
170
|
-
|
|
171
|
-
let committed = false;
|
|
172
|
-
let lastError = null;
|
|
173
|
-
let finalStats = { totalSize: 0, isSharded: false, shardCount: 1 };
|
|
149
|
+
const strategies = [ { bytes: 900 * 1024, keys: null }, { bytes: 450 * 1024, keys: 10000 }, { bytes: 200 * 1024, keys: 2000 } ];
|
|
150
|
+
let committed = false; let lastError = null; let finalStats = { totalSize: 0, isSharded: false, shardCount: 1 };
|
|
174
151
|
|
|
175
152
|
for (let attempt = 0; attempt < strategies.length; attempt++) {
|
|
176
153
|
if (committed) break;
|
|
177
|
-
|
|
178
154
|
const constraints = strategies[attempt];
|
|
179
|
-
|
|
180
155
|
try {
|
|
181
|
-
// 1. Prepare Shards with current constraints
|
|
182
156
|
const updates = await prepareAutoShardedWrites(result, docRef, logger, constraints.bytes, constraints.keys);
|
|
183
|
-
|
|
184
|
-
// Stats
|
|
185
157
|
const pointer = updates.find(u => u.data._completed === true);
|
|
186
158
|
finalStats.isSharded = pointer && pointer.data._sharded === true;
|
|
187
159
|
finalStats.shardCount = finalStats.isSharded ? (pointer.data._shardCount || 1) : 1;
|
|
188
160
|
finalStats.totalSize = updates.reduce((acc, u) => acc + (u.data ? JSON.stringify(u.data).length : 0), 0);
|
|
189
|
-
|
|
190
|
-
// 2. Attempt Commit
|
|
191
161
|
await commitBatchInChunks(config, deps, updates, `${name}::${dateContext} (Att ${attempt+1})`);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (logger && logger.logStorage) {
|
|
195
|
-
logger.logStorage(null, name, dateContext, docRef.path, finalStats.totalSize, finalStats.isSharded);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
committed = true; // Exit loop
|
|
199
|
-
|
|
162
|
+
if (logger && logger.logStorage) { logger.logStorage(null, name, dateContext, docRef.path, finalStats.totalSize, finalStats.isSharded); }
|
|
163
|
+
committed = true;
|
|
200
164
|
} catch (commitErr) {
|
|
201
165
|
lastError = commitErr;
|
|
202
166
|
const msg = commitErr.message || '';
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
logger.log('ERROR', `[SelfHealing] ${name} encountered FATAL error (Attempt ${attempt + 1}): ${msg}. Aborting.`);
|
|
207
|
-
throw commitErr;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const isSizeError = msg.includes('Transaction too big') || msg.includes('payload is too large');
|
|
211
|
-
const isIndexError = msg.includes('too many index entries') || msg.includes('INVALID_ARGUMENT');
|
|
212
|
-
|
|
213
|
-
if (isSizeError || isIndexError) {
|
|
214
|
-
logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} failed write attempt ${attempt + 1}. Retrying with tighter constraints...`, { error: msg });
|
|
215
|
-
continue; // Try next strategy
|
|
216
|
-
} else {
|
|
217
|
-
logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} unknown error (Attempt ${attempt + 1}). Retrying...`, { error: msg });
|
|
218
|
-
}
|
|
167
|
+
if (NON_RETRYABLE_ERRORS.includes(commitErr.code)) { logger.log('ERROR', `[SelfHealing] ${name} FATAL error: ${msg}.`); throw commitErr; }
|
|
168
|
+
if (msg.includes('Transaction too big') || msg.includes('payload is too large') || msg.includes('too many index entries')) { logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} failed attempt ${attempt+1}. Retrying...`, { error: msg }); continue; }
|
|
169
|
+
else { logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} unknown error. Retrying...`, { error: msg }); }
|
|
219
170
|
}
|
|
220
171
|
}
|
|
221
|
-
|
|
222
|
-
if (!committed) {
|
|
223
|
-
throw {
|
|
224
|
-
message: `Exhausted sharding strategies for ${name} on ${dateContext}. Last error: ${lastError?.message}`,
|
|
225
|
-
stack: lastError?.stack,
|
|
226
|
-
stage: 'SHARDING_LIMIT_EXCEEDED'
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
172
|
+
if (!committed) { throw { message: `Exhausted sharding strategies for ${name}. Last error: ${lastError?.message}`, stack: lastError?.stack, stage: 'SHARDING_LIMIT_EXCEEDED' }; }
|
|
230
173
|
return finalStats;
|
|
231
174
|
}
|
|
232
175
|
|
|
233
|
-
/**
|
|
234
|
-
* Deletes result documents from a previous category location.
|
|
235
|
-
*/
|
|
236
176
|
async function deleteOldCalculationData(dateStr, oldCategory, calcName, config, deps) {
|
|
237
177
|
const { db, logger, calculationUtils } = deps;
|
|
238
178
|
const { withRetry } = calculationUtils || { withRetry: (fn) => fn() };
|
|
239
|
-
|
|
240
179
|
try {
|
|
241
|
-
const oldDocRef = db.collection(config.resultsCollection)
|
|
242
|
-
|
|
243
|
-
.collection(config.resultsSubcollection)
|
|
244
|
-
.doc(oldCategory)
|
|
245
|
-
.collection(config.computationsSubcollection)
|
|
246
|
-
.doc(calcName);
|
|
247
|
-
|
|
248
|
-
const shardsCol = oldDocRef.collection('_shards');
|
|
180
|
+
const oldDocRef = db.collection(config.resultsCollection).doc(dateStr).collection(config.resultsSubcollection).doc(oldCategory).collection(config.computationsSubcollection).doc(calcName);
|
|
181
|
+
const shardsCol = oldDocRef.collection('_shards');
|
|
249
182
|
const shardsSnap = await withRetry(() => shardsCol.listDocuments(), 'ListOldShards');
|
|
250
|
-
const batch
|
|
251
|
-
let ops = 0;
|
|
252
|
-
|
|
183
|
+
const batch = db.batch(); let ops = 0;
|
|
253
184
|
for (const shardDoc of shardsSnap) { batch.delete(shardDoc); ops++; }
|
|
254
|
-
batch.delete(oldDocRef);
|
|
255
|
-
ops++;
|
|
256
|
-
|
|
185
|
+
batch.delete(oldDocRef); ops++;
|
|
257
186
|
await withRetry(() => batch.commit(), 'CleanupOldCategory');
|
|
258
|
-
logger.log('INFO', `[Migration] Cleaned up ${ops} docs for ${calcName} in
|
|
259
|
-
|
|
260
|
-
} catch (e) {
|
|
261
|
-
logger.log('WARN', `[Migration] Failed to clean up old data for ${calcName}: ${e.message}`);
|
|
262
|
-
}
|
|
187
|
+
logger.log('INFO', `[Migration] Cleaned up ${ops} docs for ${calcName} in '${oldCategory}'`);
|
|
188
|
+
} catch (e) { logger.log('WARN', `[Migration] Failed to clean up ${calcName}: ${e.message}`); }
|
|
263
189
|
}
|
|
264
190
|
|
|
265
191
|
function calculateFirestoreBytes(value) {
|
|
266
|
-
if (value === null) return 1;
|
|
267
|
-
if (value === undefined) return 0;
|
|
268
|
-
if (typeof value === 'boolean') return 1;
|
|
269
|
-
if (typeof value === 'number') return 8;
|
|
270
|
-
if (typeof value === 'string') return Buffer.byteLength(value, 'utf8') + 1;
|
|
271
|
-
if (value instanceof Date) return 8;
|
|
272
|
-
if (value.constructor && value.constructor.name === 'DocumentReference') { return Buffer.byteLength(value.path, 'utf8') + 16; }
|
|
192
|
+
if (value === null) return 1; if (value === undefined) return 0; if (typeof value === 'boolean') return 1; if (typeof value === 'number') return 8; if (typeof value === 'string') return Buffer.byteLength(value, 'utf8') + 1; if (value instanceof Date) return 8; if (value.constructor && value.constructor.name === 'DocumentReference') { return Buffer.byteLength(value.path, 'utf8') + 16; }
|
|
273
193
|
if (Array.isArray(value)) { let sum = 0; for (const item of value) sum += calculateFirestoreBytes(item); return sum; }
|
|
274
|
-
if (typeof value === 'object') { let sum = 0; for (const k in value) { if (Object.prototype.hasOwnProperty.call(value, k)) { sum += (Buffer.byteLength(k, 'utf8') + 1) + calculateFirestoreBytes(value[k]); } } return sum; }
|
|
275
|
-
return 0;
|
|
194
|
+
if (typeof value === 'object') { let sum = 0; for (const k in value) { if (Object.prototype.hasOwnProperty.call(value, k)) { sum += (Buffer.byteLength(k, 'utf8') + 1) + calculateFirestoreBytes(value[k]); } } return sum; } return 0;
|
|
276
195
|
}
|
|
277
196
|
|
|
278
197
|
async function prepareAutoShardedWrites(result, docRef, logger, maxBytes = 900 * 1024, maxKeys = null) {
|
|
279
|
-
const OVERHEAD_ALLOWANCE
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const docPathSize = Buffer.byteLength(docRef.path, 'utf8') + 16;
|
|
284
|
-
|
|
285
|
-
const writes = [];
|
|
286
|
-
const shardCollection = docRef.collection('_shards');
|
|
287
|
-
let currentChunk = {};
|
|
288
|
-
let currentChunkSize = 0;
|
|
289
|
-
let currentKeyCount = 0;
|
|
290
|
-
let shardIndex = 0;
|
|
198
|
+
const OVERHEAD_ALLOWANCE = 20 * 1024; const CHUNK_LIMIT = maxBytes - OVERHEAD_ALLOWANCE;
|
|
199
|
+
const totalSize = calculateFirestoreBytes(result); const docPathSize = Buffer.byteLength(docRef.path, 'utf8') + 16;
|
|
200
|
+
const writes = []; const shardCollection = docRef.collection('_shards');
|
|
201
|
+
let currentChunk = {}; let currentChunkSize = 0; let currentKeyCount = 0; let shardIndex = 0;
|
|
291
202
|
|
|
292
|
-
// Fast path: If small enough AND keys are safe
|
|
293
203
|
if (!maxKeys && (totalSize + docPathSize) < CHUNK_LIMIT) {
|
|
294
|
-
const data = {
|
|
295
|
-
...result,
|
|
296
|
-
_completed: true,
|
|
297
|
-
_sharded: false,
|
|
298
|
-
_lastUpdated: new Date().toISOString()
|
|
299
|
-
};
|
|
204
|
+
const data = { ...result, _completed: true, _sharded: false, _lastUpdated: new Date().toISOString() };
|
|
300
205
|
return [{ ref: docRef, data, options: { merge: true } }];
|
|
301
206
|
}
|
|
302
207
|
|
|
303
208
|
for (const [key, value] of Object.entries(result)) {
|
|
304
209
|
if (key.startsWith('_')) continue;
|
|
305
|
-
const keySize
|
|
306
|
-
const
|
|
307
|
-
const itemSize = keySize + valueSize;
|
|
308
|
-
|
|
309
|
-
const byteLimitReached = (currentChunkSize + itemSize > CHUNK_LIMIT);
|
|
310
|
-
const keyLimitReached = (maxKeys && currentKeyCount + 1 >= maxKeys);
|
|
311
|
-
|
|
210
|
+
const keySize = Buffer.byteLength(key, 'utf8') + 1; const valueSize = calculateFirestoreBytes(value); const itemSize = keySize + valueSize;
|
|
211
|
+
const byteLimitReached = (currentChunkSize + itemSize > CHUNK_LIMIT); const keyLimitReached = (maxKeys && currentKeyCount + 1 >= maxKeys);
|
|
312
212
|
if (byteLimitReached || keyLimitReached) {
|
|
313
213
|
writes.push({ ref: shardCollection.doc(`shard_${shardIndex}`), data: currentChunk, options: { merge: false } });
|
|
314
|
-
shardIndex++;
|
|
315
|
-
currentChunk = {};
|
|
316
|
-
currentChunkSize = 0;
|
|
317
|
-
currentKeyCount = 0;
|
|
214
|
+
shardIndex++; currentChunk = {}; currentChunkSize = 0; currentKeyCount = 0;
|
|
318
215
|
}
|
|
319
|
-
currentChunk[key] = value;
|
|
320
|
-
currentChunkSize += itemSize;
|
|
321
|
-
currentKeyCount++;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (Object.keys(currentChunk).length > 0) {
|
|
325
|
-
writes.push({ ref: shardCollection.doc(`shard_${shardIndex}`), data: currentChunk, options: { merge: false } });
|
|
216
|
+
currentChunk[key] = value; currentChunkSize += itemSize; currentKeyCount++;
|
|
326
217
|
}
|
|
218
|
+
if (Object.keys(currentChunk).length > 0) { writes.push({ ref: shardCollection.doc(`shard_${shardIndex}`), data: currentChunk, options: { merge: false } }); }
|
|
327
219
|
|
|
328
|
-
const pointerData = {
|
|
329
|
-
_completed: true,
|
|
330
|
-
_sharded: true,
|
|
331
|
-
_shardCount: shardIndex + 1,
|
|
332
|
-
_lastUpdated: new Date().toISOString()
|
|
333
|
-
};
|
|
220
|
+
const pointerData = { _completed: true, _sharded: true, _shardCount: shardIndex + 1, _lastUpdated: new Date().toISOString() };
|
|
334
221
|
writes.push({ ref: docRef, data: pointerData, options: { merge: false } });
|
|
335
222
|
return writes;
|
|
336
223
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Manages computation status tracking in Firestore.
|
|
3
|
-
* UPDATED: Supports Schema V2 (Object with Category) for
|
|
3
|
+
* UPDATED: Supports Schema V2 (Object with Category & Composition) for deep auditing.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
async function fetchComputationStatus(dateStr, config, { db }) {
|
|
@@ -14,8 +14,11 @@ async function fetchComputationStatus(dateStr, config, { db }) {
|
|
|
14
14
|
|
|
15
15
|
// Normalize V1 (String) to V2 (Object)
|
|
16
16
|
for (const [name, value] of Object.entries(rawData)) {
|
|
17
|
-
if (typeof value === 'string') {
|
|
18
|
-
|
|
17
|
+
if (typeof value === 'string') {
|
|
18
|
+
normalized[name] = { hash: value, category: null, composition: null }; // Legacy entry
|
|
19
|
+
} else {
|
|
20
|
+
normalized[name] = value;
|
|
21
|
+
}
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
return normalized;
|
|
@@ -30,8 +33,16 @@ async function updateComputationStatus(dateStr, updates, config, { db }) {
|
|
|
30
33
|
|
|
31
34
|
const safeUpdates = {};
|
|
32
35
|
for (const [key, val] of Object.entries(updates)) {
|
|
33
|
-
if (typeof val === 'string') {
|
|
34
|
-
|
|
36
|
+
if (typeof val === 'string') {
|
|
37
|
+
// Legacy Call Fallback
|
|
38
|
+
safeUpdates[key] = { hash: val, category: 'unknown', lastUpdated: new Date() };
|
|
39
|
+
} else {
|
|
40
|
+
// V2 Call: val should contain { hash, category, composition }
|
|
41
|
+
safeUpdates[key] = {
|
|
42
|
+
...val,
|
|
43
|
+
lastUpdated: new Date()
|
|
44
|
+
};
|
|
45
|
+
}
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
await docRef.set(safeUpdates, { merge: true });
|
|
@@ -111,8 +111,9 @@ async function generateBuildReport(config, dependencies, manifest, daysBack = 90
|
|
|
111
111
|
// E. Format Findings
|
|
112
112
|
const dateSummary = { willRun: [], willReRun: [], blocked: [], impossible: [] };
|
|
113
113
|
|
|
114
|
+
// Pass the generated "Reason" string through to the report
|
|
114
115
|
analysis.runnable.forEach (item => dateSummary.willRun.push ({ name: item.name, reason: "New / No Previous Record" }));
|
|
115
|
-
analysis.reRuns.forEach (item => dateSummary.willReRun.push ({ name: item.name, reason: item.
|
|
116
|
+
analysis.reRuns.forEach (item => dateSummary.willReRun.push ({ name: item.name, reason: item.reason || "Hash Mismatch" }));
|
|
116
117
|
analysis.impossible.forEach (item => dateSummary.impossible.push ({ name: item.name, reason: item.reason }));
|
|
117
118
|
[...analysis.blocked, ...analysis.failedDependency].forEach(item => dateSummary.blocked.push({ name: item.name, reason: item.reason || 'Dependency' }));
|
|
118
119
|
|