bulltrackers-module 1.0.554 → 1.0.556
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/alert-system/helpers/alert_helpers.js +147 -55
- package/functions/computation-system/helpers/computation_dispatcher.js +87 -2
- package/functions/computation-system/scripts/force_run.js +72 -0
- package/functions/computation-system/workflows/bulltrackers_pipeline.yaml +26 -6
- package/package.json +1 -1
|
@@ -18,7 +18,7 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
18
18
|
|
|
19
19
|
// 2. Find all users subscribed to this PI and alert type
|
|
20
20
|
// Use computationName (e.g., 'RiskScoreIncrease') to map to alertConfig keys
|
|
21
|
-
const subscriptions = await findSubscriptionsForPI(db, logger, piCid, alertType.computationName);
|
|
21
|
+
const subscriptions = await findSubscriptionsForPI(db, logger, piCid, alertType.computationName, computationDate, dependencies);
|
|
22
22
|
|
|
23
23
|
if (subscriptions.length === 0) {
|
|
24
24
|
logger.log('INFO', `[processAlertForPI] No subscriptions found for PI ${piCid}, alert type ${alertType.id}`);
|
|
@@ -65,7 +65,6 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
// Use writeWithMigration to write to new path (with legacy fallback)
|
|
68
|
-
// notifications is a subcollection, so we need isCollection: true and documentId
|
|
69
68
|
const writePromise = writeWithMigration(
|
|
70
69
|
db,
|
|
71
70
|
'signedInUsers',
|
|
@@ -92,10 +91,18 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
92
91
|
// Wait for all notifications to be written
|
|
93
92
|
await Promise.all(notificationPromises);
|
|
94
93
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
// 5. Notify the PI themselves if they are a signed-in user (Optional feature)
|
|
95
|
+
await notifyPIIfSignedIn(db, logger, piCid, alertType, subscriptions.length);
|
|
96
|
+
|
|
97
|
+
// 6. Update global rootdata collection for computation system
|
|
98
|
+
// (Wrap in try-catch to prevent crashing the alert if metrics fail)
|
|
99
|
+
try {
|
|
100
|
+
const { updateAlertHistoryRootData } = require('../../generic-api/user-api/helpers/rootdata/rootdata_aggregation_helpers');
|
|
101
|
+
const triggeredUserCids = subscriptions.map(s => s.userCid);
|
|
102
|
+
await updateAlertHistoryRootData(db, logger, piCid, alertType, computationMetadata, computationDate, triggeredUserCids);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
logger.log('WARN', `[processAlertForPI] Failed to update history rootdata: ${e.message}`);
|
|
105
|
+
}
|
|
99
106
|
|
|
100
107
|
logger.log('SUCCESS', `[processAlertForPI] Created ${notificationPromises.length} notifications for PI ${piCid}, alert type ${alertType.id}`);
|
|
101
108
|
|
|
@@ -107,20 +114,19 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
107
114
|
|
|
108
115
|
/**
|
|
109
116
|
* Find all users who should receive alerts for a PI and alert type
|
|
110
|
-
*
|
|
117
|
+
* Uses WatchlistMembershipData/{date} to find users, then reads watchlists from SignedInUsers/{cid}/watchlists
|
|
111
118
|
*/
|
|
112
|
-
async function findSubscriptionsForPI(db, logger, piCid, alertTypeId) {
|
|
119
|
+
async function findSubscriptionsForPI(db, logger, piCid, alertTypeId, computationDate, dependencies = {}) {
|
|
113
120
|
const subscriptions = [];
|
|
114
121
|
|
|
115
122
|
// Map computation names to watchlist alertConfig keys
|
|
116
|
-
// The alertTypeId is actually the computation name (e.g., 'RiskScoreIncrease')
|
|
117
123
|
const computationToConfigKey = {
|
|
118
124
|
'RiskScoreIncrease': 'increasedRisk',
|
|
119
125
|
'SignificantVolatility': 'volatilityChanges',
|
|
120
126
|
'NewSectorExposure': 'newSector',
|
|
121
127
|
'PositionInvestedIncrease': 'increasedPositionSize',
|
|
122
128
|
'NewSocialPost': 'newSocialPost',
|
|
123
|
-
'TestSystemProbe': 'increasedRisk' // Hack: Map to 'increasedRisk'
|
|
129
|
+
'TestSystemProbe': 'increasedRisk' // Hack: Map to 'increasedRisk' key
|
|
124
130
|
};
|
|
125
131
|
|
|
126
132
|
const configKey = computationToConfigKey[alertTypeId];
|
|
@@ -129,7 +135,121 @@ async function findSubscriptionsForPI(db, logger, piCid, alertTypeId) {
|
|
|
129
135
|
return subscriptions;
|
|
130
136
|
}
|
|
131
137
|
|
|
132
|
-
//
|
|
138
|
+
// Step 1: Load WatchlistMembershipData/{date} to find which users have this PI in their watchlist
|
|
139
|
+
const piCidStr = String(piCid);
|
|
140
|
+
let userCids = [];
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const membershipRef = db.collection('WatchlistMembershipData').doc(computationDate);
|
|
144
|
+
const membershipDoc = await membershipRef.get();
|
|
145
|
+
|
|
146
|
+
if (membershipDoc.exists) {
|
|
147
|
+
const membershipData = membershipDoc.data();
|
|
148
|
+
const piMembership = membershipData[piCidStr];
|
|
149
|
+
|
|
150
|
+
if (piMembership && piMembership.users && Array.isArray(piMembership.users)) {
|
|
151
|
+
userCids = piMembership.users.map(cid => String(cid));
|
|
152
|
+
logger.log('INFO', `[findSubscriptionsForPI] Found ${userCids.length} users with PI ${piCid} in watchlist from WatchlistMembershipData/${computationDate}`);
|
|
153
|
+
} else {
|
|
154
|
+
logger.log('INFO', `[findSubscriptionsForPI] No users found for PI ${piCid} in WatchlistMembershipData/${computationDate}`);
|
|
155
|
+
return subscriptions;
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
logger.log('WARN', `[findSubscriptionsForPI] WatchlistMembershipData/${computationDate} not found, falling back to scanning all watchlists`);
|
|
159
|
+
// Fallback to legacy method if membership data doesn't exist
|
|
160
|
+
return await findSubscriptionsForPILegacy(db, logger, piCid, alertTypeId, configKey);
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
logger.log('ERROR', `[findSubscriptionsForPI] Error loading WatchlistMembershipData: ${error.message}`);
|
|
164
|
+
// Fallback to legacy method on error
|
|
165
|
+
return await findSubscriptionsForPILegacy(db, logger, piCid, alertTypeId, configKey);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Step 2: For each user, read their watchlists from SignedInUsers/{cid}/watchlists
|
|
169
|
+
const { readWithMigration } = require('../../generic-api/user-api/helpers/core/path_resolution_helpers');
|
|
170
|
+
const { collectionRegistry } = dependencies;
|
|
171
|
+
const config = dependencies.config || {};
|
|
172
|
+
|
|
173
|
+
for (const userCidStr of userCids) {
|
|
174
|
+
try {
|
|
175
|
+
const userCid = Number(userCidStr);
|
|
176
|
+
|
|
177
|
+
// Read all watchlists for this user from new path
|
|
178
|
+
const watchlistsResult = await readWithMigration(
|
|
179
|
+
db,
|
|
180
|
+
'signedInUsers',
|
|
181
|
+
'watchlists',
|
|
182
|
+
{ cid: userCid },
|
|
183
|
+
{
|
|
184
|
+
isCollection: true,
|
|
185
|
+
dataType: 'watchlists',
|
|
186
|
+
config,
|
|
187
|
+
logger,
|
|
188
|
+
collectionRegistry: collectionRegistry
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
if (!watchlistsResult) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Get watchlists from snapshot or data
|
|
197
|
+
let watchlists = [];
|
|
198
|
+
if (watchlistsResult.snapshot && !watchlistsResult.snapshot.empty) {
|
|
199
|
+
watchlists = watchlistsResult.snapshot.docs.map(doc => ({
|
|
200
|
+
id: doc.id,
|
|
201
|
+
...doc.data()
|
|
202
|
+
}));
|
|
203
|
+
} else if (watchlistsResult.data) {
|
|
204
|
+
// If it's a single document, wrap it
|
|
205
|
+
watchlists = [watchlistsResult.data];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Step 3: Check each watchlist for the PI and alert config
|
|
209
|
+
for (const watchlistData of watchlists) {
|
|
210
|
+
if (watchlistData.type === 'static' && watchlistData.items && Array.isArray(watchlistData.items)) {
|
|
211
|
+
for (const item of watchlistData.items) {
|
|
212
|
+
if (Number(item.cid) === Number(piCid)) {
|
|
213
|
+
// Check if this alert type is enabled
|
|
214
|
+
const isTestProbe = alertTypeId === 'TestSystemProbe';
|
|
215
|
+
const isEnabled = item.alertConfig && item.alertConfig[configKey] === true;
|
|
216
|
+
|
|
217
|
+
// If it's the probe OR the user explicitly enabled it
|
|
218
|
+
if (isTestProbe || isEnabled) {
|
|
219
|
+
subscriptions.push({
|
|
220
|
+
userCid: userCid,
|
|
221
|
+
piCid: piCid,
|
|
222
|
+
piUsername: item.username || `PI-${piCid}`,
|
|
223
|
+
watchlistId: watchlistData.id || watchlistData.watchlistId,
|
|
224
|
+
watchlistName: watchlistData.name || 'Unnamed Watchlist',
|
|
225
|
+
alertConfig: item.alertConfig
|
|
226
|
+
});
|
|
227
|
+
break; // Found in this watchlist, no need to check other items
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
logger.log('WARN', `[findSubscriptionsForPI] Error reading watchlists for user ${userCidStr}: ${error.message}`);
|
|
235
|
+
// Continue with next user
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
logger.log('INFO', `[findSubscriptionsForPI] Found ${subscriptions.length} subscriptions for PI ${piCid}, alert type ${alertTypeId}`);
|
|
241
|
+
return subscriptions;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Legacy fallback method - scans all watchlists (used if WatchlistMembershipData is not available)
|
|
246
|
+
*/
|
|
247
|
+
async function findSubscriptionsForPILegacy(db, logger, piCid, alertTypeId, configKey) {
|
|
248
|
+
const subscriptions = [];
|
|
249
|
+
|
|
250
|
+
logger.log('INFO', `[findSubscriptionsForPILegacy] Using legacy method to find subscriptions for PI ${piCid}`);
|
|
251
|
+
|
|
252
|
+
// Get all watchlists from legacy path
|
|
133
253
|
const watchlistsCollection = db.collection('watchlists');
|
|
134
254
|
const watchlistsSnapshot = await watchlistsCollection.get();
|
|
135
255
|
|
|
@@ -144,8 +264,10 @@ async function findSubscriptionsForPI(db, logger, piCid, alertTypeId) {
|
|
|
144
264
|
if (listData.type === 'static' && listData.items && Array.isArray(listData.items)) {
|
|
145
265
|
for (const item of listData.items) {
|
|
146
266
|
if (Number(item.cid) === Number(piCid)) {
|
|
147
|
-
|
|
148
|
-
|
|
267
|
+
const isTestProbe = alertTypeId === 'TestSystemProbe';
|
|
268
|
+
const isEnabled = item.alertConfig && item.alertConfig[configKey] === true;
|
|
269
|
+
|
|
270
|
+
if (isTestProbe || isEnabled) {
|
|
149
271
|
subscriptions.push({
|
|
150
272
|
userCid: userCid,
|
|
151
273
|
piCid: piCid,
|
|
@@ -154,19 +276,14 @@ async function findSubscriptionsForPI(db, logger, piCid, alertTypeId) {
|
|
|
154
276
|
watchlistName: listData.name || 'Unnamed Watchlist',
|
|
155
277
|
alertConfig: item.alertConfig
|
|
156
278
|
});
|
|
157
|
-
break;
|
|
279
|
+
break;
|
|
158
280
|
}
|
|
159
281
|
}
|
|
160
282
|
}
|
|
161
283
|
}
|
|
162
|
-
|
|
163
|
-
// Check dynamic watchlists - if the PI matches the computation result, check alertConfig
|
|
164
|
-
// Note: Dynamic watchlists are handled separately as they're based on computation results
|
|
165
|
-
// For now, we only process static watchlists here
|
|
166
284
|
}
|
|
167
285
|
}
|
|
168
286
|
|
|
169
|
-
logger.log('INFO', `[findSubscriptionsForPI] Found ${subscriptions.length} subscriptions for PI ${piCid}, alert type ${alertTypeId}`);
|
|
170
287
|
return subscriptions;
|
|
171
288
|
}
|
|
172
289
|
|
|
@@ -178,10 +295,6 @@ function shouldTriggerAlert(subscription, alertTypeId) {
|
|
|
178
295
|
if (!subscription.thresholds || Object.keys(subscription.thresholds).length === 0) {
|
|
179
296
|
return true;
|
|
180
297
|
}
|
|
181
|
-
|
|
182
|
-
// Threshold checking logic would go here
|
|
183
|
-
// For now, we'll implement basic threshold checks in the trigger function
|
|
184
|
-
// This is a placeholder for future enhancement
|
|
185
298
|
return true;
|
|
186
299
|
}
|
|
187
300
|
|
|
@@ -195,9 +308,7 @@ async function getPIUsername(db, piCid) {
|
|
|
195
308
|
const { getPIUsernameFromMasterList } = require('../../generic-api/user-api/helpers/core/user_status_helpers');
|
|
196
309
|
const username = await getPIUsernameFromMasterList(db, piCid, null, null);
|
|
197
310
|
|
|
198
|
-
if (username)
|
|
199
|
-
return username;
|
|
200
|
-
}
|
|
311
|
+
if (username) return username;
|
|
201
312
|
|
|
202
313
|
// Fallback: try to get from any subscription
|
|
203
314
|
const subscriptionsSnapshot = await db.collection('watchlist_subscriptions')
|
|
@@ -221,18 +332,14 @@ async function getPIUsername(db, piCid) {
|
|
|
221
332
|
*/
|
|
222
333
|
async function notifyPIIfSignedIn(db, logger, piCid, alertType, alertCount) {
|
|
223
334
|
try {
|
|
224
|
-
|
|
225
|
-
const userRef = db.collection('signed_in_users').doc(String(piCid));
|
|
335
|
+
const userRef = db.collection('signedInUsers').doc(String(piCid));
|
|
226
336
|
const userDoc = await userRef.get();
|
|
227
337
|
|
|
228
|
-
if (!userDoc.exists)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// Create a notification for the PI
|
|
233
|
-
const notificationRef = db.collection('user_alerts')
|
|
338
|
+
if (!userDoc.exists) return;
|
|
339
|
+
|
|
340
|
+
const notificationRef = db.collection('signedInUsers')
|
|
234
341
|
.doc(String(piCid))
|
|
235
|
-
.collection('
|
|
342
|
+
.collection('user_alerts_metrics') // Changed from user_alerts/triggered to avoid collision
|
|
236
343
|
.doc(`trigger_${Date.now()}_${alertType.id}`);
|
|
237
344
|
|
|
238
345
|
await notificationRef.set({
|
|
@@ -246,8 +353,8 @@ async function notifyPIIfSignedIn(db, logger, piCid, alertType, alertCount) {
|
|
|
246
353
|
|
|
247
354
|
logger.log('INFO', `[notifyPIIfSignedIn] Notified PI ${piCid} that ${alertCount} users received ${alertType.id} alert`);
|
|
248
355
|
} catch (error) {
|
|
249
|
-
|
|
250
|
-
|
|
356
|
+
// Silent fail - non-critical
|
|
357
|
+
logger.log('DEBUG', `[notifyPIIfSignedIn] Skipped for PI ${piCid}`);
|
|
251
358
|
}
|
|
252
359
|
}
|
|
253
360
|
|
|
@@ -290,25 +397,17 @@ function tryDecompress(data) {
|
|
|
290
397
|
|
|
291
398
|
/**
|
|
292
399
|
* Read and decompress computation results
|
|
293
|
-
* Handles two result formats:
|
|
294
|
-
* 1. { cids: [...], metadata: {...} } - Global metadata
|
|
295
|
-
* 2. { cids: [...], userId1: {...}, userId2: {...} } - Per-user metadata
|
|
296
400
|
*/
|
|
297
401
|
function readComputationResults(docData) {
|
|
298
402
|
try {
|
|
299
|
-
// Try to decompress if needed
|
|
300
403
|
const decompressed = tryDecompress(docData);
|
|
301
404
|
|
|
302
|
-
// If decompressed is an object with cids, return it
|
|
303
405
|
if (decompressed && typeof decompressed === 'object') {
|
|
304
|
-
// Handle structure: { cids: [...], metadata: {...} }
|
|
305
406
|
if (decompressed.cids && Array.isArray(decompressed.cids)) {
|
|
306
|
-
// Check if there's per-user data (keys that are numbers)
|
|
307
407
|
const userDataKeys = Object.keys(decompressed)
|
|
308
408
|
.filter(key => key !== 'cids' && key !== 'metadata' && /^\d+$/.test(key));
|
|
309
409
|
|
|
310
410
|
if (userDataKeys.length > 0) {
|
|
311
|
-
// Format 2: Per-user metadata stored as { cids: [...], userId: {...} }
|
|
312
411
|
return {
|
|
313
412
|
cids: decompressed.cids,
|
|
314
413
|
perUserData: userDataKeys.reduce((acc, key) => {
|
|
@@ -318,7 +417,6 @@ function readComputationResults(docData) {
|
|
|
318
417
|
globalMetadata: decompressed.metadata || {}
|
|
319
418
|
};
|
|
320
419
|
} else {
|
|
321
|
-
// Format 1: Global metadata
|
|
322
420
|
return {
|
|
323
421
|
cids: decompressed.cids,
|
|
324
422
|
metadata: decompressed.metadata || {}
|
|
@@ -326,7 +424,6 @@ function readComputationResults(docData) {
|
|
|
326
424
|
}
|
|
327
425
|
}
|
|
328
426
|
|
|
329
|
-
// If it's a flat object with CID keys but no cids array, extract them
|
|
330
427
|
const cids = Object.keys(decompressed)
|
|
331
428
|
.filter(key => /^\d+$/.test(key))
|
|
332
429
|
.map(key => Number(key));
|
|
@@ -344,7 +441,6 @@ function readComputationResults(docData) {
|
|
|
344
441
|
};
|
|
345
442
|
}
|
|
346
443
|
}
|
|
347
|
-
|
|
348
444
|
return { cids: [], metadata: {}, perUserData: {} };
|
|
349
445
|
} catch (error) {
|
|
350
446
|
console.error('[readComputationResults] Error reading results', error);
|
|
@@ -357,9 +453,7 @@ function readComputationResults(docData) {
|
|
|
357
453
|
*/
|
|
358
454
|
async function readComputationResultsWithShards(db, docData, docRef) {
|
|
359
455
|
try {
|
|
360
|
-
// Check if data is sharded
|
|
361
456
|
if (docData._sharded === true && docData._shardCount) {
|
|
362
|
-
// Data is stored in shards - read all shards and merge
|
|
363
457
|
const shardsCol = docRef.collection('_shards');
|
|
364
458
|
const shardsSnapshot = await shardsCol.get();
|
|
365
459
|
|
|
@@ -373,8 +467,6 @@ async function readComputationResultsWithShards(db, docData, docRef) {
|
|
|
373
467
|
return readComputationResults(mergedData);
|
|
374
468
|
}
|
|
375
469
|
}
|
|
376
|
-
|
|
377
|
-
// Data is in the main document (compressed or not)
|
|
378
470
|
return readComputationResults(docData);
|
|
379
471
|
} catch (error) {
|
|
380
472
|
console.error('[readComputationResultsWithShards] Error reading sharded results', error);
|
|
@@ -387,6 +479,6 @@ module.exports = {
|
|
|
387
479
|
findSubscriptionsForPI,
|
|
388
480
|
getPIUsername,
|
|
389
481
|
readComputationResults,
|
|
390
|
-
readComputationResultsWithShards
|
|
391
|
-
|
|
392
|
-
|
|
482
|
+
readComputationResultsWithShards,
|
|
483
|
+
notifyPIIfSignedIn // Exporting this as well since it's defined
|
|
484
|
+
};
|
|
@@ -132,7 +132,6 @@ async function getStableDateSession(config, dependencies, passToRun, dateLimitSt
|
|
|
132
132
|
return allDates;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
// =============================================================================
|
|
136
135
|
// MAIN ENTRY POINT
|
|
137
136
|
// =============================================================================
|
|
138
137
|
async function dispatchComputationPass(config, dependencies, computationManifest, reqBody = {}) {
|
|
@@ -144,14 +143,100 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
144
143
|
else if (action === 'SWEEP') {
|
|
145
144
|
return handleSweepDispatch(config, dependencies, computationManifest, reqBody);
|
|
146
145
|
}
|
|
147
|
-
// [NEW] Handler for Final Forensics Reporting
|
|
148
146
|
else if (action === 'REPORT') {
|
|
149
147
|
return handleFinalSweepReporting(config, dependencies, computationManifest, reqBody);
|
|
150
148
|
}
|
|
149
|
+
// [NEW] FORCE RUN HANDLER
|
|
150
|
+
else if (action === 'FORCE_RUN') {
|
|
151
|
+
return handleForceRun(config, dependencies, computationManifest, reqBody);
|
|
152
|
+
}
|
|
151
153
|
|
|
152
154
|
return handleStandardDispatch(config, dependencies, computationManifest, reqBody);
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
// =============================================================================
|
|
158
|
+
// NEW: Force Run Handler (Bypasses Checks)
|
|
159
|
+
// =============================================================================
|
|
160
|
+
async function handleForceRun(config, dependencies, computationManifest, reqBody) {
|
|
161
|
+
const { logger } = dependencies;
|
|
162
|
+
const pubsubUtils = new PubSubUtils(dependencies);
|
|
163
|
+
const computationName = reqBody.computation; // Required
|
|
164
|
+
const dateInput = reqBody.date; // Optional (YYYY-MM-DD)
|
|
165
|
+
|
|
166
|
+
if (!computationName) {
|
|
167
|
+
throw new Error('Force Run requires "computation" name.');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 1. Verify Computation Exists
|
|
171
|
+
const manifestItem = computationManifest.find(c => normalizeName(c.name) === normalizeName(computationName));
|
|
172
|
+
if (!manifestItem) {
|
|
173
|
+
throw new Error(`Computation '${computationName}' not found in manifest.`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 2. Determine Target Dates
|
|
177
|
+
let targetDates = [];
|
|
178
|
+
if (dateInput) {
|
|
179
|
+
// Single Date Mode
|
|
180
|
+
targetDates = [dateInput];
|
|
181
|
+
} else {
|
|
182
|
+
// All Dates Mode (Backfill)
|
|
183
|
+
logger.log('INFO', `[ForceRun] No date provided. Calculating date range for ${computationName}...`);
|
|
184
|
+
const earliestDates = await getEarliestDataDates(config, dependencies);
|
|
185
|
+
// Calculate from system start until today
|
|
186
|
+
targetDates = getExpectedDateStrings(earliestDates.absoluteEarliest, new Date());
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
logger.log('WARN', `[ForceRun] 🚨 MANUALLY Triggering ${computationName} for ${targetDates.length} dates. Pass: ${manifestItem.pass}`);
|
|
190
|
+
|
|
191
|
+
// 3. Construct Tasks
|
|
192
|
+
const dispatchId = crypto.randomUUID();
|
|
193
|
+
const tasks = targetDates.map(date => {
|
|
194
|
+
const traceId = crypto.randomBytes(16).toString('hex');
|
|
195
|
+
const spanId = crypto.randomBytes(8).toString('hex');
|
|
196
|
+
return {
|
|
197
|
+
action: 'RUN_COMPUTATION_DATE',
|
|
198
|
+
computation: manifestItem.name,
|
|
199
|
+
date: date,
|
|
200
|
+
pass: manifestItem.pass || "1",
|
|
201
|
+
dispatchId: dispatchId,
|
|
202
|
+
triggerReason: 'MANUAL_FORCE_API',
|
|
203
|
+
resources: reqBody.resources || 'standard',
|
|
204
|
+
// Trace context allows you to find these specific runs in Cloud Trace
|
|
205
|
+
traceContext: { traceId, spanId, sampled: true }
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// 4. Batch Publish (Chunked to stay under Pub/Sub limits)
|
|
210
|
+
const CHUNK_SIZE = 250; // Safe batch size
|
|
211
|
+
const topic = (reqBody.resources === 'high-mem')
|
|
212
|
+
? (config.computationTopicHighMem || 'computation-tasks-highmem')
|
|
213
|
+
: (config.computationTopicStandard || 'computation-tasks');
|
|
214
|
+
|
|
215
|
+
let dispatchedCount = 0;
|
|
216
|
+
const chunks = [];
|
|
217
|
+
for (let i = 0; i < tasks.length; i += CHUNK_SIZE) {
|
|
218
|
+
chunks.push(tasks.slice(i, i + CHUNK_SIZE));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Publish chunks sequentially to avoid memory spikes
|
|
222
|
+
for (const chunk of chunks) {
|
|
223
|
+
await pubsubUtils.batchPublishTasks(dependencies, {
|
|
224
|
+
topicName: topic,
|
|
225
|
+
tasks: chunk,
|
|
226
|
+
taskType: 'manual-force-run'
|
|
227
|
+
});
|
|
228
|
+
dispatchedCount += chunk.length;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
status: 'FORCED',
|
|
233
|
+
computation: computationName,
|
|
234
|
+
mode: dateInput ? 'SINGLE_DATE' : 'ALL_DATES',
|
|
235
|
+
datesTriggered: dispatchedCount,
|
|
236
|
+
targetTopic: topic
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
155
240
|
// =============================================================================
|
|
156
241
|
// NEW: Final Sweep Reporting Handler
|
|
157
242
|
// =============================================================================
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Force Run Control Script
|
|
3
|
+
* Usage:
|
|
4
|
+
* node scripts/force_run.js <ComputationName> [YYYY-MM-DD]
|
|
5
|
+
* * Examples:
|
|
6
|
+
* node scripts/force_run.js TestSystemProbe 2026-01-02 (Runs specific day)
|
|
7
|
+
* node scripts/force_run.js TestSystemProbe (Runs ALL days from start)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
require('dotenv').config(); // Load environment variables
|
|
11
|
+
const { PubSub } = require('@google-cloud/pubsub');
|
|
12
|
+
|
|
13
|
+
// CONFIGURATION
|
|
14
|
+
const PROJECT_ID = process.env.GCP_PROJECT_ID || 'stocks-12345';
|
|
15
|
+
const TOPIC_NAME = process.env.PUBSUB_TOPIC_DISPATCH || 'dispatch-topic'; // Must match orchestrator_config.js
|
|
16
|
+
const SYSTEM_START_DATE = '2026-01-01'; // The beginning of time for your system
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const computationName = args[0];
|
|
21
|
+
const specificDate = args[1];
|
|
22
|
+
|
|
23
|
+
if (!computationName) {
|
|
24
|
+
console.error('❌ Error: Computation Name is required.');
|
|
25
|
+
console.log('Usage: node scripts/force_run.js <ComputationName> [YYYY-MM-DD]');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pubsub = new PubSub({ projectId: PROJECT_ID });
|
|
30
|
+
const topic = pubsub.topic(TOPIC_NAME);
|
|
31
|
+
|
|
32
|
+
// 1. Logic: Single Date vs. All Dates
|
|
33
|
+
if (specificDate) {
|
|
34
|
+
await triggerComputation(topic, computationName, specificDate);
|
|
35
|
+
} else {
|
|
36
|
+
console.log(`⚠️ No date provided. Triggering REPLAY for ${computationName} from ${SYSTEM_START_DATE} to Today...`);
|
|
37
|
+
|
|
38
|
+
let currentDate = new Date(SYSTEM_START_DATE);
|
|
39
|
+
const today = new Date();
|
|
40
|
+
|
|
41
|
+
while (currentDate <= today) {
|
|
42
|
+
const dateStr = currentDate.toISOString().split('T')[0];
|
|
43
|
+
await triggerComputation(topic, computationName, dateStr);
|
|
44
|
+
|
|
45
|
+
// Move to next day
|
|
46
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
47
|
+
|
|
48
|
+
// Small throttle to prevent flooding Pub/Sub quota if years of data
|
|
49
|
+
await new Promise(r => setTimeout(r, 100));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function triggerComputation(topic, computation, date) {
|
|
55
|
+
const payload = {
|
|
56
|
+
computationName: computation,
|
|
57
|
+
date: date,
|
|
58
|
+
force: true, // Tells Orchestrator to ignore "Already Complete" status
|
|
59
|
+
source: 'manual-cli' // Audit trail
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const dataBuffer = Buffer.from(JSON.stringify(payload));
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const messageId = await topic.publishMessage({ data: dataBuffer });
|
|
66
|
+
console.log(`✅ [${date}] Triggered ${computation} (Msg ID: ${messageId})`);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error(`❌ [${date}] Failed to trigger ${computation}:`, error.message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
main().catch(console.error);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Cloud Workflows: Precision Cursor-Based Orchestrator
|
|
1
|
+
# Cloud Workflows: Precision Cursor-Based Orchestrator with Manual Override
|
|
2
2
|
main:
|
|
3
3
|
params: [input]
|
|
4
4
|
steps:
|
|
@@ -9,6 +9,12 @@ main:
|
|
|
9
9
|
- current_date: '${text.split(time.format(sys.now()), "T")[0]}'
|
|
10
10
|
- date_to_run: '${default(map.get(input, "date"), current_date)}'
|
|
11
11
|
|
|
12
|
+
# --- 🔀 NEW: CHECK FOR MANUAL OVERRIDE ---
|
|
13
|
+
- check_manual_override:
|
|
14
|
+
switch:
|
|
15
|
+
- condition: '${map.get(input, "action") == "FORCE_RUN"}'
|
|
16
|
+
next: execute_manual_force_run
|
|
17
|
+
|
|
12
18
|
# --- PHASE 1: EXECUTION (Standard + High Mem Retry) ---
|
|
13
19
|
- run_sequential_passes:
|
|
14
20
|
for:
|
|
@@ -80,7 +86,7 @@ main:
|
|
|
80
86
|
- next_loop_retry:
|
|
81
87
|
next: sequential_date_loop
|
|
82
88
|
|
|
83
|
-
# --- VERIFICATION & SWEEP ---
|
|
89
|
+
# --- VERIFICATION & SWEEP (CRITICAL LOGIC PRESERVED) ---
|
|
84
90
|
- verify_pass_completion:
|
|
85
91
|
call: http.post
|
|
86
92
|
args:
|
|
@@ -116,8 +122,6 @@ main:
|
|
|
116
122
|
seconds: '${int(sweep_task.eta)}'
|
|
117
123
|
|
|
118
124
|
# --- PHASE 2: FINAL FORENSIC REPORTING ---
|
|
119
|
-
# Triggered after ALL execution attempts for this pass (Standard -> Verify -> HighMem Sweep)
|
|
120
|
-
# We ask the dispatcher to run the FinalSweepReporter for the target date.
|
|
121
125
|
- run_final_forensics:
|
|
122
126
|
for:
|
|
123
127
|
value: pass_id
|
|
@@ -139,5 +143,21 @@ main:
|
|
|
139
143
|
args:
|
|
140
144
|
text: '${"📝 FINAL REPORT: Pass " + pass_id + " -> " + report_res.body.issuesFound + " detailed forensic documents created."}'
|
|
141
145
|
|
|
142
|
-
-
|
|
143
|
-
return: "Pipeline Complete with Forensic Analysis"
|
|
146
|
+
- finish_standard:
|
|
147
|
+
return: "Pipeline Complete with Forensic Analysis"
|
|
148
|
+
|
|
149
|
+
# --- 🚨 MANUAL OVERRIDE EXECUTION PATH ---
|
|
150
|
+
- execute_manual_force_run:
|
|
151
|
+
call: http.post
|
|
152
|
+
args:
|
|
153
|
+
url: '${"https://europe-west1-" + project + ".cloudfunctions.net/computation-dispatcher"}'
|
|
154
|
+
body:
|
|
155
|
+
action: 'FORCE_RUN'
|
|
156
|
+
computation: '${input.computation}'
|
|
157
|
+
date: '${map.get(input, "date")}' # Can be null for ALL_DATES
|
|
158
|
+
resources: '${map.get(input, "resources")}'
|
|
159
|
+
auth: { type: OIDC }
|
|
160
|
+
result: force_res
|
|
161
|
+
|
|
162
|
+
- finish_manual:
|
|
163
|
+
return: '${force_res.body}'
|