bulltrackers-module 1.0.306 → 1.0.308
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 +100 -212
- package/functions/computation-system/helpers/computation_worker.js +56 -267
- package/functions/computation-system/utils/utils.js +54 -171
- package/package.json +1 -1
- package/functions/computation-system/features.md +0 -395
- package/functions/computation-system/paper.md +0 -93
|
@@ -1,247 +1,130 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FILENAME: computation-system/utils/utils.js
|
|
3
|
+
* UPDATED: Removed hardcoded date logic. Earliest dates are now fully data-driven.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
const { FieldValue, FieldPath } = require('@google-cloud/firestore');
|
|
6
7
|
const crypto = require('crypto');
|
|
7
8
|
|
|
8
|
-
// [
|
|
9
|
-
//
|
|
9
|
+
// [UPDATED] Registry for Data Availability.
|
|
10
|
+
// Populated dynamically by getEarliestDataDates().
|
|
10
11
|
const DEFINITIVE_EARLIEST_DATES = {
|
|
11
|
-
portfolio:
|
|
12
|
-
history:
|
|
13
|
-
social:
|
|
14
|
-
insights:
|
|
15
|
-
price:
|
|
16
|
-
absoluteEarliest:
|
|
12
|
+
portfolio: null,
|
|
13
|
+
history: null,
|
|
14
|
+
social: null,
|
|
15
|
+
insights: null,
|
|
16
|
+
price: null,
|
|
17
|
+
absoluteEarliest: null
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
+
/** Normalizes a calculation name to kebab-case */
|
|
20
21
|
function normalizeName(name) { return name.replace(/_/g, '-'); }
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
* Generates a SHA-256 hash of a code string.
|
|
24
|
-
*/
|
|
23
|
+
/** Generates a SHA-256 hash of a code string. */
|
|
25
24
|
function generateCodeHash(codeString) {
|
|
26
25
|
if (!codeString) return 'unknown';
|
|
27
|
-
let clean = codeString.replace(/\/\/.*$/gm, '');
|
|
28
|
-
clean = clean.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
29
|
-
clean = clean.replace(/\s+/g, '');
|
|
26
|
+
let clean = codeString.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, '');
|
|
30
27
|
return crypto.createHash('sha256').update(clean).digest('hex');
|
|
31
28
|
}
|
|
32
29
|
|
|
33
|
-
/**
|
|
34
|
-
* [NEW] Generates a stable SHA-256 hash of a data object.
|
|
35
|
-
* Keys are sorted to ensure determinism.
|
|
36
|
-
*/
|
|
30
|
+
/** Generates a stable SHA-256 hash of a data object. */
|
|
37
31
|
function generateDataHash(data) {
|
|
38
32
|
if (data === undefined) return 'undefined';
|
|
39
|
-
|
|
40
|
-
// Recursive stable stringify
|
|
41
33
|
const stableStringify = (obj) => {
|
|
42
|
-
if (typeof obj !== 'object' || obj === null)
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
if (Array.isArray(obj)) {
|
|
46
|
-
return '[' + obj.map(stableStringify).join(',') + ']';
|
|
47
|
-
}
|
|
48
|
-
return '{' + Object.keys(obj).sort().map(k =>
|
|
49
|
-
JSON.stringify(k) + ':' + stableStringify(obj[k])
|
|
50
|
-
).join(',') + '}';
|
|
34
|
+
if (typeof obj !== 'object' || obj === null) return JSON.stringify(obj);
|
|
35
|
+
if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
|
|
36
|
+
return '{' + Object.keys(obj).sort().map(k => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}';
|
|
51
37
|
};
|
|
52
|
-
|
|
53
38
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
} catch (e) {
|
|
57
|
-
return 'hash_error';
|
|
58
|
-
}
|
|
39
|
+
return crypto.createHash('sha256').update(stableStringify(data)).digest('hex');
|
|
40
|
+
} catch (e) { return 'hash_error'; }
|
|
59
41
|
}
|
|
60
42
|
|
|
61
|
-
/**
|
|
62
|
-
* Executes a function with exponential backoff retry logic.
|
|
63
|
-
* @param {Function} fn - Async function to execute
|
|
64
|
-
* @param {string} operationName - Label for logging
|
|
65
|
-
* @param {number} maxRetries - Max attempts (default 3)
|
|
66
|
-
*/
|
|
43
|
+
/** Exponential backoff retry logic. */
|
|
67
44
|
async function withRetry(fn, operationName, maxRetries = 3) {
|
|
68
45
|
let attempt = 0;
|
|
69
46
|
while (attempt < maxRetries) {
|
|
70
|
-
try {
|
|
71
|
-
return await fn();
|
|
72
|
-
} catch (error) {
|
|
47
|
+
try { return await fn(); } catch (error) {
|
|
73
48
|
attempt++;
|
|
74
|
-
console.warn(`[Retry] ${operationName} failed (Attempt ${attempt}/${maxRetries}): ${error.message}`);
|
|
75
49
|
if (attempt >= maxRetries) throw error;
|
|
76
|
-
// Exponential backoff: 1s, 2s, 4s...
|
|
77
50
|
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1)));
|
|
78
51
|
}
|
|
79
52
|
}
|
|
80
53
|
}
|
|
81
54
|
|
|
82
|
-
/**
|
|
83
|
-
* UPDATED: Now supports { type: 'DELETE' } in the write object.
|
|
84
|
-
*/
|
|
55
|
+
/** Commit a batch of writes in chunks. */
|
|
85
56
|
async function commitBatchInChunks(config, deps, writes, operationName) {
|
|
86
57
|
const { db, logger } = deps;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (!writes || !writes.length) {
|
|
91
|
-
logger.log('WARN', `[${operationName}] No writes to commit.`);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const MAX_BATCH_OPS = 300;
|
|
96
|
-
const MAX_BATCH_BYTES = 9 * 1024 * 1024;
|
|
58
|
+
const retryFn = (deps.calculationUtils?.withRetry) || withRetry;
|
|
59
|
+
if (!writes?.length) return;
|
|
97
60
|
|
|
98
|
-
|
|
99
|
-
let currentOpsCount = 0;
|
|
100
|
-
let currentBytesEst = 0;
|
|
101
|
-
let batchIndex = 1;
|
|
61
|
+
const MAX_BATCH_OPS = 300, MAX_BATCH_BYTES = 9 * 1024 * 1024;
|
|
62
|
+
let currentBatch = db.batch(), currentOpsCount = 0, currentBytesEst = 0, batchIndex = 1;
|
|
102
63
|
|
|
103
64
|
const commitAndReset = async () => {
|
|
104
65
|
if (currentOpsCount > 0) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
() => currentBatch.commit(),
|
|
108
|
-
`${operationName} (Chunk ${batchIndex})`
|
|
109
|
-
);
|
|
110
|
-
logger.log('INFO', `[${operationName}] Committed chunk ${batchIndex} (${currentOpsCount} ops, ~${(currentBytesEst / 1024 / 1024).toFixed(2)} MB).`);
|
|
111
|
-
batchIndex++;
|
|
112
|
-
} catch (err) {
|
|
113
|
-
logger.log('ERROR', `[${operationName}] Failed to commit chunk ${batchIndex}. Size: ${(currentBytesEst / 1024 / 1024).toFixed(2)} MB.`, { error: err.message });
|
|
114
|
-
throw err;
|
|
115
|
-
}
|
|
66
|
+
await retryFn(() => currentBatch.commit(), `${operationName} (Chunk ${batchIndex})`);
|
|
67
|
+
batchIndex++;
|
|
116
68
|
}
|
|
117
|
-
currentBatch = db.batch();
|
|
118
|
-
currentOpsCount = 0;
|
|
119
|
-
currentBytesEst = 0;
|
|
69
|
+
currentBatch = db.batch(); currentOpsCount = 0; currentBytesEst = 0;
|
|
120
70
|
};
|
|
121
71
|
|
|
122
72
|
for (const write of writes) {
|
|
123
|
-
// [NEW] Handle DELETE operations
|
|
124
73
|
if (write.type === 'DELETE') {
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
currentBatch.delete(write.ref);
|
|
129
|
-
currentOpsCount++;
|
|
130
|
-
continue;
|
|
74
|
+
if (currentOpsCount + 1 > MAX_BATCH_OPS) await commitAndReset();
|
|
75
|
+
currentBatch.delete(write.ref); currentOpsCount++; continue;
|
|
131
76
|
}
|
|
132
|
-
|
|
133
|
-
// Standard SET/UPDATE operations
|
|
134
77
|
let docSize = 100;
|
|
135
78
|
try { if (write.data) docSize = JSON.stringify(write.data).length; } catch (e) { }
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if ((currentOpsCount + 1 > MAX_BATCH_OPS) || (currentBytesEst + docSize > MAX_BATCH_BYTES)) {
|
|
142
|
-
await commitAndReset();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// USE PROVIDED OPTIONS OR DEFAULT TO MERGE: TRUE
|
|
146
|
-
const options = write.options || { merge: true };
|
|
147
|
-
currentBatch.set(write.ref, write.data, options);
|
|
148
|
-
|
|
149
|
-
currentOpsCount++;
|
|
150
|
-
currentBytesEst += docSize;
|
|
79
|
+
if ((currentOpsCount + 1 > MAX_BATCH_OPS) || (currentBytesEst + docSize > MAX_BATCH_BYTES)) await commitAndReset();
|
|
80
|
+
currentBatch.set(write.ref, write.data, write.options || { merge: true });
|
|
81
|
+
currentOpsCount++; currentBytesEst += docSize;
|
|
151
82
|
}
|
|
152
|
-
|
|
153
83
|
await commitAndReset();
|
|
154
84
|
}
|
|
155
85
|
|
|
156
|
-
/**
|
|
86
|
+
/** Generate array of date strings between two dates. */
|
|
157
87
|
function getExpectedDateStrings(startDate, endDate) {
|
|
158
88
|
const dateStrings = [];
|
|
159
|
-
if (startDate <= endDate) {
|
|
89
|
+
if (startDate && endDate && startDate <= endDate) {
|
|
160
90
|
const startUTC = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate()));
|
|
161
|
-
|
|
162
|
-
for (let d = startUTC; d <= endUTC; d.setUTCDate(d.getUTCDate() + 1)) { dateStrings.push(new Date(d).toISOString().slice(0, 10)); }
|
|
91
|
+
for (let d = startUTC; d <= endDate; d.setUTCDate(d.getUTCDate() + 1)) { dateStrings.push(new Date(d).toISOString().slice(0, 10)); }
|
|
163
92
|
}
|
|
164
93
|
return dateStrings;
|
|
165
94
|
}
|
|
166
95
|
|
|
167
|
-
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
96
|
+
/**
|
|
97
|
+
* [UPDATED] Single Source of Truth for Data Availability.
|
|
98
|
+
* Updates the global DEFINITIVE_EARLIEST_DATES object.
|
|
170
99
|
*/
|
|
171
100
|
async function getEarliestDataDates(config, deps) {
|
|
172
101
|
const { db, logger } = deps;
|
|
173
|
-
|
|
174
|
-
const indexCollection = process.env.ROOT_DATA_AVAILABILITY_COLLECTION ||
|
|
175
|
-
config.rootDataAvailabilityCollection ||
|
|
176
|
-
'system_root_data_index';
|
|
102
|
+
const indexCollection = process.env.ROOT_DATA_AVAILABILITY_COLLECTION || 'system_root_data_index';
|
|
177
103
|
|
|
178
|
-
// Helper to find earliest date where a specific flag is true
|
|
179
|
-
// Efficient: Uses the document ID (date string) index.
|
|
180
104
|
const getEarliestForType = async (flagName) => {
|
|
181
105
|
try {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
.limit(1)
|
|
186
|
-
.get();
|
|
187
|
-
|
|
188
|
-
if (!snapshot.empty) {
|
|
189
|
-
const dateStr = snapshot.docs[0].id; // YYYY-MM-DD
|
|
190
|
-
// Safety check on date format
|
|
191
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
|
192
|
-
return new Date(dateStr + 'T00:00:00Z');
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
} catch (e) {
|
|
196
|
-
logger.log('WARN', `[Utils] Failed to query index for ${flagName}: ${e.message}`);
|
|
197
|
-
}
|
|
106
|
+
const snap = await db.collection(indexCollection).where(flagName, '==', true).orderBy(FieldPath.documentId(), 'asc').limit(1).get();
|
|
107
|
+
if (!snap.empty) return new Date(snap.docs[0].id + 'T00:00:00Z');
|
|
108
|
+
} catch (e) { logger.log('WARN', `[Utils] Index query failed for ${flagName}`); }
|
|
198
109
|
return null;
|
|
199
110
|
};
|
|
200
111
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
getEarliestForType('
|
|
204
|
-
getEarliestForType('hasHistory'),
|
|
205
|
-
getEarliestForType('hasSocial'),
|
|
206
|
-
getEarliestForType('hasInsights'),
|
|
207
|
-
getEarliestForType('hasPrices')
|
|
112
|
+
const [portfolio, history, social, insights, price] = await Promise.all([
|
|
113
|
+
getEarliestForType('hasPortfolio'), getEarliestForType('hasHistory'),
|
|
114
|
+
getEarliestForType('hasSocial'), getEarliestForType('hasInsights'), getEarliestForType('hasPrices')
|
|
208
115
|
]);
|
|
209
116
|
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
let absoluteEarliest = null;
|
|
214
|
-
if (foundDates.length > 0) {
|
|
215
|
-
absoluteEarliest = new Date(Math.min(...foundDates));
|
|
216
|
-
} else {
|
|
217
|
-
// Fallback if index is empty
|
|
218
|
-
const configStart = config.earliestComputationDate || '2023-01-01';
|
|
219
|
-
absoluteEarliest = new Date(configStart + 'T00:00:00Z');
|
|
220
|
-
logger.log('WARN', `[Utils] No data found in Root Data Index (${indexCollection}). Defaulting to ${configStart}`);
|
|
221
|
-
}
|
|
117
|
+
const found = [portfolio, history, social, insights, price].filter(d => d !== null);
|
|
118
|
+
const absoluteEarliest = found.length > 0 ? new Date(Math.min(...found)) : new Date('2023-01-01T00:00:00Z');
|
|
222
119
|
|
|
223
|
-
//
|
|
224
|
-
DEFINITIVE_EARLIEST_DATES
|
|
120
|
+
// Sync the global registry
|
|
121
|
+
Object.assign(DEFINITIVE_EARLIEST_DATES, { portfolio, history, social, insights, price, absoluteEarliest });
|
|
225
122
|
|
|
226
|
-
return
|
|
227
|
-
portfolio: portfolioDate || new Date('2999-12-31T00:00:00Z'),
|
|
228
|
-
history: historyDate || new Date('2999-12-31T00:00:00Z'),
|
|
229
|
-
insights: insightsDate || new Date('2999-12-31T00:00:00Z'),
|
|
230
|
-
social: socialDate || new Date('2999-12-31T00:00:00Z'),
|
|
231
|
-
price: priceDate || new Date('2999-12-31T00:00:00Z'),
|
|
232
|
-
absoluteEarliest: absoluteEarliest
|
|
233
|
-
};
|
|
123
|
+
return DEFINITIVE_EARLIEST_DATES;
|
|
234
124
|
}
|
|
235
125
|
|
|
236
126
|
module.exports = {
|
|
237
|
-
FieldValue,
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
commitBatchInChunks,
|
|
241
|
-
getExpectedDateStrings,
|
|
242
|
-
getEarliestDataDates,
|
|
243
|
-
generateCodeHash,
|
|
244
|
-
generateDataHash,
|
|
245
|
-
withRetry,
|
|
246
|
-
DEFINITIVE_EARLIEST_DATES
|
|
127
|
+
FieldValue, FieldPath, normalizeName, commitBatchInChunks,
|
|
128
|
+
getExpectedDateStrings, getEarliestDataDates, generateCodeHash,
|
|
129
|
+
generateDataHash, withRetry, DEFINITIVE_EARLIEST_DATES
|
|
247
130
|
};
|