bulltrackers-module 1.0.215 → 1.0.216
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,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FILENAME:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* FILENAME: computation-system/helpers/orchestration_helpers.js
|
|
3
|
+
* FEATURE: Dynamic Auto-Sharding (Transparent 1MB Limit Handling)
|
|
4
|
+
* * DESCRIPTION:
|
|
5
|
+
* This module orchestrates the execution of computations. It handles:
|
|
6
|
+
* 1. Data Availability Checks
|
|
7
|
+
* 2. Dependency Injection (fetching results from previous passes)
|
|
8
|
+
* 3. Transparent Auto-Sharding:
|
|
9
|
+
* - Writes: Automatically detects if a result > 900KB. Splits it into a '_shards' subcollection.
|
|
10
|
+
* - Reads: Automatically detects sharded pointers and re-assembles the data.
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
const { ComputationController } = require('../controllers/computation_controller');
|
|
@@ -13,60 +18,27 @@ const {
|
|
|
13
18
|
getHistoryPartRefs, streamPortfolioData, streamHistoryData,
|
|
14
19
|
getRelevantShardRefs, loadDataByRefs
|
|
15
20
|
} = require('../utils/data_loader');
|
|
16
|
-
|
|
17
|
-
// --- DYNAMIC LAYER LOADING ---
|
|
18
|
-
// Replaces the old static import from 'math_primitives.js'
|
|
19
21
|
const mathLayer = require('../layers/index.js');
|
|
20
|
-
|
|
21
22
|
const pLimit = require('p-limit');
|
|
22
23
|
|
|
23
|
-
// Mappings
|
|
24
|
-
// (e.g. allowing 'math.compute' to resolve to 'MathPrimitives')
|
|
24
|
+
// Mappings for backward compatibility
|
|
25
25
|
const LEGACY_MAPPING = {
|
|
26
|
-
DataExtractor:
|
|
27
|
-
HistoryExtractor: 'history',
|
|
28
|
-
MathPrimitives: 'compute',
|
|
29
|
-
Aggregators: 'aggregate',
|
|
30
|
-
Validators: 'validate',
|
|
31
|
-
SignalPrimitives: 'signals',
|
|
32
|
-
SCHEMAS: 'schemas',
|
|
33
|
-
DistributionAnalytics: 'distribution',
|
|
34
|
-
TimeSeries: 'TimeSeries',
|
|
35
|
-
priceExtractor: 'priceExtractor',
|
|
36
|
-
InsightsExtractor: 'insights',
|
|
37
|
-
UserClassifier: 'classifier',
|
|
38
|
-
CognitiveBiases: 'bias',
|
|
39
|
-
SkillAttribution: 'skill',
|
|
40
|
-
Psychometrics: 'psychometrics'
|
|
26
|
+
DataExtractor: 'extract', HistoryExtractor: 'history', MathPrimitives: 'compute', Aggregators: 'aggregate', Validators: 'validate', SignalPrimitives: 'signals', SCHEMAS: 'schemas', DistributionAnalytics: 'distribution', TimeSeries: 'TimeSeries', priceExtractor: 'priceExtractor', InsightsExtractor: 'insights', UserClassifier: 'classifier', CognitiveBiases: 'bias', SkillAttribution: 'skill', Psychometrics: 'psychometrics'
|
|
41
27
|
};
|
|
42
28
|
|
|
43
29
|
function groupByPass(manifest) { return manifest.reduce((acc, calc) => { (acc[calc.pass] = acc[calc.pass] || []).push(calc); return acc; }, {}); }
|
|
44
30
|
|
|
45
|
-
/**
|
|
46
|
-
* --- PASSIVE DATA VALIDATION ---
|
|
47
|
-
*/
|
|
48
31
|
function validateResultPatterns(logger, calcName, results, category) {
|
|
49
32
|
if (category === 'speculator' || category === 'speculators') return;
|
|
50
|
-
const tickers
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
const sampleTicker = tickers.find(t => results[t] && typeof results[t] === 'object');
|
|
54
|
-
if (!sampleTicker) return;
|
|
55
|
-
const keys = Object.keys(results[sampleTicker]);
|
|
56
|
-
keys.forEach(key => {
|
|
33
|
+
const tickers = Object.keys(results); const totalItems = tickers.length; if (totalItems < 5) return;
|
|
34
|
+
const sampleTicker = tickers.find(t => results[t] && typeof results[t] === 'object'); if (!sampleTicker) return;
|
|
35
|
+
Object.keys(results[sampleTicker]).forEach(key => {
|
|
57
36
|
if (key.startsWith('_')) return;
|
|
58
|
-
let nullCount
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (val === null) nullCount++;
|
|
64
|
-
if (val === undefined) undefinedCount++;
|
|
65
|
-
if (typeof val === 'number' && isNaN(val)) nanCount++;
|
|
66
|
-
}
|
|
67
|
-
if (nanCount === totalItems) { logger.log('ERROR', `[DataQuality] Calc '${calcName}' field '${key}' is NaN for 100% of ${totalItems} items.`);
|
|
68
|
-
} else if (undefinedCount === totalItems) { logger.log('ERROR', `[DataQuality] Calc '${calcName}' field '${key}' is UNDEFINED for 100% of ${totalItems} items.`); }
|
|
69
|
-
else if (nullCount > (totalItems * 0.9)) { logger.log('WARN', `[DataQuality] Calc '${calcName}' field '${key}' is NULL for ${nullCount}/${totalItems} items.`); }
|
|
37
|
+
let nullCount = 0, nanCount = 0, undefinedCount = 0;
|
|
38
|
+
for (const t of tickers) { const val = results[t][key]; if (val === null) nullCount++; if (val === undefined) undefinedCount++; if (typeof val === 'number' && isNaN(val)) nanCount++; }
|
|
39
|
+
if (nanCount === totalItems) logger.log('ERROR', `[DataQuality] Calc '${calcName}' field '${key}' is NaN for 100% of items.`);
|
|
40
|
+
else if (undefinedCount === totalItems) logger.log('ERROR', `[DataQuality] Calc '${calcName}' field '${key}' is UNDEFINED for 100% of items.`);
|
|
41
|
+
else if (nullCount > (totalItems * 0.9)) logger.log('WARN', `[DataQuality] Calc '${calcName}' field '${key}' is NULL for ${nullCount}/${totalItems} items.`);
|
|
70
42
|
});
|
|
71
43
|
}
|
|
72
44
|
|
|
@@ -74,11 +46,11 @@ function checkRootDependencies(calcManifest, rootDataStatus) {
|
|
|
74
46
|
const missing = [];
|
|
75
47
|
if (!calcManifest.rootDataDependencies) return { canRun: true, missing };
|
|
76
48
|
for (const dep of calcManifest.rootDataDependencies) {
|
|
77
|
-
if (dep === 'portfolio'
|
|
78
|
-
else if (dep === 'insights' && !rootDataStatus.hasInsights)
|
|
79
|
-
else if (dep === 'social'
|
|
80
|
-
else if (dep === 'history'
|
|
81
|
-
else if (dep === 'price'
|
|
49
|
+
if (dep === 'portfolio' && !rootDataStatus.hasPortfolio) missing.push('portfolio');
|
|
50
|
+
else if (dep === 'insights' && !rootDataStatus.hasInsights) missing.push('insights');
|
|
51
|
+
else if (dep === 'social' && !rootDataStatus.hasSocial) missing.push('social');
|
|
52
|
+
else if (dep === 'history' && !rootDataStatus.hasHistory) missing.push('history');
|
|
53
|
+
else if (dep === 'price' && !rootDataStatus.hasPrices) missing.push('price');
|
|
82
54
|
}
|
|
83
55
|
return { canRun: missing.length === 0, missing };
|
|
84
56
|
}
|
|
@@ -88,115 +60,111 @@ async function checkRootDataAvailability(dateStr, config, dependencies, earliest
|
|
|
88
60
|
const dateToProcess = new Date(dateStr + 'T00:00:00Z');
|
|
89
61
|
let portfolioRefs = [], historyRefs = [];
|
|
90
62
|
let hasPortfolio = false, hasInsights = false, hasSocial = false, hasHistory = false, hasPrices = false, insightsData = null, socialData = null;
|
|
91
|
-
|
|
92
63
|
try {
|
|
93
64
|
const tasks = [];
|
|
94
|
-
if (dateToProcess >= earliestDates.portfolio) tasks.push(getPortfolioPartRefs
|
|
95
|
-
if (dateToProcess >= earliestDates.insights)
|
|
96
|
-
if (dateToProcess >= earliestDates.social)
|
|
97
|
-
if (dateToProcess >= earliestDates.history)
|
|
98
|
-
|
|
65
|
+
if (dateToProcess >= earliestDates.portfolio) tasks.push(getPortfolioPartRefs(config, dependencies, dateStr).then(r => { portfolioRefs = r; hasPortfolio = !!r.length; }));
|
|
66
|
+
if (dateToProcess >= earliestDates.insights) tasks.push(loadDailyInsights(config, dependencies, dateStr).then(r => { insightsData = r; hasInsights = !!r; }));
|
|
67
|
+
if (dateToProcess >= earliestDates.social) tasks.push(loadDailySocialPostInsights(config, dependencies, dateStr).then(r => { socialData = r; hasSocial = !!r; }));
|
|
68
|
+
if (dateToProcess >= earliestDates.history) tasks.push(getHistoryPartRefs(config, dependencies, dateStr).then(r => { historyRefs = r; hasHistory = !!r.length; }));
|
|
99
69
|
if (dateToProcess >= earliestDates.price) { tasks.push(checkPriceDataAvailability(config, dependencies).then(r => { hasPrices = r; })); }
|
|
100
70
|
await Promise.all(tasks);
|
|
101
71
|
if (!(hasPortfolio || hasInsights || hasSocial || hasHistory || hasPrices)) return null;
|
|
102
|
-
|
|
103
|
-
return {
|
|
104
|
-
portfolioRefs,
|
|
105
|
-
historyRefs,
|
|
106
|
-
todayInsights: insightsData,
|
|
107
|
-
todaySocialPostInsights: socialData,
|
|
108
|
-
status: { hasPortfolio, hasInsights, hasSocial, hasHistory, hasPrices },
|
|
109
|
-
yesterdayPortfolioRefs: null
|
|
110
|
-
};
|
|
111
|
-
|
|
72
|
+
return { portfolioRefs, historyRefs, todayInsights: insightsData, todaySocialPostInsights: socialData, status: { hasPortfolio, hasInsights, hasSocial, hasHistory, hasPrices }, yesterdayPortfolioRefs: null };
|
|
112
73
|
} catch (err) { logger.log('ERROR', `Error checking data: ${err.message}`); return null; }
|
|
113
74
|
}
|
|
114
75
|
|
|
115
76
|
async function firestoreHelper(action, { key, updates, config, db }) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
case 'fetchStatus': {
|
|
123
|
-
if (!key) throw new Error('fetchStatus requires a key');
|
|
124
|
-
const docRef = db.collection(collections.status).doc(key);
|
|
125
|
-
const snap = await docRef.get();
|
|
126
|
-
return snap.exists ? snap.data() : {};
|
|
77
|
+
const collections = { price: config.priceCollection || 'asset_prices', status: config.computationStatusCollection || 'computation_status', };
|
|
78
|
+
switch (action) {
|
|
79
|
+
case 'checkAvailability': try { const snapshot = await db.collection(collections.price).limit(1).get(); return !snapshot.empty; } catch (e) { return false; }
|
|
80
|
+
case 'fetchStatus': { if (!key) throw new Error('fetchStatus requires a key'); const docRef = db.collection(collections.status).doc(key); const snap = await docRef.get(); return snap.exists ? snap.data() : {}; }
|
|
81
|
+
case 'updateStatus': { if (!key) throw new Error('updateStatus requires a key'); if (!updates || Object.keys(updates).length === 0) return; const docRef = db.collection(collections.status).doc(key); await docRef.set(updates, { merge: true }); return true; }
|
|
82
|
+
default: throw new Error(`Unknown action: ${action}`);
|
|
127
83
|
}
|
|
128
|
-
|
|
129
|
-
case 'updateStatus': {
|
|
130
|
-
if (!key) throw new Error('updateStatus requires a key');
|
|
131
|
-
if (!updates || Object.keys(updates).length === 0) return;
|
|
132
|
-
const docRef = db.collection(collections.status).doc(key);
|
|
133
|
-
await docRef.set(updates, { merge: true });
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
default: throw new Error(`Unknown action: ${action}`); }
|
|
138
84
|
}
|
|
139
85
|
|
|
140
|
-
async function checkPriceDataAvailability
|
|
141
|
-
async function fetchComputationStatus
|
|
142
|
-
async function fetchGlobalComputationStatus
|
|
143
|
-
async function updateComputationStatus
|
|
144
|
-
|
|
86
|
+
async function checkPriceDataAvailability(config, dependencies) { return firestoreHelper('checkAvailability', { config, db: dependencies.db }); }
|
|
87
|
+
async function fetchComputationStatus(dateStr, config, { db }) { return firestoreHelper('fetchStatus', { key: dateStr, config, db }); }
|
|
88
|
+
async function fetchGlobalComputationStatus(config, { db }) { return firestoreHelper('fetchStatus', { key: 'global_status', config, db }); }
|
|
89
|
+
async function updateComputationStatus(dateStr, updates, config, { db }) { return firestoreHelper('updateStatus', { key: dateStr, updates, config, db }); }
|
|
145
90
|
|
|
91
|
+
/**
|
|
92
|
+
* --- REFACTORED: fetchExistingResults ---
|
|
93
|
+
* Transparently handles both standard documents and auto-sharded documents.
|
|
94
|
+
* 1. Fetches the doc.
|
|
95
|
+
* 2. Checks for `_sharded: true` flag.
|
|
96
|
+
* 3. If sharded, fetches subcollection and merges data back into a single object.
|
|
97
|
+
*/
|
|
146
98
|
async function fetchExistingResults(dateStr, calcsInPass, fullManifest, config, { db }, includeSelf = false) {
|
|
147
|
-
const manifestMap
|
|
99
|
+
const manifestMap = new Map(fullManifest.map(c => [normalizeName(c.name), c]));
|
|
148
100
|
const calcsToFetch = new Set();
|
|
149
|
-
for (const calc of calcsInPass) { if (calc.dependencies)
|
|
101
|
+
for (const calc of calcsInPass) { if (calc.dependencies) calc.dependencies.forEach(d => calcsToFetch.add(normalizeName(d))); if (includeSelf && calc.isHistorical) calcsToFetch.add(normalizeName(calc.name)); }
|
|
150
102
|
if (!calcsToFetch.size) return {};
|
|
151
103
|
const fetched = {};
|
|
152
104
|
const docRefs = [];
|
|
153
105
|
const names = [];
|
|
106
|
+
|
|
107
|
+
// 1. Prepare Reads
|
|
154
108
|
for (const name of calcsToFetch) {
|
|
155
109
|
const m = manifestMap.get(name);
|
|
156
|
-
if (m) { docRefs.push(db.collection(config.resultsCollection).doc(dateStr)
|
|
157
|
-
|
|
110
|
+
if (m) { docRefs.push(db.collection(config.resultsCollection).doc(dateStr).collection(config.resultsSubcollection).doc(m.category || 'unknown').collection(config.computationsSubcollection).doc(name)); names.push(name); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (docRefs.length) {
|
|
114
|
+
const snaps = await db.getAll(...docRefs);
|
|
115
|
+
const hydrationPromises = [];
|
|
116
|
+
|
|
117
|
+
// 2. Process Initial Snapshots
|
|
118
|
+
snaps.forEach((doc, i) => { const name = names[i]; if (!doc.exists) return; const data = doc.data(); if (data._sharded === true) { hydrationPromises.push(hydrateAutoShardedResult(doc.ref, name)); } else if (data._completed) { fetched[name] = data; } }); // CHECK FOR AUTO-SHARDING FLAG
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
// 3. Hydrate Sharded Data in Parallel
|
|
122
|
+
if (hydrationPromises.length > 0) { const hydratedResults = await Promise.all(hydrationPromises); hydratedResults.forEach(res => { fetched[res.name] = res.data; }); }
|
|
123
|
+
}
|
|
158
124
|
return fetched;
|
|
159
125
|
}
|
|
160
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Helper: Fetches all docs in the '_shards' subcollection and merges them.
|
|
129
|
+
*/
|
|
130
|
+
async function hydrateAutoShardedResult(docRef, resultName) {
|
|
131
|
+
// Determine subcollection name (defaulting to '_shards')
|
|
132
|
+
const shardsCol = docRef.collection('_shards');
|
|
133
|
+
const snapshot = await shardsCol.get();
|
|
134
|
+
|
|
135
|
+
const assembledData = { _completed: true }; // Rebuild the object
|
|
136
|
+
|
|
137
|
+
snapshot.forEach(doc => { const chunk = doc.data(); Object.assign(assembledData, chunk); });
|
|
138
|
+
|
|
139
|
+
// Remove internal flags if they leaked into the shards
|
|
140
|
+
delete assembledData._sharded;
|
|
141
|
+
delete assembledData._completed;
|
|
142
|
+
|
|
143
|
+
return { name: resultName, data: assembledData };
|
|
144
|
+
}
|
|
145
|
+
|
|
161
146
|
async function streamAndProcess(dateStr, state, passName, config, deps, rootData, portfolioRefs, historyRefs, fetchedDeps, previousFetchedDeps) {
|
|
162
147
|
const { logger } = deps;
|
|
163
|
-
const controller
|
|
164
|
-
const calcs
|
|
165
|
-
const streamingCalcs = calcs.filter(c => c.manifest.rootDataDependencies.includes('portfolio') || c.manifest.rootDataDependencies.includes('history')
|
|
166
|
-
|
|
148
|
+
const controller = new ComputationController(config, deps);
|
|
149
|
+
const calcs = Object.values(state).filter(c => c && c.manifest);
|
|
150
|
+
const streamingCalcs = calcs.filter(c => c.manifest.rootDataDependencies.includes('portfolio') || c.manifest.rootDataDependencies.includes('history'));
|
|
167
151
|
if (streamingCalcs.length === 0) return;
|
|
168
|
-
|
|
152
|
+
|
|
169
153
|
logger.log('INFO', `[${passName}] Streaming for ${streamingCalcs.length} computations...`);
|
|
170
|
-
|
|
171
154
|
await controller.loader.loadMappings();
|
|
172
|
-
const prevDate
|
|
155
|
+
const prevDate = new Date(dateStr + 'T00:00:00Z'); prevDate.setUTCDate(prevDate.getUTCDate() - 1);
|
|
173
156
|
const prevDateStr = prevDate.toISOString().slice(0, 10);
|
|
174
|
-
|
|
175
|
-
const tP_iter = streamPortfolioData(config, deps, dateStr, portfolioRefs);
|
|
157
|
+
const tP_iter = streamPortfolioData(config, deps, dateStr, portfolioRefs);
|
|
176
158
|
const needsYesterdayPortfolio = streamingCalcs.some(c => c.manifest.isHistorical);
|
|
177
|
-
const yP_iter
|
|
178
|
-
const needsTradingHistory
|
|
179
|
-
const tH_iter
|
|
180
|
-
|
|
181
|
-
let yP_chunk = {};
|
|
182
|
-
let tH_chunk = {};
|
|
159
|
+
const yP_iter = (needsYesterdayPortfolio && rootData.yesterdayPortfolioRefs) ? streamPortfolioData(config, deps, prevDateStr, rootData.yesterdayPortfolioRefs) : null;
|
|
160
|
+
const needsTradingHistory = streamingCalcs.some(c => c.manifest.rootDataDependencies.includes('history'));
|
|
161
|
+
const tH_iter = (needsTradingHistory && historyRefs) ? streamHistoryData(config, deps, dateStr, historyRefs) : null;
|
|
183
162
|
|
|
163
|
+
let yP_chunk = {}, tH_chunk = {};
|
|
184
164
|
for await (const tP_chunk of tP_iter) {
|
|
185
165
|
if (yP_iter) yP_chunk = (await yP_iter.next()).value || {};
|
|
186
166
|
if (tH_iter) tH_chunk = (await tH_iter.next()).value || {};
|
|
187
|
-
|
|
188
|
-
const promises = streamingCalcs.map(calc =>
|
|
189
|
-
controller.executor.executePerUser(
|
|
190
|
-
calc,
|
|
191
|
-
calc.manifest,
|
|
192
|
-
dateStr,
|
|
193
|
-
tP_chunk,
|
|
194
|
-
yP_chunk,
|
|
195
|
-
tH_chunk,
|
|
196
|
-
fetchedDeps,
|
|
197
|
-
previousFetchedDeps
|
|
198
|
-
)
|
|
199
|
-
);
|
|
167
|
+
const promises = streamingCalcs.map(calc => controller.executor.executePerUser(calc, calc.manifest, dateStr, tP_chunk, yP_chunk, tH_chunk, fetchedDeps, previousFetchedDeps));
|
|
200
168
|
await Promise.all(promises);
|
|
201
169
|
}
|
|
202
170
|
logger.log('INFO', `[${passName}] Streaming complete.`);
|
|
@@ -211,13 +179,8 @@ async function runStandardComputationPass(date, calcs, passName, config, deps, r
|
|
|
211
179
|
const prevStr = prev.toISOString().slice(0, 10);
|
|
212
180
|
fullRoot.yesterdayPortfolioRefs = await getPortfolioPartRefs(config, deps, prevStr);
|
|
213
181
|
}
|
|
214
|
-
|
|
215
182
|
const state = {};
|
|
216
|
-
for (const c of calcs) {
|
|
217
|
-
try { const inst = new c.class(); inst.manifest = c; state[normalizeName(c.name)] = inst; logger.log('INFO', `${c.name} calculation running for ${dStr}`); }
|
|
218
|
-
catch (e) { logger.log('WARN', `Failed to init ${c.name}`); }
|
|
219
|
-
}
|
|
220
|
-
|
|
183
|
+
for (const c of calcs) { try { const inst = new c.class(); inst.manifest = c; state[normalizeName(c.name)] = inst; logger.log('INFO', `${c.name} calculation running for ${dStr}`); } catch (e) { logger.log('WARN', `Failed to init ${c.name}`); } }
|
|
221
184
|
await streamAndProcess(dStr, state, passName, config, deps, fullRoot, rootData.portfolioRefs, rootData.historyRefs, fetchedDeps, previousFetchedDeps);
|
|
222
185
|
return await commitResults(state, dStr, passName, config, deps, skipStatusWrite);
|
|
223
186
|
}
|
|
@@ -226,12 +189,10 @@ async function runMetaComputationPass(date, calcs, passName, config, deps, fetch
|
|
|
226
189
|
const controller = new ComputationController(config, deps);
|
|
227
190
|
const dStr = date.toISOString().slice(0, 10);
|
|
228
191
|
const state = {};
|
|
229
|
-
|
|
230
192
|
for (const mCalc of calcs) {
|
|
231
193
|
try {
|
|
232
194
|
deps.logger.log('INFO', `${mCalc.name} calculation running for ${dStr}`);
|
|
233
|
-
const inst = new mCalc.class();
|
|
234
|
-
inst.manifest = mCalc;
|
|
195
|
+
const inst = new mCalc.class(); inst.manifest = mCalc;
|
|
235
196
|
await controller.executor.executeOncePerDay(inst, mCalc, dStr, fetchedDeps, previousFetchedDeps);
|
|
236
197
|
state[normalizeName(mCalc.name)] = inst;
|
|
237
198
|
} catch (e) { deps.logger.log('ERROR', `Meta calc failed ${mCalc.name}: ${e.message}`); }
|
|
@@ -241,15 +202,14 @@ async function runMetaComputationPass(date, calcs, passName, config, deps, fetch
|
|
|
241
202
|
|
|
242
203
|
/**
|
|
243
204
|
* --- REFACTORED: commitResults ---
|
|
244
|
-
*
|
|
245
|
-
* If
|
|
246
|
-
*
|
|
205
|
+
* Automatically detects result size.
|
|
206
|
+
* If > 900KB, it splits the result into chunks and writes to a subcollection.
|
|
207
|
+
* If < 900KB, it writes normally.
|
|
247
208
|
*/
|
|
248
209
|
async function commitResults(stateObj, dStr, passName, config, deps, skipStatusWrite = false) {
|
|
249
210
|
const successUpdates = {};
|
|
250
211
|
const schemas = [];
|
|
251
212
|
|
|
252
|
-
// Iterate PER CALCULATION to isolate failures
|
|
253
213
|
for (const name in stateObj) {
|
|
254
214
|
const calc = stateObj[name];
|
|
255
215
|
let hasData = false;
|
|
@@ -258,49 +218,30 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
258
218
|
const result = await calc.getResult();
|
|
259
219
|
if (!result) { deps.logger.log('INFO', `${name} for ${dStr}: Skipped (Empty Result)`); continue; }
|
|
260
220
|
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const docsMap = sData[colName];
|
|
271
|
-
for (const docId in docsMap) { const ref = docId.includes('/') ? deps.db.doc(docId) : deps.db.collection(colName).doc(docId); shardedWrites.push({ ref, data: { ...docsMap[docId], _completed: true } }); } }
|
|
272
|
-
if (Object.keys(sData).length > 0) hasData = true;
|
|
273
|
-
} else { standardRes[key] = result[key]; }
|
|
221
|
+
const mainDocRef = deps.db.collection(config.resultsCollection).doc(dStr).collection(config.resultsSubcollection).doc(calc.manifest.category).collection(config.computationsSubcollection).doc(name);
|
|
222
|
+
|
|
223
|
+
// AUTO-SHARDING LOGIC
|
|
224
|
+
const updates = await prepareAutoShardedWrites(result, mainDocRef, deps.logger);
|
|
225
|
+
|
|
226
|
+
// Collect Schemas if present
|
|
227
|
+
if (calc.manifest.class.getSchema) {
|
|
228
|
+
const { class: _cls, ...safeMetadata } = calc.manifest;
|
|
229
|
+
schemas.push({ name, category: calc.manifest.category, schema: calc.manifest.class.getSchema(), metadata: safeMetadata });
|
|
274
230
|
}
|
|
275
231
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
232
|
+
if (updates.length > 0) {
|
|
233
|
+
await commitBatchInChunks(config, deps, updates, `${name} Results`);
|
|
234
|
+
successUpdates[name] = calc.manifest.hash || true;
|
|
235
|
+
const isSharded = updates.some(u => u.data._sharded === true);
|
|
236
|
+
deps.logger.log('INFO', `${name} for ${dStr}: \u2714 Success (Written ${isSharded ? 'Sharded' : 'Standard'})`);
|
|
237
|
+
} else {
|
|
238
|
+
deps.logger.log('INFO', `${name} for ${dStr}: - Empty Data`);
|
|
283
239
|
}
|
|
284
240
|
|
|
285
|
-
// 3. Queue Schema (Safe to accumulate)
|
|
286
|
-
if (calc.manifest.class.getSchema) { const { class: _cls, ...safeMetadata } = calc.manifest; schemas.push({ name, category: calc.manifest.category, schema: calc.manifest.class.getSchema(), metadata: safeMetadata }); }
|
|
287
|
-
|
|
288
|
-
// 4. ATTEMPT COMMIT FOR THIS CALCULATION ONLY
|
|
289
|
-
if (hasData) {
|
|
290
|
-
const allWritesForCalc = [...calcWrites, ...shardedWrites];
|
|
291
|
-
if (allWritesForCalc.length > 0) {
|
|
292
|
-
await commitBatchInChunks(config, deps, allWritesForCalc, `${name} Results`);
|
|
293
|
-
successUpdates[name] = calc.manifest.hash || true;
|
|
294
|
-
deps.logger.log('INFO', `${name} for ${dStr}: \u2714 Success (Written)`);
|
|
295
|
-
} else { deps.logger.log('INFO', `${name} for ${dStr}: - No Data to Write`); }
|
|
296
|
-
} else { deps.logger.log('INFO', `${name} for ${dStr}: - Empty`); }
|
|
297
241
|
} catch (e) { deps.logger.log('ERROR', `${name} for ${dStr}: \u2716 FAILED Commit: ${e.message}`); }
|
|
298
242
|
}
|
|
299
243
|
|
|
300
|
-
// Save Schemas (Best effort, isolated)
|
|
301
244
|
if (schemas.length) batchStoreSchemas(deps, config, schemas).catch(() => { });
|
|
302
|
-
|
|
303
|
-
// Update Status Document (Only for the ones that succeeded)
|
|
304
245
|
if (!skipStatusWrite && Object.keys(successUpdates).length > 0) {
|
|
305
246
|
await updateComputationStatus(dStr, successUpdates, config, deps);
|
|
306
247
|
deps.logger.log('INFO', `[${passName}] Updated status document for ${Object.keys(successUpdates).length} successful computations.`);
|
|
@@ -309,116 +250,151 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
309
250
|
}
|
|
310
251
|
|
|
311
252
|
/**
|
|
312
|
-
*
|
|
253
|
+
* Accurately calculates the size of a value according to Firestore storage rules.
|
|
254
|
+
* Reference: https://firebase.google.com/docs/firestore/storage-size
|
|
313
255
|
*/
|
|
256
|
+
function calculateFirestoreBytes(value) {
|
|
257
|
+
if (value === null) return 1;
|
|
258
|
+
if (value === undefined) return 0; // Firestore drops undefined fields
|
|
259
|
+
if (typeof value === 'boolean') return 1;
|
|
260
|
+
if (typeof value === 'number') return 8; // All numbers are 64-bit doubles or integers
|
|
261
|
+
if (typeof value === 'string') return Buffer.byteLength(value, 'utf8') + 1;
|
|
262
|
+
if (value instanceof Date) return 8; // Timestamps are 8 bytes
|
|
263
|
+
|
|
264
|
+
// Handle References (approximate based on path length)
|
|
265
|
+
if (value.constructor && value.constructor.name === 'DocumentReference') {
|
|
266
|
+
// Path string + 16 bytes for the reference type overhead
|
|
267
|
+
return Buffer.byteLength(value.path, 'utf8') + 16;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Handle Arrays: Sum of all values
|
|
271
|
+
if (Array.isArray(value)) {
|
|
272
|
+
let sum = 0;
|
|
273
|
+
for (const item of value) sum += calculateFirestoreBytes(item);
|
|
274
|
+
return sum;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Handle Objects (Maps): Sum of (Key + 1 + Value)
|
|
278
|
+
if (typeof value === 'object') {
|
|
279
|
+
let sum = 0;
|
|
280
|
+
for (const k in value) {
|
|
281
|
+
if (Object.prototype.hasOwnProperty.call(value, k)) {
|
|
282
|
+
// Key size (utf8 + 1) + Value size
|
|
283
|
+
sum += (Buffer.byteLength(k, 'utf8') + 1) + calculateFirestoreBytes(value[k]);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return sum;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return 0; // Fallback
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async function prepareAutoShardedWrites(result, docRef, logger) {
|
|
294
|
+
const SAFETY_THRESHOLD_BYTES = 1000 * 1024; // 1MB Limit (We target just under this)
|
|
295
|
+
const OVERHEAD_ALLOWANCE = 20 * 1024; // 20KB Safety margin for document path & metadata
|
|
296
|
+
const CHUNK_LIMIT = SAFETY_THRESHOLD_BYTES - OVERHEAD_ALLOWANCE;
|
|
297
|
+
const totalSize = calculateFirestoreBytes(result); // 1. Calculate Total Size Once (O(N))
|
|
298
|
+
const docPathSize = Buffer.byteLength(docRef.path, 'utf8') + 16; // Add the size of the document path itself (Firestore counts this against the 1MB limit)
|
|
299
|
+
|
|
300
|
+
if ((totalSize + docPathSize) < CHUNK_LIMIT) { const data = { ...result, _completed: true, _sharded: false }; return [{ ref: docRef, data, options: { merge: true } }]; } // CASE A: Fits in one document
|
|
301
|
+
|
|
302
|
+
logger.log('INFO', `[AutoShard] Result size ~${Math.round(totalSize/1024)}KB exceeds limit. Sharding...`);
|
|
303
|
+
|
|
304
|
+
const writes = [];
|
|
305
|
+
const shardCollection = docRef.collection('_shards');
|
|
306
|
+
|
|
307
|
+
let currentChunk = {};
|
|
308
|
+
let currentChunkSize = 0;
|
|
309
|
+
let shardIndex = 0;
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
for (const [key, value] of Object.entries(result)) { // 2. Efficient O(N) Loop
|
|
313
|
+
if (key.startsWith('_')) continue;
|
|
314
|
+
const keySize = Buffer.byteLength(key, 'utf8') + 1; // Calculate size of just this item
|
|
315
|
+
const valueSize = calculateFirestoreBytes(value);
|
|
316
|
+
const itemSize = keySize + valueSize;
|
|
317
|
+
|
|
318
|
+
if (currentChunkSize + itemSize > CHUNK_LIMIT) { // Check if adding this item would overflow the current chunk
|
|
319
|
+
// Flush current chunk
|
|
320
|
+
writes.push({ ref: shardCollection.doc(`shard_${shardIndex}`), data: currentChunk, options: { merge: false } }); // Overwrite
|
|
321
|
+
shardIndex++;
|
|
322
|
+
currentChunk = {};
|
|
323
|
+
currentChunkSize = 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Add to current chunk
|
|
327
|
+
currentChunk[key] = value;
|
|
328
|
+
currentChunkSize += itemSize;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Flush final chunk
|
|
332
|
+
if (Object.keys(currentChunk).length > 0) { writes.push({ ref: shardCollection.doc(`shard_${shardIndex}`), data: currentChunk, options: { merge: false } }); }
|
|
333
|
+
|
|
334
|
+
// Pointer Document
|
|
335
|
+
const pointerData = { _completed: true, _sharded: true, _shardCount: shardIndex + 1, _lastUpdated: new Date().toISOString() };
|
|
336
|
+
|
|
337
|
+
// Use merge: false to ensure we overwrite any previous non-sharded blob
|
|
338
|
+
writes.push({ ref: docRef, data: pointerData, options: { merge: false } });
|
|
339
|
+
|
|
340
|
+
return writes;
|
|
341
|
+
}
|
|
342
|
+
|
|
314
343
|
async function runBatchPriceComputation(config, deps, dateStrings, calcs, targetTickers = []) {
|
|
315
344
|
const { logger, db, calculationUtils } = deps;
|
|
316
345
|
const controller = new ComputationController(config, deps);
|
|
317
|
-
|
|
318
346
|
const mappings = await controller.loader.loadMappings();
|
|
319
|
-
|
|
320
347
|
let targetInstrumentIds = [];
|
|
321
348
|
if (targetTickers && targetTickers.length > 0) {
|
|
322
349
|
const tickerToInst = mappings.tickerToInstrument || {};
|
|
323
350
|
targetInstrumentIds = targetTickers.map(t => tickerToInst[t]).filter(id => id);
|
|
324
|
-
if (targetInstrumentIds.length === 0) { logger.log('WARN', '[BatchPrice] Target tickers provided but no IDs found. Aborting.'); return; }
|
|
325
|
-
|
|
351
|
+
if (targetInstrumentIds.length === 0) { logger.log('WARN', '[BatchPrice] Target tickers provided but no IDs found. Aborting.'); return; }
|
|
352
|
+
}
|
|
326
353
|
const allShardRefs = await getRelevantShardRefs(config, deps, targetInstrumentIds);
|
|
327
|
-
|
|
328
354
|
if (!allShardRefs.length) { logger.log('WARN', '[BatchPrice] No relevant price shards found. Exiting.'); return; }
|
|
329
|
-
|
|
330
|
-
const OUTER_CONCURRENCY_LIMIT = 2;
|
|
331
|
-
const SHARD_BATCH_SIZE = 20;
|
|
332
|
-
const WRITE_BATCH_LIMIT = 50;
|
|
333
|
-
|
|
355
|
+
const OUTER_CONCURRENCY_LIMIT = 2, SHARD_BATCH_SIZE = 20, WRITE_BATCH_LIMIT = 50;
|
|
334
356
|
logger.log('INFO', `[BatchPrice] Execution Plan: ${dateStrings.length} days, ${allShardRefs.length} shards. Concurrency: ${OUTER_CONCURRENCY_LIMIT}.`);
|
|
335
|
-
|
|
336
|
-
const shardChunks = [];
|
|
337
|
-
for (let i = 0; i < allShardRefs.length; i += SHARD_BATCH_SIZE) { shardChunks.push(allShardRefs.slice(i, i + SHARD_BATCH_SIZE)); }
|
|
338
|
-
|
|
357
|
+
const shardChunks = []; for (let i = 0; i < allShardRefs.length; i += SHARD_BATCH_SIZE) { shardChunks.push(allShardRefs.slice(i, i + SHARD_BATCH_SIZE)); }
|
|
339
358
|
const outerLimit = pLimit(OUTER_CONCURRENCY_LIMIT);
|
|
340
|
-
|
|
341
359
|
const chunkPromises = [];
|
|
342
360
|
for (let index = 0; index < shardChunks.length; index++) {
|
|
343
361
|
const shardChunkRefs = shardChunks[index];
|
|
344
362
|
chunkPromises.push(outerLimit(async () => {
|
|
345
363
|
try {
|
|
346
364
|
logger.log('INFO', `[BatchPrice] Processing chunk ${index + 1}/${shardChunks.length} (${shardChunkRefs.length} shards)...`);
|
|
347
|
-
|
|
348
365
|
const pricesData = await loadDataByRefs(config, deps, shardChunkRefs);
|
|
349
|
-
|
|
350
|
-
if (targetInstrumentIds.length > 0) {
|
|
351
|
-
const requestedSet = new Set(targetInstrumentIds);
|
|
352
|
-
for (const loadedInstrumentId in pricesData) { if (!requestedSet.has(loadedInstrumentId)) { delete pricesData[loadedInstrumentId]; } }
|
|
353
|
-
}
|
|
354
|
-
|
|
366
|
+
if (targetInstrumentIds.length > 0) { const requestedSet = new Set(targetInstrumentIds); for (const loadedInstrumentId in pricesData) { if (!requestedSet.has(loadedInstrumentId)) { delete pricesData[loadedInstrumentId]; } } }
|
|
355
367
|
const writes = [];
|
|
356
|
-
|
|
357
368
|
for (const dateStr of dateStrings) {
|
|
358
|
-
|
|
359
|
-
// --- DYNAMIC MATH CONTEXT CONSTRUCTION ---
|
|
360
369
|
const dynamicMathContext = {};
|
|
361
|
-
for (const [key, value] of Object.entries(mathLayer)) { dynamicMathContext[key] = value;
|
|
362
|
-
|
|
363
|
-
const context = {
|
|
364
|
-
mappings,
|
|
365
|
-
prices: { history: pricesData },
|
|
366
|
-
date: { today: dateStr },
|
|
367
|
-
math: dynamicMathContext // Injected here
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
+
for (const [key, value] of Object.entries(mathLayer)) { dynamicMathContext[key] = value; if (LEGACY_MAPPING[key]) { dynamicMathContext[LEGACY_MAPPING[key]] = value;} }
|
|
371
|
+
const context = { mappings, prices: { history: pricesData }, date: { today: dateStr }, math: dynamicMathContext };
|
|
370
372
|
for (const calcManifest of calcs) {
|
|
371
373
|
try {
|
|
372
|
-
const instance = new calcManifest.class();
|
|
373
|
-
await instance.process(context);
|
|
374
|
-
const result = await instance.getResult();
|
|
375
|
-
|
|
374
|
+
const instance = new calcManifest.class(); await instance.process(context); const result = await instance.getResult();
|
|
376
375
|
if (result && Object.keys(result).length > 0) {
|
|
377
|
-
let dataToWrite = result;
|
|
378
|
-
if (result.by_instrument) dataToWrite = result.by_instrument;
|
|
379
|
-
|
|
376
|
+
let dataToWrite = result; if (result.by_instrument) dataToWrite = result.by_instrument;
|
|
380
377
|
if (Object.keys(dataToWrite).length > 0) {
|
|
381
|
-
const docRef = db.collection(config.resultsCollection).doc(dateStr)
|
|
382
|
-
|
|
378
|
+
const docRef = db.collection(config.resultsCollection).doc(dateStr).collection(config.resultsSubcollection).doc(calcManifest.category).collection(config.computationsSubcollection).doc(normalizeName(calcManifest.name));
|
|
383
379
|
writes.push({ ref: docRef, data: { ...dataToWrite, _completed: true }, options: { merge: true } });
|
|
384
380
|
}
|
|
385
381
|
}
|
|
386
382
|
} catch (err) { logger.log('ERROR', `[BatchPrice] \u2716 Failed ${calcManifest.name} for ${dateStr}: ${err.message}`); }
|
|
387
383
|
}
|
|
388
384
|
}
|
|
389
|
-
|
|
390
385
|
if (writes.length > 0) {
|
|
391
|
-
const commitBatches = [];
|
|
392
|
-
for (let i = 0; i < writes.length; i += WRITE_BATCH_LIMIT) { commitBatches.push(writes.slice(i, i + WRITE_BATCH_LIMIT)); }
|
|
393
|
-
|
|
386
|
+
const commitBatches = []; for (let i = 0; i < writes.length; i += WRITE_BATCH_LIMIT) { commitBatches.push(writes.slice(i, i + WRITE_BATCH_LIMIT)); }
|
|
394
387
|
const commitLimit = pLimit(10);
|
|
395
|
-
|
|
396
388
|
await Promise.all(commitBatches.map((batchWrites, bIndex) => commitLimit(async () => {
|
|
397
|
-
const batch = db.batch();
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
try { await calculationUtils.withRetry(() => batch.commit(), `BatchPrice-C${index}-B${bIndex}`);
|
|
401
|
-
} catch (commitErr) { logger.log('ERROR', `[BatchPrice] Commit failed for Chunk ${index} Batch ${bIndex}.`, { error: commitErr.message }); }
|
|
389
|
+
const batch = db.batch(); batchWrites.forEach(w => batch.set(w.ref, w.data, w.options));
|
|
390
|
+
try { await calculationUtils.withRetry(() => batch.commit(), `BatchPrice-C${index}-B${bIndex}`); } catch (commitErr) { logger.log('ERROR', `[BatchPrice] Commit failed for Chunk ${index} Batch ${bIndex}.`, { error: commitErr.message }); }
|
|
402
391
|
})));
|
|
403
392
|
}
|
|
404
|
-
|
|
405
393
|
} catch (chunkErr) { logger.log('ERROR', `[BatchPrice] Fatal error processing Chunk ${index}.`, { error: chunkErr.message }); }
|
|
406
394
|
}));
|
|
407
395
|
}
|
|
408
|
-
|
|
409
396
|
await Promise.all(chunkPromises);
|
|
410
397
|
logger.log('INFO', '[BatchPrice] Optimization pass complete.');
|
|
411
398
|
}
|
|
412
399
|
|
|
413
|
-
module.exports = {
|
|
414
|
-
groupByPass,
|
|
415
|
-
checkRootDependencies,
|
|
416
|
-
checkRootDataAvailability,
|
|
417
|
-
fetchExistingResults,
|
|
418
|
-
fetchComputationStatus,
|
|
419
|
-
fetchGlobalComputationStatus,
|
|
420
|
-
updateComputationStatus,
|
|
421
|
-
runStandardComputationPass,
|
|
422
|
-
runMetaComputationPass,
|
|
423
|
-
runBatchPriceComputation
|
|
424
|
-
};
|
|
400
|
+
module.exports = { groupByPass, checkRootDependencies, checkRootDataAvailability, fetchExistingResults, fetchComputationStatus, fetchGlobalComputationStatus, updateComputationStatus, runStandardComputationPass, runMetaComputationPass, runBatchPriceComputation };
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* REFACTORED: Now stateless and receive dependencies where needed.
|
|
4
|
-
* FIXED: 'commitBatchInChunks' now respects Firestore 10MB size limit.
|
|
5
|
-
* NEW: Added 'generateCodeHash' for version control.
|
|
2
|
+
* FILENAME: computation-system/utils/utils.js
|
|
6
3
|
*/
|
|
7
4
|
|
|
8
5
|
const { FieldValue, FieldPath } = require('@google-cloud/firestore');
|
|
@@ -12,27 +9,18 @@ const crypto = require('crypto');
|
|
|
12
9
|
function normalizeName(name) { return name.replace(/_/g, '-'); }
|
|
13
10
|
|
|
14
11
|
/**
|
|
15
|
-
* Generates a SHA-256 hash of a code string
|
|
16
|
-
* This effectively versions the logic.
|
|
17
|
-
* @param {string} codeString - The source code of the function/class.
|
|
18
|
-
* @returns {string} The hex hash.
|
|
12
|
+
* Generates a SHA-256 hash of a code string.
|
|
19
13
|
*/
|
|
20
14
|
function generateCodeHash(codeString) {
|
|
21
15
|
if (!codeString) return 'unknown';
|
|
22
|
-
|
|
23
|
-
// 1. Remove single-line comments (//...)
|
|
24
16
|
let clean = codeString.replace(/\/\/.*$/gm, '');
|
|
25
|
-
// 2. Remove multi-line comments (/*...*/)
|
|
26
17
|
clean = clean.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
27
|
-
// 3. Remove all whitespace (spaces, tabs, newlines)
|
|
28
18
|
clean = clean.replace(/\s+/g, '');
|
|
29
|
-
|
|
30
19
|
return crypto.createHash('sha256').update(clean).digest('hex');
|
|
31
20
|
}
|
|
32
21
|
|
|
33
22
|
/** * Stage 2: Commit a batch of writes in chunks
|
|
34
|
-
* FIXED: Now
|
|
35
|
-
* to prevent "Request payload size exceeds the limit" errors.
|
|
23
|
+
* FIXED: Now respects write.options (e.g. { merge: false }) to allow overwrites/deletes.
|
|
36
24
|
*/
|
|
37
25
|
async function commitBatchInChunks(config, deps, writes, operationName) {
|
|
38
26
|
const { db, logger, calculationUtils } = deps;
|
|
@@ -43,17 +31,14 @@ async function commitBatchInChunks(config, deps, writes, operationName) {
|
|
|
43
31
|
return;
|
|
44
32
|
}
|
|
45
33
|
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const MAX_BATCH_BYTES = 9 * 1024 * 1024; // 9MB Safety limit (Max 10MB)
|
|
34
|
+
const MAX_BATCH_OPS = 300;
|
|
35
|
+
const MAX_BATCH_BYTES = 9 * 1024 * 1024;
|
|
49
36
|
|
|
50
37
|
let currentBatch = db.batch();
|
|
51
38
|
let currentOpsCount = 0;
|
|
52
39
|
let currentBytesEst = 0;
|
|
53
40
|
let batchIndex = 1;
|
|
54
|
-
let totalChunks = 0; // We don't know total chunks in advance now due to dynamic sizing
|
|
55
41
|
|
|
56
|
-
// Helper to commit the current batch and reset
|
|
57
42
|
const commitAndReset = async () => {
|
|
58
43
|
if (currentOpsCount > 0) {
|
|
59
44
|
try {
|
|
@@ -74,30 +59,25 @@ async function commitBatchInChunks(config, deps, writes, operationName) {
|
|
|
74
59
|
};
|
|
75
60
|
|
|
76
61
|
for (const write of writes) {
|
|
77
|
-
// 1. Estimate Size: JSON stringify is a decent proxy for Firestore payload size
|
|
78
|
-
// We handle potential circular refs or failures gracefully by assuming a minimum size
|
|
79
62
|
let docSize = 100;
|
|
80
|
-
try {
|
|
81
|
-
if (write.data) docSize = JSON.stringify(write.data).length;
|
|
82
|
-
} catch (e) { /* ignore size check error */ }
|
|
63
|
+
try { if (write.data) docSize = JSON.stringify(write.data).length; } catch (e) { }
|
|
83
64
|
|
|
84
|
-
// 2. Warn if a SINGLE document is approaching the 1MB limit
|
|
85
65
|
if (docSize > 900 * 1024) {
|
|
86
|
-
logger.log('WARN', `[${operationName}] Large document detected (~${(docSize / 1024).toFixed(2)} KB)
|
|
66
|
+
logger.log('WARN', `[${operationName}] Large document detected (~${(docSize / 1024).toFixed(2)} KB).`);
|
|
87
67
|
}
|
|
88
68
|
|
|
89
|
-
// 3. Check if adding this write would overflow the batch
|
|
90
69
|
if ((currentOpsCount + 1 > MAX_BATCH_OPS) || (currentBytesEst + docSize > MAX_BATCH_BYTES)) {
|
|
91
70
|
await commitAndReset();
|
|
92
71
|
}
|
|
93
72
|
|
|
94
|
-
//
|
|
95
|
-
|
|
73
|
+
// USE PROVIDED OPTIONS OR DEFAULT TO MERGE: TRUE
|
|
74
|
+
const options = write.options || { merge: true };
|
|
75
|
+
currentBatch.set(write.ref, write.data, options);
|
|
76
|
+
|
|
96
77
|
currentOpsCount++;
|
|
97
78
|
currentBytesEst += docSize;
|
|
98
79
|
}
|
|
99
80
|
|
|
100
|
-
// 5. Commit remaining
|
|
101
81
|
await commitAndReset();
|
|
102
82
|
}
|
|
103
83
|
|
|
@@ -112,10 +92,7 @@ function getExpectedDateStrings(startDate, endDate) {
|
|
|
112
92
|
return dateStrings;
|
|
113
93
|
}
|
|
114
94
|
|
|
115
|
-
/**
|
|
116
|
-
* --- NEW HELPER ---
|
|
117
|
-
* Stage 4: Get the earliest date in a *flat* collection where doc IDs are dates.
|
|
118
|
-
*/
|
|
95
|
+
/** Stage 4: Get the earliest date in a *flat* collection where doc IDs are dates. */
|
|
119
96
|
async function getFirstDateFromSimpleCollection(config, deps, collectionName) {
|
|
120
97
|
const { db, logger, calculationUtils } = deps;
|
|
121
98
|
const { withRetry } = calculationUtils;
|
|
@@ -149,22 +126,10 @@ async function getFirstDateFromCollection(config, deps, collectionName) {
|
|
|
149
126
|
return earliestDate;
|
|
150
127
|
}
|
|
151
128
|
|
|
152
|
-
/** *
|
|
153
|
-
* Stage 5: Determine the earliest date from *all* source data.
|
|
154
|
-
*/
|
|
129
|
+
/** Stage 5: Determine the earliest date from *all* source data. */
|
|
155
130
|
async function getEarliestDataDates(config, deps) {
|
|
156
131
|
const { logger } = deps;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const [
|
|
160
|
-
investorDate,
|
|
161
|
-
speculatorDate,
|
|
162
|
-
investorHistoryDate,
|
|
163
|
-
speculatorHistoryDate,
|
|
164
|
-
insightsDate,
|
|
165
|
-
socialDate,
|
|
166
|
-
priceDate
|
|
167
|
-
] = await Promise.all([
|
|
132
|
+
const [ investorDate, speculatorDate, investorHistoryDate, speculatorHistoryDate, insightsDate, socialDate, priceDate ] = await Promise.all([
|
|
168
133
|
getFirstDateFromCollection(config, deps, config.normalUserPortfolioCollection),
|
|
169
134
|
getFirstDateFromCollection(config, deps, config.speculatorPortfolioCollection),
|
|
170
135
|
getFirstDateFromCollection(config, deps, config.normalUserHistoryCollection),
|
|
@@ -185,17 +150,11 @@ async function getEarliestDataDates(config, deps) {
|
|
|
185
150
|
const earliestInsightsDate = getMinDate(insightsDate);
|
|
186
151
|
const earliestSocialDate = getMinDate(socialDate);
|
|
187
152
|
const earliestPriceDate = getMinDate(priceDate);
|
|
188
|
-
const absoluteEarliest = getMinDate(
|
|
189
|
-
earliestPortfolioDate,
|
|
190
|
-
earliestHistoryDate,
|
|
191
|
-
earliestInsightsDate,
|
|
192
|
-
earliestSocialDate,
|
|
193
|
-
earliestPriceDate
|
|
194
|
-
);
|
|
153
|
+
const absoluteEarliest = getMinDate(earliestPortfolioDate, earliestHistoryDate, earliestInsightsDate, earliestSocialDate, earliestPriceDate);
|
|
195
154
|
|
|
196
155
|
const fallbackDate = new Date(config.earliestComputationDate + 'T00:00:00Z' || '2023-01-01T00:00:00Z');
|
|
197
156
|
|
|
198
|
-
|
|
157
|
+
return {
|
|
199
158
|
portfolio: earliestPortfolioDate || new Date('2999-12-31T00:00:00Z'),
|
|
200
159
|
history: earliestHistoryDate || new Date('2999-12-31T00:00:00Z'),
|
|
201
160
|
insights: earliestInsightsDate || new Date('2999-12-31T00:00:00Z'),
|
|
@@ -203,71 +162,26 @@ async function getEarliestDataDates(config, deps) {
|
|
|
203
162
|
price: earliestPriceDate || new Date('2999-12-31T00:00:00Z'),
|
|
204
163
|
absoluteEarliest: absoluteEarliest || fallbackDate
|
|
205
164
|
};
|
|
206
|
-
|
|
207
|
-
logger.log('INFO', 'Earliest data availability map built:', {
|
|
208
|
-
portfolio: result.portfolio.toISOString().slice(0, 10),
|
|
209
|
-
history: result.history.toISOString().slice(0, 10),
|
|
210
|
-
insights: result.insights.toISOString().slice(0, 10),
|
|
211
|
-
social: result.social.toISOString().slice(0, 10),
|
|
212
|
-
price: result.price.toISOString().slice(0, 10),
|
|
213
|
-
absoluteEarliest: result.absoluteEarliest.toISOString().slice(0, 10)
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
return result;
|
|
217
165
|
}
|
|
218
166
|
|
|
219
|
-
/**
|
|
220
|
-
* NEW HELPER: Get the earliest date from price collection
|
|
221
|
-
*/
|
|
222
167
|
async function getFirstDateFromPriceCollection(config, deps) {
|
|
223
168
|
const { db, logger, calculationUtils } = deps;
|
|
224
169
|
const { withRetry } = calculationUtils;
|
|
225
170
|
const collection = config.priceCollection || 'asset_prices';
|
|
226
|
-
|
|
227
171
|
try {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const snapshot = await withRetry(
|
|
231
|
-
() => db.collection(collection).limit(10).get(),
|
|
232
|
-
`GetPriceShards(${collection})`
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
if (snapshot.empty) {
|
|
236
|
-
logger.log('WARN', `No price shards found in ${collection}`);
|
|
237
|
-
return null;
|
|
238
|
-
}
|
|
239
|
-
|
|
172
|
+
const snapshot = await withRetry(() => db.collection(collection).limit(10).get(), `GetPriceShards(${collection})`);
|
|
240
173
|
let earliestDate = null;
|
|
241
|
-
|
|
242
174
|
snapshot.forEach(doc => {
|
|
243
175
|
const shardData = doc.data();
|
|
244
176
|
for (const instrumentId in shardData) {
|
|
245
177
|
const instrumentData = shardData[instrumentId];
|
|
246
178
|
if (!instrumentData.prices) continue;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
.filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d))
|
|
250
|
-
.sort();
|
|
251
|
-
|
|
252
|
-
if (dates.length > 0) {
|
|
253
|
-
const firstDate = new Date(dates[0] + 'T00:00:00Z');
|
|
254
|
-
if (!earliestDate || firstDate < earliestDate) {
|
|
255
|
-
earliestDate = firstDate;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
179
|
+
const dates = Object.keys(instrumentData.prices).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort();
|
|
180
|
+
if (dates.length > 0) { const firstDate = new Date(dates[0] + 'T00:00:00Z'); if (!earliestDate || firstDate < earliestDate) earliestDate = firstDate; }
|
|
258
181
|
}
|
|
259
182
|
});
|
|
260
|
-
|
|
261
|
-
if (earliestDate) {
|
|
262
|
-
logger.log('TRACE', `[getFirstDateFromPriceCollection] Earliest price date: ${earliestDate.toISOString().slice(0, 10)}`);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
183
|
return earliestDate;
|
|
266
|
-
|
|
267
|
-
} catch (e) {
|
|
268
|
-
logger.log('ERROR', `Failed to get earliest price date from ${collection}`, { errorMessage: e.message });
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
184
|
+
} catch (e) { logger.log('ERROR', `Failed to get earliest price date from ${collection}`, { errorMessage: e.message }); return null; }
|
|
271
185
|
}
|
|
272
186
|
|
|
273
187
|
module.exports = { FieldValue, FieldPath, normalizeName, commitBatchInChunks, getExpectedDateStrings, getEarliestDataDates, generateCodeHash };
|