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,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Business Rules Registry
|
|
3
|
+
*
|
|
4
|
+
* Manages business rules that are injected into computations:
|
|
5
|
+
* 1. Loads all rule modules from config
|
|
6
|
+
* 2. Hashes each rule for version tracking
|
|
7
|
+
* 3. Detects which rules each computation uses
|
|
8
|
+
* 4. Injects rules into computation context
|
|
9
|
+
*
|
|
10
|
+
* Design Philosophy:
|
|
11
|
+
* - Computations are "simple recipes" - they orchestrate, not calculate
|
|
12
|
+
* - Business logic (math, extraction, validation) lives in rules
|
|
13
|
+
* - Single point of reference for all shared functions
|
|
14
|
+
* - Rule changes automatically trigger re-runs of dependent computations
|
|
15
|
+
*
|
|
16
|
+
* Example Usage:
|
|
17
|
+
* ```javascript
|
|
18
|
+
* // In computation process():
|
|
19
|
+
* async process(context) {
|
|
20
|
+
* const { rules, data, entityId } = context;
|
|
21
|
+
*
|
|
22
|
+
* // Use injected rules - no require() needed
|
|
23
|
+
* const portfolio = rules.portfolio.extractPositions(data['portfolio_snapshots']);
|
|
24
|
+
* const sharpeRatio = rules.metrics.calculateSharpe(returns, riskFreeRate);
|
|
25
|
+
* const riskScore = rules.risk.calculateRiskScore(portfolio, volatility);
|
|
26
|
+
*
|
|
27
|
+
* this.setResult(entityId, { sharpeRatio, riskScore });
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {Object} RuleModule
|
|
36
|
+
* @property {string} name - Module name (e.g., 'portfolio', 'metrics')
|
|
37
|
+
* @property {Object} exports - The exported functions/objects
|
|
38
|
+
* @property {string} hash - Hash of the module for version tracking
|
|
39
|
+
* @property {Object} functionHashes - Hash of each exported function
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
class RulesRegistry {
|
|
43
|
+
/**
|
|
44
|
+
* @param {Object} config - Configuration containing rule modules
|
|
45
|
+
* @param {Object} [logger] - Logger instance
|
|
46
|
+
*/
|
|
47
|
+
constructor(config, logger = null) {
|
|
48
|
+
this.config = config;
|
|
49
|
+
this.logger = logger;
|
|
50
|
+
|
|
51
|
+
// Storage
|
|
52
|
+
this.modules = new Map(); // name -> RuleModule
|
|
53
|
+
this.context = {}; // The injected context object
|
|
54
|
+
this.functionIndex = new Map(); // functionName -> { module, hash }
|
|
55
|
+
|
|
56
|
+
// Load rules from config
|
|
57
|
+
this._loadRules();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the rules context to inject into computations.
|
|
62
|
+
* @returns {Object} Rules context with all modules
|
|
63
|
+
*/
|
|
64
|
+
getContext() {
|
|
65
|
+
return this.context;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get all rule hashes for a computation based on which rules it uses.
|
|
70
|
+
* @param {string} codeString - Computation source code
|
|
71
|
+
* @returns {Object} { usedRules: { moduleName: [functionNames] }, hashes: { moduleName: hash } }
|
|
72
|
+
*/
|
|
73
|
+
detectUsage(codeString) {
|
|
74
|
+
const usedRules = {};
|
|
75
|
+
const hashes = {};
|
|
76
|
+
|
|
77
|
+
for (const [moduleName, module] of this.modules) {
|
|
78
|
+
const usedFunctions = [];
|
|
79
|
+
|
|
80
|
+
// Check each exported function
|
|
81
|
+
for (const [funcName, funcHash] of Object.entries(module.functionHashes)) {
|
|
82
|
+
// Look for patterns like: rules.moduleName.funcName or just funcName
|
|
83
|
+
const patterns = [
|
|
84
|
+
new RegExp(`rules\\.${moduleName}\\.${funcName}\\b`), // rules.portfolio.extractPositions
|
|
85
|
+
new RegExp(`\\.${funcName}\\(`), // .extractPositions(
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
for (const pattern of patterns) {
|
|
89
|
+
if (pattern.test(codeString)) {
|
|
90
|
+
if (!usedFunctions.includes(funcName)) {
|
|
91
|
+
usedFunctions.push(funcName);
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (usedFunctions.length > 0) {
|
|
99
|
+
usedRules[moduleName] = usedFunctions;
|
|
100
|
+
|
|
101
|
+
// Combine hashes of used functions
|
|
102
|
+
const usedHashes = usedFunctions
|
|
103
|
+
.map(f => module.functionHashes[f])
|
|
104
|
+
.sort()
|
|
105
|
+
.join('|');
|
|
106
|
+
hashes[moduleName] = this._hash(usedHashes);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { usedRules, hashes };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get the combined hash of all rules (for safe mode).
|
|
115
|
+
* @returns {string} Combined hash
|
|
116
|
+
*/
|
|
117
|
+
getAllHash() {
|
|
118
|
+
const allHashes = Array.from(this.modules.values())
|
|
119
|
+
.map(m => m.hash)
|
|
120
|
+
.sort()
|
|
121
|
+
.join('|');
|
|
122
|
+
return this._hash(allHashes);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get module hash by name.
|
|
127
|
+
* @param {string} moduleName - Module name
|
|
128
|
+
* @returns {string|null} Module hash or null
|
|
129
|
+
*/
|
|
130
|
+
getModuleHash(moduleName) {
|
|
131
|
+
return this.modules.get(moduleName)?.hash || null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get stats about loaded rules.
|
|
136
|
+
* @returns {Object} Stats
|
|
137
|
+
*/
|
|
138
|
+
getStats() {
|
|
139
|
+
const stats = {
|
|
140
|
+
moduleCount: this.modules.size,
|
|
141
|
+
functionCount: this.functionIndex.size,
|
|
142
|
+
modules: {}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
for (const [name, module] of this.modules) {
|
|
146
|
+
stats.modules[name] = {
|
|
147
|
+
functionCount: Object.keys(module.functionHashes).length,
|
|
148
|
+
hash: module.hash
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return stats;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// =========================================================================
|
|
156
|
+
// PRIVATE METHODS
|
|
157
|
+
// =========================================================================
|
|
158
|
+
|
|
159
|
+
_loadRules() {
|
|
160
|
+
const rulesConfig = this.config.rules || {};
|
|
161
|
+
|
|
162
|
+
for (const [moduleName, moduleExports] of Object.entries(rulesConfig)) {
|
|
163
|
+
this._registerModule(moduleName, moduleExports);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this._log('INFO', `Loaded ${this.modules.size} rule modules with ${this.functionIndex.size} functions`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_registerModule(name, exports) {
|
|
170
|
+
const functionHashes = {};
|
|
171
|
+
const cleanExports = {};
|
|
172
|
+
|
|
173
|
+
// Process each export
|
|
174
|
+
for (const [key, value] of Object.entries(exports)) {
|
|
175
|
+
if (typeof value === 'function') {
|
|
176
|
+
// Hash the function source
|
|
177
|
+
functionHashes[key] = this._hashFunction(value);
|
|
178
|
+
cleanExports[key] = value;
|
|
179
|
+
|
|
180
|
+
// Index for quick lookup
|
|
181
|
+
this.functionIndex.set(key, { module: name, hash: functionHashes[key] });
|
|
182
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
183
|
+
// For objects (like constants or nested modules), hash the JSON
|
|
184
|
+
functionHashes[key] = this._hash(JSON.stringify(value));
|
|
185
|
+
cleanExports[key] = value;
|
|
186
|
+
this.functionIndex.set(key, { module: name, hash: functionHashes[key] });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Create module hash from all function hashes
|
|
191
|
+
const moduleHash = this._hash(
|
|
192
|
+
Object.values(functionHashes).sort().join('|')
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Store module
|
|
196
|
+
this.modules.set(name, {
|
|
197
|
+
name,
|
|
198
|
+
exports: cleanExports,
|
|
199
|
+
hash: moduleHash,
|
|
200
|
+
functionHashes
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Add to context
|
|
204
|
+
this.context[name] = cleanExports;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_hashFunction(fn) {
|
|
208
|
+
const source = fn.toString();
|
|
209
|
+
// Strip comments and normalize whitespace for loose equality
|
|
210
|
+
const cleaned = source
|
|
211
|
+
.replace(/\/\/.*$/gm, '')
|
|
212
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
213
|
+
.replace(/\s+/g, ' ')
|
|
214
|
+
.trim();
|
|
215
|
+
return this._hash(cleaned);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_hash(str) {
|
|
219
|
+
return crypto.createHash('sha256').update(str).digest('hex').substring(0, 16);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_log(level, message) {
|
|
223
|
+
if (this.logger && typeof this.logger.log === 'function') {
|
|
224
|
+
this.logger.log(level, `[Rules] ${message}`);
|
|
225
|
+
} else {
|
|
226
|
+
console.log(`[Rules] ${message}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { RulesRegistry };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Run Analyzer
|
|
3
|
+
* * Pure logic component that determines which computations need to run.
|
|
4
|
+
* Decouples decision-making from execution and storage.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class RunAnalyzer {
|
|
8
|
+
constructor(manifest, dataFetcher, logger = null) {
|
|
9
|
+
this.manifest = manifest;
|
|
10
|
+
this.dataFetcher = dataFetcher;
|
|
11
|
+
this.logger = logger || console;
|
|
12
|
+
this.manifestMap = new Map(manifest.map(e => [e.name, e]));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Analyze the system state to determine runnable computations.
|
|
17
|
+
* @param {string} dateStr - Target date
|
|
18
|
+
* @param {Map} dailyStatus - Current day's status map
|
|
19
|
+
* @param {Map} prevDayStatus - Previous day's status map
|
|
20
|
+
* @returns {Promise<Object>} The Analysis Report
|
|
21
|
+
*/
|
|
22
|
+
async analyze(dateStr, dailyStatus, prevDayStatus) {
|
|
23
|
+
const report = {
|
|
24
|
+
runnable: [],
|
|
25
|
+
skipped: [],
|
|
26
|
+
blocked: [],
|
|
27
|
+
impossible: [],
|
|
28
|
+
reRuns: []
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const isToday = dateStr === new Date().toISOString().slice(0, 10);
|
|
32
|
+
|
|
33
|
+
for (const entry of this.manifest) {
|
|
34
|
+
const decision = await this._evaluateEntry(
|
|
35
|
+
entry,
|
|
36
|
+
dateStr,
|
|
37
|
+
isToday,
|
|
38
|
+
dailyStatus,
|
|
39
|
+
prevDayStatus
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
report[decision.type].push(decision.payload);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Merge reRuns into runnable for backward compatibility if needed by consumers
|
|
46
|
+
// But keeping them distinct in the report is better for logging
|
|
47
|
+
return report;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async _evaluateEntry(entry, dateStr, isToday, dailyStatus, prevDayStatus) {
|
|
51
|
+
const { name, requires, dependencies, isHistorical, isTest, schedule } = entry;
|
|
52
|
+
const stored = dailyStatus.get(name);
|
|
53
|
+
|
|
54
|
+
// 1. Schedule Check
|
|
55
|
+
if (!this._shouldRunOnDate(dateStr, schedule)) {
|
|
56
|
+
return { type: 'skipped', payload: { name, reason: `Not scheduled (${schedule})` } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. Data Availability Check
|
|
60
|
+
// Note: This is the only async IO part (calls DataFetcher)
|
|
61
|
+
const availability = await this.dataFetcher.checkAvailability(requires, dateStr);
|
|
62
|
+
if (!availability.canRun) {
|
|
63
|
+
if (!isToday) {
|
|
64
|
+
return { type: 'impossible', payload: { name, reason: `Missing data: ${availability.missing.join(', ')}` } };
|
|
65
|
+
} else {
|
|
66
|
+
return { type: 'blocked', payload: { name, reason: `Waiting for data: ${availability.missing.join(', ')}` } };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 3. Force Test Re-run
|
|
71
|
+
if (isTest && isToday) {
|
|
72
|
+
return {
|
|
73
|
+
type: 'reRuns',
|
|
74
|
+
payload: {
|
|
75
|
+
name, pass: entry.pass, reason: 'Test computation (always runs today)',
|
|
76
|
+
oldHash: stored?.hash, newHash: entry.hash
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 4. Code Change Detection
|
|
82
|
+
let needsRun = false;
|
|
83
|
+
let runReason = '';
|
|
84
|
+
|
|
85
|
+
if (!stored) {
|
|
86
|
+
needsRun = true;
|
|
87
|
+
runReason = 'New computation';
|
|
88
|
+
} else if (stored.hash !== entry.hash) {
|
|
89
|
+
needsRun = true;
|
|
90
|
+
runReason = 'Code changed';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 5. Dependency Checks
|
|
94
|
+
const missingDeps = [];
|
|
95
|
+
let hasDataDrift = false;
|
|
96
|
+
|
|
97
|
+
for (const dep of dependencies) {
|
|
98
|
+
const depEntry = this.manifestMap.get(dep);
|
|
99
|
+
const depStatus = dailyStatus.get(dep);
|
|
100
|
+
|
|
101
|
+
if (!depStatus) {
|
|
102
|
+
missingDeps.push(dep);
|
|
103
|
+
} else if (depEntry && depStatus.hash !== depEntry.hash) {
|
|
104
|
+
// Dependency is present but old (needs update first)
|
|
105
|
+
missingDeps.push(dep);
|
|
106
|
+
} else if (stored?.dependencyResultHashes) {
|
|
107
|
+
// Check for data drift
|
|
108
|
+
const lastSeenResultHash = stored.dependencyResultHashes[dep];
|
|
109
|
+
if (lastSeenResultHash && depStatus.resultHash !== lastSeenResultHash) {
|
|
110
|
+
hasDataDrift = true;
|
|
111
|
+
if (!needsRun) {
|
|
112
|
+
needsRun = true;
|
|
113
|
+
runReason = `Dependency data changed: ${dep}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (missingDeps.length > 0) {
|
|
120
|
+
return { type: 'blocked', payload: { name, reason: `Waiting for: ${missingDeps.join(', ')}` } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 6. Historical Check
|
|
124
|
+
if (isHistorical && !needsRun) {
|
|
125
|
+
const prevStatus = prevDayStatus.get(name);
|
|
126
|
+
if (!prevStatus) {
|
|
127
|
+
needsRun = true;
|
|
128
|
+
runReason = 'Historical: no previous day result';
|
|
129
|
+
} else if (prevStatus.hash !== entry.hash) {
|
|
130
|
+
needsRun = true;
|
|
131
|
+
runReason = 'Historical: code version discontinuity';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 7. Final Decision
|
|
136
|
+
if (needsRun) {
|
|
137
|
+
const type = stored ? 'reRuns' : 'runnable';
|
|
138
|
+
return {
|
|
139
|
+
type,
|
|
140
|
+
payload: {
|
|
141
|
+
name, pass: entry.pass, reason: runReason,
|
|
142
|
+
oldHash: stored?.hash, newHash: entry.hash
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { type: 'skipped', payload: { name, reason: 'Already up to date' } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_shouldRunOnDate(dateStr, schedule) {
|
|
151
|
+
if (!schedule || schedule === 'daily') return true;
|
|
152
|
+
|
|
153
|
+
const date = new Date(dateStr + 'T00:00:00Z');
|
|
154
|
+
const dayOfWeek = date.getUTCDay(); // 0=Sun
|
|
155
|
+
const dayOfMonth = date.getUTCDate();
|
|
156
|
+
|
|
157
|
+
if (schedule === 'weekly') return dayOfWeek === 0;
|
|
158
|
+
if (schedule === 'monthly') return dayOfMonth === 1;
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { RunAnalyzer };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Cost Attribution Tracker for BigQuery
|
|
3
|
+
* * Tracks query costs per computation by monitoring bytes processed.
|
|
4
|
+
* * Manages its own storage table in BigQuery.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { BigQuery } = require('@google-cloud/bigquery');
|
|
8
|
+
|
|
9
|
+
class CostTracker {
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} config - System configuration
|
|
12
|
+
* @param {Object} [logger] - Logger instance
|
|
13
|
+
*/
|
|
14
|
+
constructor(config, logger = null) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.logger = logger || console;
|
|
17
|
+
|
|
18
|
+
this.bigquery = new BigQuery({
|
|
19
|
+
projectId: config.bigquery.projectId,
|
|
20
|
+
location: config.bigquery.location || 'US'
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
this.datasetId = config.bigquery.dataset;
|
|
24
|
+
this.tableName = 'computation_costs';
|
|
25
|
+
this._tableChecked = false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record the cost of a computation execution.
|
|
30
|
+
* @param {string} computationName
|
|
31
|
+
* @param {string} dateStr - The execution business date
|
|
32
|
+
* @param {number} bytesProcessed - Total bytes processed by BigQuery
|
|
33
|
+
*/
|
|
34
|
+
async trackCost(computationName, dateStr, bytesProcessed) {
|
|
35
|
+
// If no bytes processed (cached or no-op), don't record
|
|
36
|
+
if (!bytesProcessed || bytesProcessed <= 0) return;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await this._ensureTable();
|
|
40
|
+
|
|
41
|
+
// Pricing: ~$5.00 per TB
|
|
42
|
+
const COST_PER_TB = 5.0;
|
|
43
|
+
const BYTES_PER_TB = 1099511627776; // 1024^4
|
|
44
|
+
const estimatedCost = (bytesProcessed / BYTES_PER_TB) * COST_PER_TB;
|
|
45
|
+
|
|
46
|
+
const row = {
|
|
47
|
+
date: dateStr,
|
|
48
|
+
computation_name: computationName,
|
|
49
|
+
bytes_processed: bytesProcessed,
|
|
50
|
+
estimated_cost_usd: parseFloat(estimatedCost.toFixed(6)), // Keep reasonable precision
|
|
51
|
+
timestamp: new Date().toISOString()
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
await this.bigquery
|
|
55
|
+
.dataset(this.datasetId)
|
|
56
|
+
.table(this.tableName)
|
|
57
|
+
.insert([row]);
|
|
58
|
+
|
|
59
|
+
this._log('DEBUG', `Recorded cost for ${computationName}: $${row.estimated_cost_usd} (${bytesProcessed} bytes)`);
|
|
60
|
+
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// Swallow errors to prevent blocking the main execution pipeline
|
|
63
|
+
this._log('ERROR', `Failed to track cost for ${computationName}: ${e.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generate a cost report for a date range.
|
|
69
|
+
* @param {string} startDate - YYYY-MM-DD
|
|
70
|
+
* @param {string} endDate - YYYY-MM-DD
|
|
71
|
+
*/
|
|
72
|
+
async getCostReport(startDate, endDate) {
|
|
73
|
+
try {
|
|
74
|
+
const query = `
|
|
75
|
+
SELECT
|
|
76
|
+
computation_name,
|
|
77
|
+
SUM(bytes_processed) as total_bytes,
|
|
78
|
+
SUM(estimated_cost_usd) as total_cost,
|
|
79
|
+
COUNT(*) as execution_count,
|
|
80
|
+
AVG(estimated_cost_usd) as avg_cost_per_run
|
|
81
|
+
FROM \`${this.config.bigquery.projectId}.${this.datasetId}.${this.tableName}\`
|
|
82
|
+
WHERE date BETWEEN @startDate AND @endDate
|
|
83
|
+
GROUP BY computation_name
|
|
84
|
+
ORDER BY total_cost DESC
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
const [rows] = await this.bigquery.query({
|
|
88
|
+
query,
|
|
89
|
+
params: { startDate, endDate },
|
|
90
|
+
location: this.config.bigquery.location
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const totalCost = rows.reduce((sum, r) => sum + (r.total_cost || 0), 0);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
period: { start: startDate, end: endDate },
|
|
97
|
+
totalCost,
|
|
98
|
+
breakdown: rows.map(r => ({
|
|
99
|
+
computation: r.computation_name,
|
|
100
|
+
cost: r.total_cost,
|
|
101
|
+
runs: r.execution_count,
|
|
102
|
+
avgCost: r.avg_cost_per_run,
|
|
103
|
+
bytes: r.total_bytes
|
|
104
|
+
}))
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
} catch (e) {
|
|
108
|
+
this._log('ERROR', `Failed to generate cost report: ${e.message}`);
|
|
109
|
+
return { error: e.message };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async _ensureTable() {
|
|
114
|
+
if (this._tableChecked) return;
|
|
115
|
+
|
|
116
|
+
const dataset = this.bigquery.dataset(this.datasetId);
|
|
117
|
+
const table = dataset.table(this.tableName);
|
|
118
|
+
|
|
119
|
+
const [exists] = await table.exists();
|
|
120
|
+
|
|
121
|
+
if (!exists) {
|
|
122
|
+
this._log('INFO', `Creating cost tracking table: ${this.tableName}`);
|
|
123
|
+
await dataset.createTable(this.tableName, {
|
|
124
|
+
schema: [
|
|
125
|
+
{ name: 'date', type: 'DATE', mode: 'REQUIRED' },
|
|
126
|
+
{ name: 'computation_name', type: 'STRING', mode: 'REQUIRED' },
|
|
127
|
+
{ name: 'bytes_processed', type: 'INTEGER', mode: 'REQUIRED' },
|
|
128
|
+
{ name: 'estimated_cost_usd', type: 'FLOAT', mode: 'REQUIRED' },
|
|
129
|
+
{ name: 'timestamp', type: 'TIMESTAMP', mode: 'REQUIRED' }
|
|
130
|
+
],
|
|
131
|
+
timePartitioning: {
|
|
132
|
+
type: 'DAY',
|
|
133
|
+
field: 'date'
|
|
134
|
+
},
|
|
135
|
+
clustering: {
|
|
136
|
+
fields: ['computation_name']
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this._tableChecked = true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_log(level, message) {
|
|
145
|
+
const prefix = '[CostTracker]';
|
|
146
|
+
if (this.logger && typeof this.logger.log === 'function') {
|
|
147
|
+
this.logger.log(level, `${prefix} ${message}`);
|
|
148
|
+
} else {
|
|
149
|
+
console.log(`${level}: ${prefix} ${message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { CostTracker };
|