bulltrackers-module 1.0.733 → 1.0.734
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-v2/README.md +152 -0
- package/functions/computation-system-v2/computations/PopularInvestorProfileMetrics.js +720 -0
- package/functions/computation-system-v2/computations/PopularInvestorRiskAssessment.js +176 -0
- package/functions/computation-system-v2/computations/PopularInvestorRiskMetrics.js +294 -0
- package/functions/computation-system-v2/computations/TestComputation.js +46 -0
- package/functions/computation-system-v2/computations/UserPortfolioSummary.js +172 -0
- package/functions/computation-system-v2/config/bulltrackers.config.js +317 -0
- package/functions/computation-system-v2/framework/core/Computation.js +73 -0
- package/functions/computation-system-v2/framework/core/Manifest.js +223 -0
- package/functions/computation-system-v2/framework/core/RuleInjector.js +53 -0
- package/functions/computation-system-v2/framework/core/Rules.js +231 -0
- package/functions/computation-system-v2/framework/core/RunAnalyzer.js +163 -0
- package/functions/computation-system-v2/framework/cost/CostTracker.js +154 -0
- package/functions/computation-system-v2/framework/data/DataFetcher.js +399 -0
- package/functions/computation-system-v2/framework/data/QueryBuilder.js +232 -0
- package/functions/computation-system-v2/framework/data/SchemaRegistry.js +287 -0
- package/functions/computation-system-v2/framework/execution/Orchestrator.js +498 -0
- package/functions/computation-system-v2/framework/execution/TaskRunner.js +35 -0
- package/functions/computation-system-v2/framework/execution/middleware/CostTrackerMiddleware.js +32 -0
- package/functions/computation-system-v2/framework/execution/middleware/LineageMiddleware.js +32 -0
- package/functions/computation-system-v2/framework/execution/middleware/Middleware.js +14 -0
- package/functions/computation-system-v2/framework/execution/middleware/ProfilerMiddleware.js +47 -0
- package/functions/computation-system-v2/framework/index.js +45 -0
- package/functions/computation-system-v2/framework/lineage/LineageTracker.js +147 -0
- package/functions/computation-system-v2/framework/monitoring/Profiler.js +80 -0
- package/functions/computation-system-v2/framework/resilience/Checkpointer.js +66 -0
- package/functions/computation-system-v2/framework/scheduling/ScheduleValidator.js +327 -0
- package/functions/computation-system-v2/framework/storage/StateRepository.js +286 -0
- package/functions/computation-system-v2/framework/storage/StorageManager.js +469 -0
- package/functions/computation-system-v2/framework/storage/index.js +9 -0
- package/functions/computation-system-v2/framework/testing/ComputationTester.js +86 -0
- package/functions/computation-system-v2/framework/utils/Graph.js +205 -0
- package/functions/computation-system-v2/handlers/dispatcher.js +109 -0
- package/functions/computation-system-v2/handlers/index.js +23 -0
- package/functions/computation-system-v2/handlers/onDemand.js +289 -0
- package/functions/computation-system-v2/handlers/scheduler.js +327 -0
- package/functions/computation-system-v2/index.js +163 -0
- package/functions/computation-system-v2/rules/index.js +49 -0
- package/functions/computation-system-v2/rules/instruments.js +465 -0
- package/functions/computation-system-v2/rules/metrics.js +304 -0
- package/functions/computation-system-v2/rules/portfolio.js +534 -0
- package/functions/computation-system-v2/rules/rankings.js +655 -0
- package/functions/computation-system-v2/rules/social.js +562 -0
- package/functions/computation-system-v2/rules/trades.js +545 -0
- package/functions/computation-system-v2/scripts/migrate-sectors.js +73 -0
- package/functions/computation-system-v2/test/test-dispatcher.js +317 -0
- package/functions/computation-system-v2/test/test-framework.js +500 -0
- package/functions/computation-system-v2/test/test-real-execution.js +166 -0
- package/functions/computation-system-v2/test/test-real-integration.js +194 -0
- package/functions/computation-system-v2/test/test-refactor-e2e.js +131 -0
- package/functions/computation-system-v2/test/test-results.json +31 -0
- package/functions/computation-system-v2/test/test-risk-metrics-computation.js +329 -0
- package/functions/computation-system-v2/test/test-scheduler.js +204 -0
- package/functions/computation-system-v2/test/test-storage.js +449 -0
- package/functions/orchestrator/index.js +18 -26
- package/package.json +3 -2
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview StorageManager - Unified storage for computation results
|
|
3
|
+
* Includes Optimized Checkpointing and Performance Monitoring.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { Firestore } = require('@google-cloud/firestore');
|
|
7
|
+
const { BigQuery } = require('@google-cloud/bigquery');
|
|
8
|
+
const pLimit = require('p-limit');
|
|
9
|
+
|
|
10
|
+
class StorageManager {
|
|
11
|
+
constructor(config, logger = null) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.logger = logger;
|
|
14
|
+
|
|
15
|
+
this.bigquery = new BigQuery({
|
|
16
|
+
projectId: config.bigquery?.projectId,
|
|
17
|
+
location: config.bigquery?.location || 'EU'
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
this._firestore = null;
|
|
21
|
+
this.tableExists = new Map();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get firestore() {
|
|
25
|
+
if (!this._firestore) {
|
|
26
|
+
this._firestore = new Firestore({
|
|
27
|
+
projectId: this.config.bigquery?.projectId
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return this._firestore;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async commitResults(dateStr, entry, results, depResultHashes = {}) {
|
|
34
|
+
const storageConfig = this._resolveStorageConfig(entry);
|
|
35
|
+
const writeResults = { bigquery: null, firestore: null };
|
|
36
|
+
const startTime = Date.now();
|
|
37
|
+
|
|
38
|
+
if (storageConfig.bigquery !== false) {
|
|
39
|
+
try {
|
|
40
|
+
writeResults.bigquery = await this._writeToBigQuery(
|
|
41
|
+
dateStr, entry, results, depResultHashes
|
|
42
|
+
);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
this._log('ERROR', `BigQuery write failed for ${entry.name}: ${error.message}`);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (storageConfig.firestore?.enabled) {
|
|
50
|
+
try {
|
|
51
|
+
writeResults.firestore = await this._writeToFirestore(
|
|
52
|
+
dateStr, entry, results, storageConfig.firestore
|
|
53
|
+
);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
this._log('WARN', `Firestore write failed for ${entry.name}: ${error.message}`);
|
|
56
|
+
writeResults.firestore = { error: error.message };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const duration = Date.now() - startTime;
|
|
61
|
+
this._log('INFO', `Committed ${entry.name} results in ${duration}ms`);
|
|
62
|
+
return writeResults;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// =========================================================================
|
|
66
|
+
// OPTIMIZED CHECKPOINTING (Lightweight State)
|
|
67
|
+
// =========================================================================
|
|
68
|
+
|
|
69
|
+
async initCheckpoint(dateStr, computationName, checkpointId, totalEntities) {
|
|
70
|
+
const table = 'computation_checkpoints';
|
|
71
|
+
const dataset = this.config.bigquery.dataset;
|
|
72
|
+
|
|
73
|
+
await this._ensureCheckpointTable(table);
|
|
74
|
+
|
|
75
|
+
const row = {
|
|
76
|
+
date: dateStr,
|
|
77
|
+
computation_name: computationName,
|
|
78
|
+
checkpoint_id: checkpointId,
|
|
79
|
+
status: 'running',
|
|
80
|
+
processed_count: 0,
|
|
81
|
+
total_entities: totalEntities,
|
|
82
|
+
last_entity_id: null,
|
|
83
|
+
completed_batches: [],
|
|
84
|
+
// FIX: Use instance method, remove 'new', pass Date object
|
|
85
|
+
started_at: this.bigquery.timestamp(new Date()),
|
|
86
|
+
last_updated: this.bigquery.timestamp(new Date())
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Standard insert for initialization
|
|
90
|
+
await this.bigquery.dataset(dataset).table(table).insert([row]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async updateCheckpoint(dateStr, computationName, checkpointId, state) {
|
|
94
|
+
const table = 'computation_checkpoints';
|
|
95
|
+
const fullTable = `\`${this.config.bigquery.projectId}.${this.config.bigquery.dataset}.${table}\``;
|
|
96
|
+
|
|
97
|
+
// Efficient UPDATE using structured fields
|
|
98
|
+
const query = `
|
|
99
|
+
UPDATE ${fullTable}
|
|
100
|
+
SET
|
|
101
|
+
processed_count = @processedCount,
|
|
102
|
+
last_entity_id = @lastEntityId,
|
|
103
|
+
completed_batches = ARRAY_CONCAT(completed_batches, [@batchIndex]),
|
|
104
|
+
last_updated = CURRENT_TIMESTAMP()
|
|
105
|
+
WHERE date = @date
|
|
106
|
+
AND computation_name = @computationName
|
|
107
|
+
AND checkpoint_id = @checkpointId
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
await this.bigquery.query({
|
|
111
|
+
query,
|
|
112
|
+
params: {
|
|
113
|
+
date: dateStr,
|
|
114
|
+
computationName,
|
|
115
|
+
checkpointId,
|
|
116
|
+
processedCount: state.processedCount,
|
|
117
|
+
lastEntityId: state.lastEntityId,
|
|
118
|
+
batchIndex: state.batchIndex // Assuming we add one batch at a time
|
|
119
|
+
},
|
|
120
|
+
location: this.config.bigquery.location
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async completeCheckpoint(dateStr, computationName, checkpointId) {
|
|
125
|
+
const table = 'computation_checkpoints';
|
|
126
|
+
const fullTable = `\`${this.config.bigquery.projectId}.${this.config.bigquery.dataset}.${table}\``;
|
|
127
|
+
|
|
128
|
+
const query = `
|
|
129
|
+
UPDATE ${fullTable}
|
|
130
|
+
SET status = 'completed', last_updated = CURRENT_TIMESTAMP()
|
|
131
|
+
WHERE date = @date AND computation_name = @computationName AND checkpoint_id = @checkpointId
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
await this.bigquery.query({
|
|
135
|
+
query,
|
|
136
|
+
params: { date: dateStr, computationName, checkpointId },
|
|
137
|
+
location: this.config.bigquery.location
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async getLatestCheckpoint(dateStr, computationName) {
|
|
142
|
+
const table = 'computation_checkpoints';
|
|
143
|
+
const fullTable = `\`${this.config.bigquery.projectId}.${this.config.bigquery.dataset}.${table}\``;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
// Get the most recent attempt
|
|
147
|
+
const query = `
|
|
148
|
+
SELECT checkpoint_id, status, processed_count, last_entity_id, completed_batches
|
|
149
|
+
FROM ${fullTable}
|
|
150
|
+
WHERE date = @date AND computation_name = @computationName
|
|
151
|
+
ORDER BY started_at DESC
|
|
152
|
+
LIMIT 1
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
const [rows] = await this.bigquery.query({
|
|
156
|
+
query,
|
|
157
|
+
params: { date: dateStr, computationName },
|
|
158
|
+
location: this.config.bigquery.location
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (rows.length === 0) return null;
|
|
162
|
+
return rows[0];
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return null; // Table might not exist yet
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async _ensureCheckpointTable(tableName) {
|
|
169
|
+
if (this.tableExists.get(tableName)) return;
|
|
170
|
+
const dataset = this.bigquery.dataset(this.config.bigquery.dataset);
|
|
171
|
+
const table = dataset.table(tableName);
|
|
172
|
+
const [exists] = await table.exists();
|
|
173
|
+
|
|
174
|
+
if (!exists) {
|
|
175
|
+
await dataset.createTable(tableName, {
|
|
176
|
+
schema: [
|
|
177
|
+
{ name: 'date', type: 'DATE', mode: 'REQUIRED' },
|
|
178
|
+
{ name: 'computation_name', type: 'STRING', mode: 'REQUIRED' },
|
|
179
|
+
{ name: 'checkpoint_id', type: 'STRING', mode: 'REQUIRED' },
|
|
180
|
+
{ name: 'status', type: 'STRING', mode: 'REQUIRED' }, // running, completed, failed
|
|
181
|
+
{ name: 'processed_count', type: 'INTEGER', mode: 'NULLABLE' },
|
|
182
|
+
{ name: 'total_entities', type: 'INTEGER', mode: 'NULLABLE' },
|
|
183
|
+
{ name: 'last_entity_id', type: 'STRING', mode: 'NULLABLE' },
|
|
184
|
+
{ name: 'completed_batches', type: 'INTEGER', mode: 'REPEATED' }, // ARRAY<INT64>
|
|
185
|
+
{ name: 'started_at', type: 'TIMESTAMP', mode: 'REQUIRED' },
|
|
186
|
+
{ name: 'last_updated', type: 'TIMESTAMP', mode: 'REQUIRED' }
|
|
187
|
+
],
|
|
188
|
+
timePartitioning: { type: 'DAY', field: 'date' },
|
|
189
|
+
clustering: { fields: ['computation_name', 'status'] }
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
this.tableExists.set(tableName, true);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// =========================================================================
|
|
196
|
+
// PERFORMANCE REPORTING
|
|
197
|
+
// =========================================================================
|
|
198
|
+
|
|
199
|
+
async savePerformanceReport(dateStr, report) {
|
|
200
|
+
const table = 'computation_performance';
|
|
201
|
+
|
|
202
|
+
await this._ensurePerformanceTable(table);
|
|
203
|
+
|
|
204
|
+
const rows = report.computations.map(c => ({
|
|
205
|
+
date: dateStr,
|
|
206
|
+
computation_name: c.name,
|
|
207
|
+
entity_count: c.entityCount,
|
|
208
|
+
avg_duration_ms: c.avgDuration,
|
|
209
|
+
max_duration_ms: c.maxDuration,
|
|
210
|
+
p95_duration_ms: c.p95Duration,
|
|
211
|
+
avg_memory_delta: c.avgMemory,
|
|
212
|
+
total_duration_ms: c.totalDuration,
|
|
213
|
+
recorded_at: new Date().toISOString()
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
if (rows.length === 0) return;
|
|
217
|
+
await this.bigquery.dataset(this.config.bigquery.dataset).table(table).insert(rows);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async _ensurePerformanceTable(tableName) {
|
|
221
|
+
if (this.tableExists.get(tableName)) return;
|
|
222
|
+
const dataset = this.bigquery.dataset(this.config.bigquery.dataset);
|
|
223
|
+
const table = dataset.table(tableName);
|
|
224
|
+
const [exists] = await table.exists();
|
|
225
|
+
|
|
226
|
+
if (!exists) {
|
|
227
|
+
await dataset.createTable(tableName, {
|
|
228
|
+
schema: [
|
|
229
|
+
{ name: 'date', type: 'DATE', mode: 'REQUIRED' },
|
|
230
|
+
{ name: 'computation_name', type: 'STRING', mode: 'REQUIRED' },
|
|
231
|
+
{ name: 'entity_count', type: 'INTEGER', mode: 'NULLABLE' },
|
|
232
|
+
{ name: 'avg_duration_ms', type: 'FLOAT', mode: 'NULLABLE' },
|
|
233
|
+
{ name: 'max_duration_ms', type: 'FLOAT', mode: 'NULLABLE' },
|
|
234
|
+
{ name: 'p95_duration_ms', type: 'FLOAT', mode: 'NULLABLE' },
|
|
235
|
+
{ name: 'avg_memory_delta', type: 'FLOAT', mode: 'NULLABLE' },
|
|
236
|
+
{ name: 'total_duration_ms', type: 'FLOAT', mode: 'NULLABLE' },
|
|
237
|
+
{ name: 'recorded_at', type: 'TIMESTAMP', mode: 'REQUIRED' }
|
|
238
|
+
],
|
|
239
|
+
timePartitioning: { type: 'DAY', field: 'date' }
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
this.tableExists.set(tableName, true);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// =========================================================================
|
|
246
|
+
// INTERNAL HELPERS
|
|
247
|
+
// =========================================================================
|
|
248
|
+
|
|
249
|
+
_resolveStorageConfig(entry) {
|
|
250
|
+
const entryStorage = entry.storage || {};
|
|
251
|
+
return {
|
|
252
|
+
bigquery: entryStorage.bigquery !== false,
|
|
253
|
+
firestore: {
|
|
254
|
+
enabled: entryStorage.firestore?.enabled === true,
|
|
255
|
+
path: entryStorage.firestore?.path || null,
|
|
256
|
+
merge: entryStorage.firestore?.merge || false,
|
|
257
|
+
includeMetadata: entryStorage.firestore?.includeMetadata !== false
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async _writeToBigQuery(dateStr, entry, results, depResultHashes) {
|
|
263
|
+
const table = this.config.resultStore?.table || 'computation_results';
|
|
264
|
+
const fullTable = `\`${this.config.bigquery.projectId}.${this.config.bigquery.dataset}.${table}\``;
|
|
265
|
+
|
|
266
|
+
const rows = this._buildBigQueryRows(dateStr, entry, results, depResultHashes);
|
|
267
|
+
if (rows.length === 0) return { rowCount: 0 };
|
|
268
|
+
|
|
269
|
+
await this._ensureBigQueryTable(table);
|
|
270
|
+
|
|
271
|
+
const batchSize = this.config.execution?.insertBatchSize || 500;
|
|
272
|
+
for (let i = 0; i < rows.length; i += batchSize) {
|
|
273
|
+
const batch = rows.slice(i, i + batchSize);
|
|
274
|
+
await this._upsertBatch(fullTable, batch);
|
|
275
|
+
}
|
|
276
|
+
return { rowCount: rows.length };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_buildBigQueryRows(dateStr, entry, results, depResultHashes) {
|
|
280
|
+
const rows = [];
|
|
281
|
+
const timestamp = new Date().toISOString();
|
|
282
|
+
const depResultHashesJson = JSON.stringify(depResultHashes);
|
|
283
|
+
|
|
284
|
+
if (entry.type === 'per-entity' && typeof results === 'object') {
|
|
285
|
+
for (const [entityId, data] of Object.entries(results)) {
|
|
286
|
+
rows.push({
|
|
287
|
+
date: dateStr,
|
|
288
|
+
computation_name: entry.name,
|
|
289
|
+
category: entry.category || 'uncategorized',
|
|
290
|
+
entity_id: String(entityId),
|
|
291
|
+
code_hash: entry.hash,
|
|
292
|
+
result_hash: this._hashResult(data),
|
|
293
|
+
dependency_result_hashes: depResultHashesJson,
|
|
294
|
+
entity_count: 1,
|
|
295
|
+
result_data: JSON.stringify(data),
|
|
296
|
+
updated_at: timestamp
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
rows.push({
|
|
301
|
+
date: dateStr,
|
|
302
|
+
computation_name: entry.name,
|
|
303
|
+
category: entry.category || 'uncategorized',
|
|
304
|
+
entity_id: '_global',
|
|
305
|
+
code_hash: entry.hash,
|
|
306
|
+
result_hash: this._hashResult(results),
|
|
307
|
+
dependency_result_hashes: depResultHashesJson,
|
|
308
|
+
entity_count: typeof results === 'object' ? Object.keys(results).length : 1,
|
|
309
|
+
result_data: JSON.stringify(results),
|
|
310
|
+
updated_at: timestamp
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return rows;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async _upsertBatch(fullTable, rows) {
|
|
317
|
+
const dates = rows.map(r => r.date);
|
|
318
|
+
const names = rows.map(r => r.computation_name);
|
|
319
|
+
const entityIds = rows.map(r => r.entity_id);
|
|
320
|
+
const codeHashes = rows.map(r => r.code_hash);
|
|
321
|
+
const resultHashes = rows.map(r => r.result_hash);
|
|
322
|
+
const depHashes = rows.map(r => r.dependency_result_hashes);
|
|
323
|
+
const entityCounts = rows.map(r => r.entity_count);
|
|
324
|
+
const resultData = rows.map(r => r.result_data);
|
|
325
|
+
const updatedAts = rows.map(r => r.updated_at);
|
|
326
|
+
|
|
327
|
+
const mergeQuery = `
|
|
328
|
+
MERGE INTO ${fullTable} T
|
|
329
|
+
USING (
|
|
330
|
+
SELECT
|
|
331
|
+
PARSE_DATE('%Y-%m-%d', date) as date,
|
|
332
|
+
computation_name, entity_id, code_hash, result_hash,
|
|
333
|
+
dependency_result_hashes, entity_count, result_data,
|
|
334
|
+
PARSE_TIMESTAMP('%Y-%m-%dT%H:%M:%E*SZ', updated_at) as updated_at
|
|
335
|
+
FROM UNNEST(@dates) AS date WITH OFFSET pos
|
|
336
|
+
JOIN UNNEST(@names) AS computation_name WITH OFFSET USING(pos)
|
|
337
|
+
JOIN UNNEST(@entity_ids) AS entity_id WITH OFFSET USING(pos)
|
|
338
|
+
JOIN UNNEST(@code_hashes) AS code_hash WITH OFFSET USING(pos)
|
|
339
|
+
JOIN UNNEST(@result_hashes) AS result_hash WITH OFFSET USING(pos)
|
|
340
|
+
JOIN UNNEST(@dep_hashes) AS dependency_result_hashes WITH OFFSET USING(pos)
|
|
341
|
+
JOIN UNNEST(@entity_counts) AS entity_count WITH OFFSET USING(pos)
|
|
342
|
+
JOIN UNNEST(@result_data) AS result_data WITH OFFSET USING(pos)
|
|
343
|
+
JOIN UNNEST(@updated_ats) AS updated_at WITH OFFSET USING(pos)
|
|
344
|
+
) S
|
|
345
|
+
ON T.date = S.date AND T.computation_name = S.computation_name AND T.entity_id = S.entity_id
|
|
346
|
+
WHEN MATCHED THEN
|
|
347
|
+
UPDATE SET code_hash = S.code_hash, result_hash = S.result_hash,
|
|
348
|
+
dependency_result_hashes = S.dependency_result_hashes,
|
|
349
|
+
entity_count = S.entity_count, result_data = S.result_data,
|
|
350
|
+
updated_at = S.updated_at
|
|
351
|
+
WHEN NOT MATCHED THEN
|
|
352
|
+
INSERT (date, computation_name, category, entity_id, code_hash, result_hash,
|
|
353
|
+
dependency_result_hashes, entity_count, result_data, updated_at)
|
|
354
|
+
VALUES (S.date, S.computation_name, @category, S.entity_id, S.code_hash, S.result_hash,
|
|
355
|
+
S.dependency_result_hashes, S.entity_count, S.result_data, S.updated_at)
|
|
356
|
+
`;
|
|
357
|
+
|
|
358
|
+
await this.bigquery.query({
|
|
359
|
+
query: mergeQuery,
|
|
360
|
+
params: {
|
|
361
|
+
dates, names, entity_ids: entityIds, code_hashes: codeHashes,
|
|
362
|
+
result_hashes: resultHashes, dep_hashes: depHashes,
|
|
363
|
+
entity_counts: entityCounts, result_data: resultData,
|
|
364
|
+
updated_ats: updatedAts, category: rows[0].category
|
|
365
|
+
},
|
|
366
|
+
location: this.config.bigquery.location
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async _ensureBigQueryTable(tableName) {
|
|
371
|
+
if (this.tableExists.get(tableName)) return;
|
|
372
|
+
const dataset = this.bigquery.dataset(this.config.bigquery.dataset);
|
|
373
|
+
const table = dataset.table(tableName);
|
|
374
|
+
const [exists] = await table.exists();
|
|
375
|
+
if (!exists) {
|
|
376
|
+
await dataset.createTable(tableName, {
|
|
377
|
+
schema: [
|
|
378
|
+
{ name: 'date', type: 'DATE', mode: 'REQUIRED' },
|
|
379
|
+
{ name: 'computation_name', type: 'STRING', mode: 'REQUIRED' },
|
|
380
|
+
{ name: 'category', type: 'STRING', mode: 'NULLABLE' },
|
|
381
|
+
{ name: 'entity_id', type: 'STRING', mode: 'REQUIRED' },
|
|
382
|
+
{ name: 'code_hash', type: 'STRING', mode: 'NULLABLE' },
|
|
383
|
+
{ name: 'result_hash', type: 'STRING', mode: 'NULLABLE' },
|
|
384
|
+
{ name: 'dependency_result_hashes', type: 'STRING', mode: 'NULLABLE' },
|
|
385
|
+
{ name: 'entity_count', type: 'INTEGER', mode: 'NULLABLE' },
|
|
386
|
+
{ name: 'result_data', type: 'STRING', mode: 'NULLABLE' },
|
|
387
|
+
{ name: 'updated_at', type: 'TIMESTAMP', mode: 'NULLABLE' }
|
|
388
|
+
],
|
|
389
|
+
timePartitioning: { type: 'DAY', field: 'date' },
|
|
390
|
+
clustering: { fields: ['computation_name', 'category'] }
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
this.tableExists.set(tableName, true);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async _writeToFirestore(dateStr, entry, results, firestoreConfig) {
|
|
397
|
+
const { path, merge, includeMetadata } = firestoreConfig;
|
|
398
|
+
if (!path) throw new Error(`Firestore path not configured for ${entry.name}`);
|
|
399
|
+
|
|
400
|
+
const timestamp = new Date();
|
|
401
|
+
const metadata = includeMetadata ? {
|
|
402
|
+
_computedAt: timestamp, _computationDate: dateStr,
|
|
403
|
+
_computationName: entry.name, _codeHash: entry.hash
|
|
404
|
+
} : {};
|
|
405
|
+
|
|
406
|
+
let docCount = 0;
|
|
407
|
+
|
|
408
|
+
if (entry.type === 'per-entity' && typeof results === 'object') {
|
|
409
|
+
const batches = [];
|
|
410
|
+
let currentBatch = this.firestore.batch();
|
|
411
|
+
let batchCount = 0;
|
|
412
|
+
const MAX_BATCH = 500;
|
|
413
|
+
|
|
414
|
+
for (const [entityId, data] of Object.entries(results)) {
|
|
415
|
+
const docPath = this._resolvePath(path, {
|
|
416
|
+
entityId, date: dateStr, computationName: entry.name, category: entry.category || 'uncategorized'
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const docRef = this.firestore.doc(docPath);
|
|
420
|
+
const docData = { ...data, ...metadata };
|
|
421
|
+
|
|
422
|
+
merge ? currentBatch.set(docRef, docData, { merge: true }) : currentBatch.set(docRef, docData);
|
|
423
|
+
|
|
424
|
+
batchCount++; docCount++;
|
|
425
|
+
if (batchCount >= MAX_BATCH) {
|
|
426
|
+
batches.push(currentBatch);
|
|
427
|
+
currentBatch = this.firestore.batch();
|
|
428
|
+
batchCount = 0;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (batchCount > 0) batches.push(currentBatch);
|
|
432
|
+
|
|
433
|
+
const limit = pLimit(10);
|
|
434
|
+
await Promise.all(batches.map(b => limit(() => b.commit())));
|
|
435
|
+
|
|
436
|
+
} else {
|
|
437
|
+
const docPath = this._resolvePath(path, {
|
|
438
|
+
entityId: '_global', date: dateStr, computationName: entry.name, category: entry.category || 'uncategorized'
|
|
439
|
+
});
|
|
440
|
+
const docRef = this.firestore.doc(docPath);
|
|
441
|
+
const docData = { ...results, ...metadata };
|
|
442
|
+
merge ? await docRef.set(docData, { merge: true }) : await docRef.set(docData);
|
|
443
|
+
docCount = 1;
|
|
444
|
+
}
|
|
445
|
+
return { docCount };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
_resolvePath(pathTemplate, values) {
|
|
449
|
+
let resolved = pathTemplate;
|
|
450
|
+
resolved = resolved.replace(/\{entityId\}/g, values.entityId || '_global');
|
|
451
|
+
resolved = resolved.replace(/\{date\}/g, values.date || '');
|
|
452
|
+
resolved = resolved.replace(/\{computationName\}/g, values.computationName || '');
|
|
453
|
+
resolved = resolved.replace(/\{category\}/g, values.category || '');
|
|
454
|
+
resolved = resolved.replace(/^\/+/, '');
|
|
455
|
+
return resolved;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
_hashResult(data) {
|
|
459
|
+
const crypto = require('crypto');
|
|
460
|
+
const str = typeof data === 'string' ? data : JSON.stringify(data);
|
|
461
|
+
return crypto.createHash('md5').update(str).digest('hex').substring(0, 16);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
_log(level, message) {
|
|
465
|
+
this.logger?.log ? this.logger.log(level, `[StorageManager] ${message}`) : console.log(`[${level}] [StorageManager] ${message}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
module.exports = { StorageManager };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Unit Testing Utility for Computations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
class ComputationTester {
|
|
6
|
+
constructor(ComputationClass, config) {
|
|
7
|
+
this.ComputationClass = ComputationClass;
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.mockData = {};
|
|
10
|
+
this.mockDependencies = {};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
withMockData(tableName, data) {
|
|
14
|
+
this.mockData[tableName] = data;
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
withMockDependency(computationName, results) {
|
|
19
|
+
this.mockDependencies[computationName] = results;
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async run(entityId = 'test_entity') {
|
|
24
|
+
const instance = new this.ComputationClass();
|
|
25
|
+
|
|
26
|
+
const context = {
|
|
27
|
+
date: '2026-01-24',
|
|
28
|
+
entityId,
|
|
29
|
+
data: this.mockData,
|
|
30
|
+
dependencies: this.mockDependencies,
|
|
31
|
+
rules: this.config.rules,
|
|
32
|
+
config: this.config,
|
|
33
|
+
logger: { log: () => {} }
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
instance._meta = { logger: context.logger, config: this.config };
|
|
37
|
+
|
|
38
|
+
await instance.process(context);
|
|
39
|
+
|
|
40
|
+
const allResults = await instance.getResult();
|
|
41
|
+
return allResults[entityId] || allResults;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async expect(entityId = 'test_entity') {
|
|
45
|
+
const result = await this.run(entityId);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
result,
|
|
49
|
+
|
|
50
|
+
toHaveProperty(prop, value) {
|
|
51
|
+
const actual = this._getNestedProperty(result, prop);
|
|
52
|
+
if (value !== undefined && actual !== value) {
|
|
53
|
+
throw new Error(`Expected ${prop} to be ${value}, got ${actual}`);
|
|
54
|
+
}
|
|
55
|
+
if (actual === undefined) {
|
|
56
|
+
throw new Error(`Expected ${prop} to exist`);
|
|
57
|
+
}
|
|
58
|
+
return this;
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
toMatchSchema(schema) {
|
|
62
|
+
const errors = this._validateSchema(result, schema);
|
|
63
|
+
if (errors.length > 0) {
|
|
64
|
+
throw new Error(`Schema validation failed: ${errors.join(', ')}`);
|
|
65
|
+
}
|
|
66
|
+
return this;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
_getNestedProperty(obj, path) {
|
|
70
|
+
return path.split('.').reduce((curr, key) => curr?.[key], obj);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
_validateSchema(data, schema) {
|
|
74
|
+
const errors = [];
|
|
75
|
+
for (const [key, type] of Object.entries(schema)) {
|
|
76
|
+
if (typeof data[key] !== type) {
|
|
77
|
+
errors.push(`${key}: expected ${type}, got ${typeof data[key]}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return errors;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { ComputationTester };
|