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,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Framework exports
|
|
3
|
+
* * This is the public API of the computation framework.
|
|
4
|
+
* Computations only need to import from here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Core
|
|
8
|
+
const { Computation } = require('./core/Computation');
|
|
9
|
+
const { Orchestrator } = require('./execution/Orchestrator'); // <--- CHANGED
|
|
10
|
+
const { ManifestBuilder } = require('./core/Manifest');
|
|
11
|
+
const { RulesRegistry } = require('./core/Rules');
|
|
12
|
+
|
|
13
|
+
// Scheduling
|
|
14
|
+
const { ScheduleValidator } = require('./scheduling/ScheduleValidator');
|
|
15
|
+
|
|
16
|
+
// Data Layer
|
|
17
|
+
const { SchemaRegistry } = require('./data/SchemaRegistry');
|
|
18
|
+
const { QueryBuilder } = require('./data/QueryBuilder');
|
|
19
|
+
const { DataFetcher } = require('./data/DataFetcher');
|
|
20
|
+
|
|
21
|
+
// Storage
|
|
22
|
+
const { StorageManager } = require('./storage/StorageManager');
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
// Base class for computations
|
|
26
|
+
Computation,
|
|
27
|
+
|
|
28
|
+
// Execution engine
|
|
29
|
+
Orchestrator, // <--- CHANGED
|
|
30
|
+
ManifestBuilder,
|
|
31
|
+
|
|
32
|
+
// Business rules
|
|
33
|
+
RulesRegistry,
|
|
34
|
+
|
|
35
|
+
// Scheduling
|
|
36
|
+
ScheduleValidator,
|
|
37
|
+
|
|
38
|
+
// Data layer (for advanced use cases)
|
|
39
|
+
SchemaRegistry,
|
|
40
|
+
QueryBuilder,
|
|
41
|
+
DataFetcher,
|
|
42
|
+
|
|
43
|
+
// Storage
|
|
44
|
+
StorageManager
|
|
45
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Data Lineage Tracker
|
|
3
|
+
* * Tracks the provenance of computation results.
|
|
4
|
+
* * Records which source data (tables, record counts) contributed to specific entity results.
|
|
5
|
+
* * Uses internal buffering to handle high-volume writes efficiently.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { BigQuery } = require('@google-cloud/bigquery');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
|
|
11
|
+
class LineageTracker {
|
|
12
|
+
/**
|
|
13
|
+
* @param {Object} config - System configuration
|
|
14
|
+
* @param {Object} [logger] - Logger instance
|
|
15
|
+
*/
|
|
16
|
+
constructor(config, logger = null) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
this.logger = logger || console;
|
|
19
|
+
|
|
20
|
+
this.bigquery = new BigQuery({
|
|
21
|
+
projectId: config.bigquery.projectId,
|
|
22
|
+
location: config.bigquery.location || 'US'
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
this.datasetId = config.bigquery.dataset;
|
|
26
|
+
this.tableName = 'data_lineage';
|
|
27
|
+
|
|
28
|
+
this.buffer = [];
|
|
29
|
+
this.BUFFER_SIZE = config.execution?.lineageBatchSize || 500;
|
|
30
|
+
this._tableChecked = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Track lineage for a specific computation result.
|
|
35
|
+
* @param {Object} params
|
|
36
|
+
* @param {string} params.computation - Computation name
|
|
37
|
+
* @param {string} params.date - Execution date
|
|
38
|
+
* @param {string} params.entityId - Entity ID
|
|
39
|
+
* @param {Object} params.sourceData - The actual input data object used
|
|
40
|
+
* @param {Object} params.result - The result object produced
|
|
41
|
+
*/
|
|
42
|
+
async track({ computation, date, entityId, sourceData, result }) {
|
|
43
|
+
if (!sourceData) return;
|
|
44
|
+
|
|
45
|
+
const sourceSummary = this._summarizeSources(sourceData);
|
|
46
|
+
const resultHash = this._hash(result);
|
|
47
|
+
|
|
48
|
+
this.buffer.push({
|
|
49
|
+
date,
|
|
50
|
+
computation_name: computation,
|
|
51
|
+
entity_id: String(entityId),
|
|
52
|
+
sources_json: JSON.stringify(sourceSummary),
|
|
53
|
+
result_hash: resultHash,
|
|
54
|
+
timestamp: new Date().toISOString()
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (this.buffer.length >= this.BUFFER_SIZE) {
|
|
58
|
+
await this.flush();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Force write any buffered records to BigQuery.
|
|
64
|
+
*/
|
|
65
|
+
async flush() {
|
|
66
|
+
if (this.buffer.length === 0) return;
|
|
67
|
+
|
|
68
|
+
const batch = [...this.buffer];
|
|
69
|
+
this.buffer = []; // Clear immediately
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await this._ensureTable();
|
|
73
|
+
|
|
74
|
+
await this.bigquery
|
|
75
|
+
.dataset(this.datasetId)
|
|
76
|
+
.table(this.tableName)
|
|
77
|
+
.insert(batch);
|
|
78
|
+
|
|
79
|
+
this._log('DEBUG', `Flushed ${batch.length} lineage records`);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
this._log('ERROR', `Failed to flush lineage buffer: ${e.message}`);
|
|
82
|
+
// In a production system, we might want to retry or dump to a dead-letter file
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Analyze source data to create a lightweight summary.
|
|
88
|
+
* @param {Object} data - Input data map { tableName: data }
|
|
89
|
+
* @returns {Array} Summary of sources [{ table, count, ... }]
|
|
90
|
+
*/
|
|
91
|
+
_summarizeSources(data) {
|
|
92
|
+
return Object.entries(data).map(([table, content]) => {
|
|
93
|
+
let count = 0;
|
|
94
|
+
let meta = null;
|
|
95
|
+
|
|
96
|
+
if (Array.isArray(content)) {
|
|
97
|
+
count = content.length;
|
|
98
|
+
} else if (content && typeof content === 'object') {
|
|
99
|
+
count = Object.keys(content).length;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Optional: If data has specific metadata fields (like 'version' or 'snapshot_id'), extract them
|
|
103
|
+
if (content && content._metadata) {
|
|
104
|
+
meta = content._metadata;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { table, count, meta };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_hash(data) {
|
|
112
|
+
const str = typeof data === 'string' ? data : JSON.stringify(data);
|
|
113
|
+
return crypto.createHash('md5').update(str || '').digest('hex').substring(0, 16);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async _ensureTable() {
|
|
117
|
+
if (this._tableChecked) return;
|
|
118
|
+
|
|
119
|
+
const dataset = this.bigquery.dataset(this.datasetId);
|
|
120
|
+
const table = dataset.table(this.tableName);
|
|
121
|
+
const [exists] = await table.exists();
|
|
122
|
+
|
|
123
|
+
if (!exists) {
|
|
124
|
+
this._log('INFO', `Creating lineage table: ${this.tableName}`);
|
|
125
|
+
await dataset.createTable(this.tableName, {
|
|
126
|
+
schema: [
|
|
127
|
+
{ name: 'date', type: 'DATE', mode: 'REQUIRED' },
|
|
128
|
+
{ name: 'computation_name', type: 'STRING', mode: 'REQUIRED' },
|
|
129
|
+
{ name: 'entity_id', type: 'STRING', mode: 'REQUIRED' },
|
|
130
|
+
{ name: 'sources_json', type: 'STRING', mode: 'REQUIRED' }, // JSON array of source summaries
|
|
131
|
+
{ name: 'result_hash', type: 'STRING', mode: 'NULLABLE' },
|
|
132
|
+
{ name: 'timestamp', type: 'TIMESTAMP', mode: 'REQUIRED' }
|
|
133
|
+
],
|
|
134
|
+
timePartitioning: { type: 'DAY', field: 'date' },
|
|
135
|
+
clustering: { fields: ['computation_name', 'entity_id'] }
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
this._tableChecked = true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_log(level, message) {
|
|
142
|
+
const prefix = '[LineageTracker]';
|
|
143
|
+
this.logger?.log ? this.logger.log(level, `${prefix} ${message}`) : console.log(`${level}: ${prefix} ${message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { LineageTracker };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Computation Profiler
|
|
3
|
+
* Tracks execution metrics (duration, memory) for computations.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class ComputationProfiler {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.profiles = new Map();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
startProfile(computationName, entityId = null) {
|
|
12
|
+
const key = entityId ? `${computationName}:${entityId}` : computationName;
|
|
13
|
+
|
|
14
|
+
this.profiles.set(key, {
|
|
15
|
+
startTime: Date.now(),
|
|
16
|
+
startMemory: process.memoryUsage().heapUsed,
|
|
17
|
+
queryCount: 0,
|
|
18
|
+
bytesProcessed: 0
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return key;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
endProfile(key, metadata = {}) {
|
|
25
|
+
const profile = this.profiles.get(key);
|
|
26
|
+
if (!profile) return;
|
|
27
|
+
|
|
28
|
+
const endTime = Date.now();
|
|
29
|
+
const endMemory = process.memoryUsage().heapUsed;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
duration: endTime - profile.startTime,
|
|
33
|
+
memoryDelta: endMemory - profile.startMemory,
|
|
34
|
+
queriesExecuted: profile.queryCount,
|
|
35
|
+
bytesProcessed: profile.bytesProcessed,
|
|
36
|
+
...metadata
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async generateReport(dateStr, manifest) {
|
|
41
|
+
const report = {
|
|
42
|
+
date: dateStr,
|
|
43
|
+
totalDuration: 0,
|
|
44
|
+
totalQueries: 0,
|
|
45
|
+
totalBytes: 0,
|
|
46
|
+
computations: []
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
for (const entry of manifest) {
|
|
50
|
+
const compProfiles = Array.from(this.profiles.entries())
|
|
51
|
+
.filter(([k]) => k.startsWith(entry.name));
|
|
52
|
+
|
|
53
|
+
if (compProfiles.length === 0) continue;
|
|
54
|
+
|
|
55
|
+
const durations = compProfiles.map(([, p]) => Date.now() - p.startTime);
|
|
56
|
+
const memories = compProfiles.map(([, p]) => process.memoryUsage().heapUsed - p.startMemory);
|
|
57
|
+
|
|
58
|
+
report.computations.push({
|
|
59
|
+
name: entry.name,
|
|
60
|
+
entityCount: compProfiles.length,
|
|
61
|
+
avgDuration: durations.reduce((a, b) => a + b, 0) / durations.length,
|
|
62
|
+
maxDuration: Math.max(...durations),
|
|
63
|
+
p95Duration: this._percentile(durations, 0.95),
|
|
64
|
+
avgMemory: memories.reduce((a, b) => a + b, 0) / memories.length,
|
|
65
|
+
totalDuration: durations.reduce((a, b) => a + b, 0)
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
report.computations.sort((a, b) => b.totalDuration - a.totalDuration);
|
|
70
|
+
return report;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_percentile(arr, p) {
|
|
74
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
75
|
+
const index = Math.ceil(sorted.length * p) - 1;
|
|
76
|
+
return sorted[index];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { ComputationProfiler };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Computation Checkpointer
|
|
3
|
+
* Manages save/resume states for long-running computations using lightweight batch tracking.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
class Checkpointer {
|
|
9
|
+
constructor(config, storage) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.storage = storage;
|
|
12
|
+
this.enabled = config.checkpointing?.enabled !== false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async initCheckpoint(dateStr, computationName, totalEntities = 0) {
|
|
16
|
+
if (!this.enabled) return null;
|
|
17
|
+
|
|
18
|
+
// Check if there is an existing running checkpoint we can resume
|
|
19
|
+
const existing = await this.storage.getLatestCheckpoint(dateStr, computationName);
|
|
20
|
+
|
|
21
|
+
if (existing && existing.status === 'running') {
|
|
22
|
+
return {
|
|
23
|
+
id: existing.checkpoint_id,
|
|
24
|
+
processedCount: existing.processed_count || 0,
|
|
25
|
+
completedBatches: new Set(existing.completed_batches || []),
|
|
26
|
+
lastEntityId: existing.last_entity_id,
|
|
27
|
+
isResumed: true
|
|
28
|
+
};
|
|
29
|
+
} else if (existing && existing.status === 'completed') {
|
|
30
|
+
// Already fully done
|
|
31
|
+
return { isCompleted: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Start new
|
|
35
|
+
const checkpointId = crypto.randomUUID();
|
|
36
|
+
await this.storage.initCheckpoint(dateStr, computationName, checkpointId, totalEntities);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: checkpointId,
|
|
40
|
+
processedCount: 0,
|
|
41
|
+
completedBatches: new Set(),
|
|
42
|
+
lastEntityId: null,
|
|
43
|
+
isResumed: false
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async markBatchComplete(dateStr, computationName, checkpointId, batchIndex, batchSize, lastEntityId) {
|
|
48
|
+
if (!this.enabled || !checkpointId) return;
|
|
49
|
+
|
|
50
|
+
// We update processed count approximately based on batch size
|
|
51
|
+
const processedCount = (batchIndex + 1) * batchSize;
|
|
52
|
+
|
|
53
|
+
await this.storage.updateCheckpoint(dateStr, computationName, checkpointId, {
|
|
54
|
+
processedCount,
|
|
55
|
+
lastEntityId,
|
|
56
|
+
batchIndex
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async complete(dateStr, computationName, checkpointId) {
|
|
61
|
+
if (!this.enabled || !checkpointId) return;
|
|
62
|
+
await this.storage.completeCheckpoint(dateStr, computationName, checkpointId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { Checkpointer };
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Schedule Validator
|
|
3
|
+
*
|
|
4
|
+
* Validates computation schedules and enforces timing rules:
|
|
5
|
+
* 1. Parses schedule declarations
|
|
6
|
+
* 2. Validates schedule format
|
|
7
|
+
* 3. Checks 15-minute rule between dependent computations
|
|
8
|
+
* 4. Generates warnings/errors for problematic schedules
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} Schedule
|
|
13
|
+
* @property {string} frequency - 'hourly' | 'daily' | 'weekly' | 'monthly'
|
|
14
|
+
* @property {string} [time] - Time in HH:MM format (UTC)
|
|
15
|
+
* @property {number} [dayOfWeek] - 0-6 (Sun-Sat) for weekly
|
|
16
|
+
* @property {number} [dayOfMonth] - 1-31 for monthly
|
|
17
|
+
* @property {string} [timezone] - Timezone (default: UTC)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} ValidationResult
|
|
22
|
+
* @property {boolean} valid - Whether all schedules are valid
|
|
23
|
+
* @property {Array} errors - Critical errors (must fix)
|
|
24
|
+
* @property {Array} warnings - Non-critical issues (should fix)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
class ScheduleValidator {
|
|
28
|
+
/**
|
|
29
|
+
* @param {Object} config - System configuration
|
|
30
|
+
* @param {Object} [logger] - Logger instance
|
|
31
|
+
*/
|
|
32
|
+
constructor(config, logger = null) {
|
|
33
|
+
this.config = config;
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
this.defaultSchedule = config.scheduling?.default || {
|
|
36
|
+
frequency: 'daily',
|
|
37
|
+
time: '02:00',
|
|
38
|
+
timezone: 'UTC'
|
|
39
|
+
};
|
|
40
|
+
this.dependencyGapMinutes = config.scheduling?.dependencyGapMinutes || 15;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate all computation schedules in the manifest.
|
|
45
|
+
* @param {Array} manifest - Array of manifest entries
|
|
46
|
+
* @returns {ValidationResult}
|
|
47
|
+
*/
|
|
48
|
+
validate(manifest) {
|
|
49
|
+
const errors = [];
|
|
50
|
+
const warnings = [];
|
|
51
|
+
|
|
52
|
+
// Build lookup map
|
|
53
|
+
const entryMap = new Map();
|
|
54
|
+
for (const entry of manifest) {
|
|
55
|
+
entryMap.set(entry.name, entry);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const entry of manifest) {
|
|
59
|
+
// 1. Validate schedule format
|
|
60
|
+
const scheduleErrors = this._validateScheduleFormat(entry);
|
|
61
|
+
errors.push(...scheduleErrors);
|
|
62
|
+
|
|
63
|
+
// 2. Check dependency timing
|
|
64
|
+
const timingIssues = this._checkDependencyTiming(entry, entryMap);
|
|
65
|
+
for (const issue of timingIssues) {
|
|
66
|
+
if (issue.severity === 'error') {
|
|
67
|
+
errors.push(issue);
|
|
68
|
+
} else {
|
|
69
|
+
warnings.push(issue);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
valid: errors.length === 0,
|
|
76
|
+
errors,
|
|
77
|
+
warnings
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse and normalize a schedule, applying defaults.
|
|
83
|
+
* @param {Object|undefined} schedule - Raw schedule from computation config
|
|
84
|
+
* @returns {Schedule} Normalized schedule
|
|
85
|
+
*/
|
|
86
|
+
parseSchedule(schedule) {
|
|
87
|
+
if (!schedule) {
|
|
88
|
+
return { ...this.defaultSchedule };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
frequency: schedule.frequency || this.defaultSchedule.frequency,
|
|
93
|
+
time: schedule.time || this.defaultSchedule.time,
|
|
94
|
+
dayOfWeek: schedule.dayOfWeek,
|
|
95
|
+
dayOfMonth: schedule.dayOfMonth,
|
|
96
|
+
timezone: schedule.timezone || 'UTC'
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Convert schedule to cron expression.
|
|
102
|
+
* @param {Schedule} schedule - Normalized schedule
|
|
103
|
+
* @returns {string} Cron expression
|
|
104
|
+
*/
|
|
105
|
+
toCron(schedule) {
|
|
106
|
+
const [hour, minute] = (schedule.time || '00:00').split(':').map(Number);
|
|
107
|
+
|
|
108
|
+
switch (schedule.frequency) {
|
|
109
|
+
case 'hourly':
|
|
110
|
+
return `${minute} * * * *`;
|
|
111
|
+
case 'daily':
|
|
112
|
+
return `${minute} ${hour} * * *`;
|
|
113
|
+
case 'weekly':
|
|
114
|
+
const dow = schedule.dayOfWeek ?? 0;
|
|
115
|
+
return `${minute} ${hour} * * ${dow}`;
|
|
116
|
+
case 'monthly':
|
|
117
|
+
const dom = schedule.dayOfMonth ?? 1;
|
|
118
|
+
return `${minute} ${hour} ${dom} * *`;
|
|
119
|
+
default:
|
|
120
|
+
return `${minute} ${hour} * * *`; // Default to daily
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Calculate minutes between two schedules on a typical day.
|
|
126
|
+
* Returns null if schedules don't overlap (different frequencies).
|
|
127
|
+
* @param {Schedule} scheduleA - First schedule
|
|
128
|
+
* @param {Schedule} scheduleB - Second schedule
|
|
129
|
+
* @returns {number|null} Minutes between A and B (positive if B is after A)
|
|
130
|
+
*/
|
|
131
|
+
calculateGap(scheduleA, scheduleB) {
|
|
132
|
+
// Only compare schedules with same or compatible frequencies
|
|
133
|
+
if (!this._frequenciesCompatible(scheduleA.frequency, scheduleB.frequency)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const timeA = this._parseTime(scheduleA.time || '00:00');
|
|
138
|
+
const timeB = this._parseTime(scheduleB.time || '00:00');
|
|
139
|
+
|
|
140
|
+
// For weekly, also check day of week
|
|
141
|
+
if (scheduleA.frequency === 'weekly' || scheduleB.frequency === 'weekly') {
|
|
142
|
+
const dowA = scheduleA.dayOfWeek ?? 0;
|
|
143
|
+
const dowB = scheduleB.dayOfWeek ?? 0;
|
|
144
|
+
if (dowA !== dowB) {
|
|
145
|
+
// Different days - calculate day gap
|
|
146
|
+
const dayDiff = ((dowB - dowA) + 7) % 7;
|
|
147
|
+
return dayDiff * 24 * 60 + (timeB - timeA);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// For monthly, also check day of month
|
|
152
|
+
if (scheduleA.frequency === 'monthly' || scheduleB.frequency === 'monthly') {
|
|
153
|
+
const domA = scheduleA.dayOfMonth ?? 1;
|
|
154
|
+
const domB = scheduleB.dayOfMonth ?? 1;
|
|
155
|
+
if (domA !== domB) {
|
|
156
|
+
const dayDiff = domB - domA;
|
|
157
|
+
return dayDiff * 24 * 60 + (timeB - timeA);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return timeB - timeA;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Check if a computation should run on a given date.
|
|
166
|
+
* @param {Schedule} schedule - Computation schedule
|
|
167
|
+
* @param {Date|string} date - Date to check
|
|
168
|
+
* @returns {boolean}
|
|
169
|
+
*/
|
|
170
|
+
shouldRunOnDate(schedule, date) {
|
|
171
|
+
const d = typeof date === 'string' ? new Date(date) : date;
|
|
172
|
+
|
|
173
|
+
switch (schedule.frequency) {
|
|
174
|
+
case 'hourly':
|
|
175
|
+
case 'daily':
|
|
176
|
+
return true;
|
|
177
|
+
case 'weekly':
|
|
178
|
+
return d.getUTCDay() === (schedule.dayOfWeek ?? 0);
|
|
179
|
+
case 'monthly':
|
|
180
|
+
return d.getUTCDate() === (schedule.dayOfMonth ?? 1);
|
|
181
|
+
default:
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// =========================================================================
|
|
187
|
+
// PRIVATE METHODS
|
|
188
|
+
// =========================================================================
|
|
189
|
+
|
|
190
|
+
_validateScheduleFormat(entry) {
|
|
191
|
+
const errors = [];
|
|
192
|
+
const schedule = entry.schedule;
|
|
193
|
+
|
|
194
|
+
if (!schedule) return errors; // Will use default
|
|
195
|
+
|
|
196
|
+
// Validate frequency
|
|
197
|
+
const validFrequencies = ['hourly', 'daily', 'weekly', 'monthly'];
|
|
198
|
+
if (schedule.frequency && !validFrequencies.includes(schedule.frequency)) {
|
|
199
|
+
errors.push({
|
|
200
|
+
computation: entry.name,
|
|
201
|
+
field: 'schedule.frequency',
|
|
202
|
+
message: `Invalid frequency: ${schedule.frequency}. Must be one of: ${validFrequencies.join(', ')}`
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Validate time format
|
|
207
|
+
if (schedule.time && !/^\d{2}:\d{2}$/.test(schedule.time)) {
|
|
208
|
+
errors.push({
|
|
209
|
+
computation: entry.name,
|
|
210
|
+
field: 'schedule.time',
|
|
211
|
+
message: `Invalid time format: ${schedule.time}. Must be HH:MM`
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Validate time values
|
|
216
|
+
if (schedule.time) {
|
|
217
|
+
const [hour, minute] = schedule.time.split(':').map(Number);
|
|
218
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
219
|
+
errors.push({
|
|
220
|
+
computation: entry.name,
|
|
221
|
+
field: 'schedule.time',
|
|
222
|
+
message: `Invalid time values: ${schedule.time}`
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Validate day of week
|
|
228
|
+
if (schedule.dayOfWeek !== undefined) {
|
|
229
|
+
if (schedule.dayOfWeek < 0 || schedule.dayOfWeek > 6) {
|
|
230
|
+
errors.push({
|
|
231
|
+
computation: entry.name,
|
|
232
|
+
field: 'schedule.dayOfWeek',
|
|
233
|
+
message: `Invalid dayOfWeek: ${schedule.dayOfWeek}. Must be 0-6`
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Validate day of month
|
|
239
|
+
if (schedule.dayOfMonth !== undefined) {
|
|
240
|
+
if (schedule.dayOfMonth < 1 || schedule.dayOfMonth > 31) {
|
|
241
|
+
errors.push({
|
|
242
|
+
computation: entry.name,
|
|
243
|
+
field: 'schedule.dayOfMonth',
|
|
244
|
+
message: `Invalid dayOfMonth: ${schedule.dayOfMonth}. Must be 1-31`
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return errors;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
_checkDependencyTiming(entry, entryMap) {
|
|
253
|
+
const issues = [];
|
|
254
|
+
const entrySchedule = this.parseSchedule(entry.schedule);
|
|
255
|
+
|
|
256
|
+
for (const depName of entry.dependencies) {
|
|
257
|
+
const dep = entryMap.get(depName);
|
|
258
|
+
if (!dep) continue;
|
|
259
|
+
|
|
260
|
+
const depSchedule = this.parseSchedule(dep.schedule);
|
|
261
|
+
const gap = this.calculateGap(depSchedule, entrySchedule);
|
|
262
|
+
|
|
263
|
+
if (gap === null) {
|
|
264
|
+
// Different frequencies - can't directly compare
|
|
265
|
+
issues.push({
|
|
266
|
+
severity: 'warning',
|
|
267
|
+
computation: entry.name,
|
|
268
|
+
dependency: depName,
|
|
269
|
+
message: `${entry.name} has different schedule frequency than dependency ${depName}. Manual verification recommended.`,
|
|
270
|
+
suggestion: 'Ensure the dependency runs before this computation on all relevant dates'
|
|
271
|
+
});
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (gap < 0) {
|
|
276
|
+
// Dependent runs BEFORE its dependency
|
|
277
|
+
issues.push({
|
|
278
|
+
severity: 'error',
|
|
279
|
+
computation: entry.name,
|
|
280
|
+
dependency: depName,
|
|
281
|
+
gap,
|
|
282
|
+
message: `${entry.name} is scheduled BEFORE its dependency ${depName} (${Math.abs(gap)} minutes earlier)`,
|
|
283
|
+
suggestion: `Move ${entry.name} to at least ${this.dependencyGapMinutes} minutes after ${depName}`
|
|
284
|
+
});
|
|
285
|
+
} else if (gap < this.dependencyGapMinutes) {
|
|
286
|
+
// Too close - race condition risk
|
|
287
|
+
issues.push({
|
|
288
|
+
severity: 'warning',
|
|
289
|
+
computation: entry.name,
|
|
290
|
+
dependency: depName,
|
|
291
|
+
gap,
|
|
292
|
+
message: `${entry.name} scheduled only ${gap} minutes after dependency ${depName}`,
|
|
293
|
+
suggestion: `Increase gap to at least ${this.dependencyGapMinutes} minutes`
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return issues;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_frequenciesCompatible(freqA, freqB) {
|
|
302
|
+
// Hourly is compatible with everything (runs every hour anyway)
|
|
303
|
+
if (freqA === 'hourly' || freqB === 'hourly') return true;
|
|
304
|
+
|
|
305
|
+
// Same frequency is always compatible
|
|
306
|
+
if (freqA === freqB) return true;
|
|
307
|
+
|
|
308
|
+
// Daily is compatible with weekly/monthly (runs every day)
|
|
309
|
+
if (freqA === 'daily' || freqB === 'daily') return true;
|
|
310
|
+
|
|
311
|
+
// Weekly and monthly are not directly comparable
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
_parseTime(timeStr) {
|
|
316
|
+
const [hour, minute] = timeStr.split(':').map(Number);
|
|
317
|
+
return hour * 60 + minute;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
_log(level, message) {
|
|
321
|
+
if (this.logger && typeof this.logger.log === 'function') {
|
|
322
|
+
this.logger.log(level, `[ScheduleValidator] ${message}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
module.exports = { ScheduleValidator };
|