bulltrackers-module 1.0.720 → 1.0.722

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.
@@ -1,41 +1,44 @@
1
1
  /**
2
2
  * @fileoverview Main orchestration logic.
3
- * REFACTORED: This file now contains the main pipe functions
4
- * that are called by the Cloud Function entry points.
5
- * It includes the new HTTP handlers for Workflow-driven "Slow-Trickle" updates.
6
- * They receive all dependencies.
3
+ * REFACTORED: Implements 'Active Scheduling' via Cloud Tasks to enforce 1-hour gaps.
4
+ * This allows the Workflow to finish immediately while Cloud Tasks manages the pacing.
7
5
  */
8
6
  const { checkDiscoveryNeed, getDiscoveryCandidates, dispatchDiscovery } = require('./helpers/discovery_helpers');
9
7
  const { getUpdateTargets, dispatchUpdates } = require('./helpers/update_helpers');
10
8
  const { FieldValue } = require('@google-cloud/firestore');
9
+ const { CloudTasksClient } = require('@google-cloud/tasks');
10
+
11
+ // Initialize Cloud Tasks Client
12
+ const cloudTasksClient = new CloudTasksClient();
11
13
 
12
14
  /**
13
15
  * ENTRY POINT: HTTP Handler for Workflow Interaction
14
- * Map this function to your HTTP Trigger in your index.js/exports.
15
- * This handles the "PLAN" and "EXECUTE_WINDOW" phases of the slow-trickle update.
16
- * * @param {object} req - Express request object.
17
- * @param {object} res - Express response object.
18
- * @param {object} dependencies - Contains logger, db, firestoreUtils, pubsubUtils.
19
- * @param {object} config - Global configuration.
20
16
  */
21
17
  async function handleOrchestratorHttp(req, res, dependencies, config) {
22
18
  const { logger } = dependencies;
23
19
  const body = req.body || {};
24
- const { action, userType, date, windows, planId, windowId } = body;
20
+ const { action, userType, date, windows, planId, windowId, orchestratorUrlOverride } = body;
25
21
 
26
22
  logger.log('INFO', `[Orchestrator HTTP] Received request: ${action}`, body);
27
23
 
28
24
  try {
29
25
  if (action === 'PLAN') {
30
- // PHASE 1: Find users and split them into Firestore documents
26
+ // PHASE 1: Plan AND Schedule
31
27
  if (!userType || !date) {
32
28
  throw new Error("Missing userType or date for PLAN action");
33
29
  }
34
- const result = await planDailyUpdates(userType, date, windows || 10, config, dependencies);
30
+
31
+ // Determine self-URL for callback
32
+ const project = process.env.GCP_PROJECT || process.env.GOOGLE_CLOUD_PROJECT;
33
+ const location = process.env.GCP_LOCATION || 'us-central1'; // Default to us-central1 if not set
34
+ // Fallback URL construction or use passed override
35
+ const orchestratorUrl = orchestratorUrlOverride || `https://${location}-${project}.cloudfunctions.net/orchestrator-http`;
36
+
37
+ const result = await planDailyUpdates(userType, date, windows || 10, config, dependencies, orchestratorUrl);
35
38
  res.status(200).send(result);
36
39
 
37
40
  } else if (action === 'EXECUTE_WINDOW') {
38
- // PHASE 2: Load specific window and dispatch
41
+ // PHASE 2: Execute (Called by Cloud Tasks)
39
42
  if (!planId || !windowId) {
40
43
  throw new Error("Missing planId or windowId for EXECUTE_WINDOW action");
41
44
  }
@@ -43,7 +46,6 @@ async function handleOrchestratorHttp(req, res, dependencies, config) {
43
46
  res.status(200).send(result);
44
47
 
45
48
  } else if (action === 'LEGACY_RUN') {
46
- // Support for triggering the old brute-force method via HTTP if needed
47
49
  await runUpdateOrchestrator(config, dependencies);
48
50
  res.status(200).send({ status: 'Completed legacy run' });
49
51
 
@@ -57,96 +59,141 @@ async function handleOrchestratorHttp(req, res, dependencies, config) {
57
59
  }
58
60
 
59
61
  /**
60
- * LOGIC: Plan the updates (Split into windows)
62
+ * LOGIC: Plan the updates (Split into windows AND Schedule Execution)
61
63
  * 1. Fetches all users needing updates.
62
64
  * 2. Shuffles them.
63
65
  * 3. Splits them into 'n' windows.
64
- * 4. Saves the windows to Firestore.
66
+ * 4. Schedules Cloud Tasks for each window with a 1-hour delay between them.
65
67
  */
66
- async function planDailyUpdates(userType, date, numberOfWindows, config, deps) {
68
+ async function planDailyUpdates(userType, date, numberOfWindows, config, deps, orchestratorUrl) {
67
69
  const { logger, db } = deps;
68
70
 
71
+ // Cloud Tasks Config
72
+ const project = process.env.GCP_PROJECT || process.env.GOOGLE_CLOUD_PROJECT;
73
+ const location = process.env.GCP_LOCATION || 'us-central1';
74
+ const queue = process.env.GCP_QUEUE_NAME || 'orchestrator-queue'; // MUST EXIST in Cloud Console
75
+
76
+ // Construct the fully qualified queue name
77
+ const parentQueue = cloudTasksClient.queuePath(project, location, queue);
78
+
69
79
  // 1. Get ALL targets
70
- // We construct thresholds to capture everyone due for today
71
80
  const now = new Date();
72
81
  const startOfTodayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
73
- const DaysAgoUTC = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
74
-
75
82
  const thresholds = {
76
83
  dateThreshold: startOfTodayUTC,
77
- gracePeriodThreshold: DaysAgoUTC
84
+ gracePeriodThreshold: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
78
85
  };
79
86
 
80
87
  logger.log('INFO', `[Orchestrator Plan] Calculating targets for ${userType}...`);
81
-
82
- // Reusing existing helper to get the raw list of users
83
88
  const targets = await getUpdateTargets(userType, thresholds, config.updateConfig, deps);
84
- logger.log('INFO', `[Orchestrator Plan] Found ${targets.length} candidates for ${userType}.`);
85
89
 
86
90
  if (targets.length === 0) {
87
- return { planId: null, totalUsers: 0, windowCount: 0, windowIds: [] };
91
+ return { planId: null, message: `No targets found for ${userType}` };
88
92
  }
89
93
 
90
- // 2. Shuffle to randomize load (Fisher-Yates shuffle)
91
- // This ensures that we don't always update the same users at the same time of day
94
+ // 2. Shuffle (Fisher-Yates)
92
95
  for (let i = targets.length - 1; i > 0; i--) {
93
96
  const j = Math.floor(Math.random() * (i + 1));
94
97
  [targets[i], targets[j]] = [targets[j], targets[i]];
95
98
  }
96
99
 
97
- // 3. Split and Save
100
+ // 3. Split, Save, and Schedule
98
101
  const chunkSize = Math.ceil(targets.length / numberOfWindows);
99
102
  const planId = `plan_${userType}_${date}`;
100
- const windowIds = [];
101
-
102
103
  const batchWriter = db.batch();
103
- let writeCount = 0;
104
104
 
105
+ // We collect task creation promises to await them at the end
106
+ const tasksToCreate = [];
107
+
105
108
  for (let i = 0; i < numberOfWindows; i++) {
106
109
  const start = i * chunkSize;
107
110
  const end = start + chunkSize;
108
111
  const chunk = targets.slice(start, end);
109
112
 
110
113
  if (chunk.length > 0) {
111
- // Store ONLY the necessary IDs/Data in Firestore
112
- // Path: system_update_plans/{planId}/windows/{windowId}
113
- const windowDocRef = db.collection('system_update_plans').doc(planId).collection('windows').doc(String(i + 1));
114
+ const windowId = i + 1;
115
+
116
+ // A. Prepare Firestore Write
117
+ const windowDocRef = db.collection('system_update_plans').doc(planId).collection('windows').doc(String(windowId));
114
118
 
115
119
  batchWriter.set(windowDocRef, {
116
120
  userType: userType,
117
- users: chunk, // This array contains the user objects/IDs
121
+ users: chunk,
118
122
  status: 'pending',
119
- windowId: i + 1,
123
+ windowId: windowId,
120
124
  userCount: chunk.length,
121
125
  createdAt: FieldValue.serverTimestamp(),
122
126
  scheduledForDate: date
123
127
  });
124
128
 
125
- windowIds.push(i + 1);
126
- writeCount++;
129
+ // B. Prepare Cloud Task
130
+ // Delay Logic: Window 1 = 0s, Window 2 = 3600s (1hr), Window 3 = 7200s (2hr)...
131
+ const delaySeconds = i * 3600;
132
+ const scheduleTimeSeconds = (Date.now() / 1000) + delaySeconds;
133
+
134
+ const taskPayload = {
135
+ action: 'EXECUTE_WINDOW',
136
+ planId: planId,
137
+ windowId: windowId,
138
+ userType: userType,
139
+ date: date
140
+ };
141
+
142
+ const taskRequest = {
143
+ parent: parentQueue,
144
+ task: {
145
+ httpRequest: {
146
+ httpMethod: 'POST',
147
+ url: orchestratorUrl,
148
+ headers: { 'Content-Type': 'application/json' },
149
+ body: Buffer.from(JSON.stringify(taskPayload)).toString('base64'),
150
+ oidcToken: {
151
+ serviceAccountEmail: process.env.GCP_SERVICE_ACCOUNT_EMAIL
152
+ }
153
+ },
154
+ scheduleTime: {
155
+ seconds: scheduleTimeSeconds
156
+ }
157
+ }
158
+ };
159
+
160
+ tasksToCreate.push(taskRequest);
127
161
  }
128
162
  }
129
163
 
164
+ // Commit Firestore Writes First (Essential so tasks don't fail looking for data)
130
165
  await batchWriter.commit();
131
- logger.log('SUCCESS', `[Orchestrator Plan] Plan Saved: ${planId} with ${writeCount} windows containing ${targets.length} users.`);
166
+ logger.log('SUCCESS', `[Orchestrator Plan] Plan ${planId} saved to Firestore.`);
167
+
168
+ // Dispatch Cloud Tasks
169
+ logger.log('INFO', `[Orchestrator Plan] Scheduling ${tasksToCreate.length} tasks for ${userType}...`);
170
+
171
+ // Create tasks in parallel (with limit if needed, but usually fine for <50 tasks)
172
+ const scheduleResults = await Promise.allSettled(tasksToCreate.map(req => cloudTasksClient.createTask(req)));
173
+
174
+ const failedCount = scheduleResults.filter(r => r.status === 'rejected').length;
175
+ if (failedCount > 0) {
176
+ logger.log('ERROR', `[Orchestrator Plan] Failed to schedule ${failedCount} tasks.`);
177
+ // Note: We don't rollback Firestore here; manual retry or idempotency handles it.
178
+ }
132
179
 
133
180
  return {
134
181
  planId: planId,
135
182
  totalUsers: targets.length,
136
- windowCount: windowIds.length,
137
- windowIds: windowIds
183
+ windowsCreated: tasksToCreate.length,
184
+ scheduledCount: tasksToCreate.length - failedCount,
185
+ message: `Plan created. ${tasksToCreate.length} windows scheduled over ${tasksToCreate.length} hours.`
138
186
  };
139
187
  }
140
188
 
141
189
  /**
142
190
  * LOGIC: Execute a specific window
143
- * 1. Reads the user list from Firestore.
144
- * 2. Calls dispatchUpdates to send them to the Task Engine.
191
+ * Triggered by Cloud Tasks when the schedule time arrives.
145
192
  */
146
193
  async function executeUpdateWindow(planId, windowId, userType, config, deps) {
147
194
  const { logger, db } = deps;
148
195
 
149
- // 1. Fetch the window from Firestore
196
+ // 1. Fetch window from Firestore
150
197
  const windowRef = db.collection('system_update_plans').doc(planId).collection('windows').doc(String(windowId));
151
198
  const windowDoc = await windowRef.get();
152
199
 
@@ -156,7 +203,7 @@ async function executeUpdateWindow(planId, windowId, userType, config, deps) {
156
203
 
157
204
  const data = windowDoc.data();
158
205
 
159
- // Idempotency check: prevent re-running a completed window
206
+ // Idempotency: Don't run if already done
160
207
  if (data.status === 'completed') {
161
208
  logger.log('WARN', `[Orchestrator Execute] Window ${windowId} already completed. Skipping.`);
162
209
  return { dispatchedCount: 0, status: 'already_completed' };
@@ -165,13 +212,12 @@ async function executeUpdateWindow(planId, windowId, userType, config, deps) {
165
212
  const targets = data.users;
166
213
  logger.log('INFO', `[Orchestrator Execute] Window ${windowId}: Dispatching ${targets.length} users.`);
167
214
 
168
- // 2. Dispatch using existing helper
169
- // The helper handles batching for Pub/Sub and logging.
215
+ // 2. Dispatch
170
216
  if (targets && targets.length > 0) {
171
217
  await dispatchUpdates(targets, userType, config.updateConfig, deps);
172
218
  }
173
219
 
174
- // 3. Mark window as complete
220
+ // 3. Mark Complete
175
221
  await windowRef.update({
176
222
  status: 'completed',
177
223
  executedAt: FieldValue.serverTimestamp()
@@ -180,131 +226,52 @@ async function executeUpdateWindow(planId, windowId, userType, config, deps) {
180
226
  return { dispatchedCount: targets.length, status: 'success' };
181
227
  }
182
228
 
183
- /** Stage 1: Main discovery orchestrator pipe */
229
+ // --- Legacy / Helper Wrappers (Preserved for compatibility) ---
230
+
184
231
  async function runDiscoveryOrchestrator(config, deps) {
185
232
  const { logger, firestoreUtils } = deps;
186
233
  logger.log('INFO', '🚀 Discovery Orchestrator triggered...');
187
234
  await firestoreUtils.resetProxyLocks(deps, config);
188
-
189
- // Check if normal user discovery is enabled
190
- if (isUserTypeEnabled('normal', config.enabledUserTypes)) {
191
- await runDiscovery('normal', config.discoveryConfig.normal, config, deps);
192
- } else {
193
- logger.log('INFO', '[Orchestrator] Normal user discovery is disabled. Skipping.');
194
- }
195
-
196
- // Check if speculator discovery is enabled
197
- if (isUserTypeEnabled('speculator', config.enabledUserTypes)) {
198
- await runDiscovery('speculator', config.discoveryConfig.speculator, config, deps);
199
- } else {
200
- logger.log('INFO', '[Orchestrator] Speculator discovery is disabled. Skipping.');
201
- }
235
+ if (isUserTypeEnabled('normal', config.enabledUserTypes)) await runDiscovery('normal', config.discoveryConfig.normal, config, deps);
236
+ if (isUserTypeEnabled('speculator', config.enabledUserTypes)) await runDiscovery('speculator', config.discoveryConfig.speculator, config, deps);
202
237
  }
203
238
 
204
- /** Stage 2: Main update orchestrator pipe */
205
239
  async function runUpdateOrchestrator(config, deps) {
206
240
  const { logger, firestoreUtils } = deps;
207
241
  logger.log('INFO', '🚀 Update Orchestrator triggered...');
208
242
  await firestoreUtils.resetProxyLocks(deps, config);
209
-
210
243
  const enabledTypes = config.enabledUserTypes || [];
211
- const enabledTypesStr = Array.isArray(enabledTypes) ? enabledTypes.join(', ') : String(enabledTypes);
212
- logger.log('INFO', `[Orchestrator] Configuration loaded. Enabled user types: [${enabledTypesStr || 'none'}]`);
213
- logger.log('INFO', `[Orchestrator] Config object keys: ${Object.keys(config).join(', ')}`);
214
- logger.log('INFO', `[Orchestrator] enabledUserTypes type: ${typeof config.enabledUserTypes}, value: ${JSON.stringify(config.enabledUserTypes)}`);
215
-
216
- // 1. Normal Users
217
- if (isUserTypeEnabled('normal', enabledTypes)) {
218
- await runUpdates('normal', config.updateConfig, config, deps);
219
- } else {
220
- logger.log('INFO', '[Orchestrator] Normal user updates are disabled. Skipping.');
221
- }
222
-
223
- // 2. Speculators
224
- if (isUserTypeEnabled('speculator', enabledTypes)) {
225
- await runUpdates('speculator', config.updateConfig, config, deps);
226
- } else {
227
- logger.log('INFO', '[Orchestrator] Speculator updates are disabled. Skipping.');
228
- }
229
244
 
230
- // 3. Popular Investors
245
+ if (isUserTypeEnabled('normal', enabledTypes)) await runUpdates('normal', config.updateConfig, config, deps);
246
+ if (isUserTypeEnabled('speculator', enabledTypes)) await runUpdates('speculator', config.updateConfig, config, deps);
231
247
  if (isUserTypeEnabled('popular_investor', enabledTypes)) {
232
- try {
233
- const piConfig = {
234
- ...config.updateConfig,
235
- popularInvestorRankingsCollection: config.updateConfig.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings'
236
- };
237
- await runUpdates('popular_investor', piConfig, config, deps);
238
- } catch (e) {
239
- logger.log('ERROR', '[Orchestrator] Failed to run Popular Investor updates.', e);
240
- }
241
- } else {
242
- logger.log('INFO', '[Orchestrator] Popular Investor updates are disabled. Skipping.');
248
+ const piConfig = { ...config.updateConfig, popularInvestorRankingsCollection: config.updateConfig.popularInvestorRankingsCollection || 'popular_investor_rankings' };
249
+ await runUpdates('popular_investor', piConfig, config, deps);
243
250
  }
244
-
245
- // 4. Signed-In Users
246
251
  if (isUserTypeEnabled('signed_in_user', enabledTypes)) {
247
- try {
248
- const signedInConfig = {
249
- ...config.updateConfig,
250
- signedInUsersCollection: config.updateConfig.signedInUsersCollection || process.env.FIRESTORE_COLLECTION_SIGNED_IN_USER_PORTFOLIOS || 'signed_in_users'
251
- };
252
- await runUpdates('signed_in_user', signedInConfig, config, deps);
253
- } catch (e) {
254
- logger.log('ERROR', '[Orchestrator] Failed to run Signed-In User updates.', e);
255
- }
256
- } else {
257
- logger.log('INFO', '[Orchestrator] Signed-In User updates are disabled. Skipping.');
252
+ const signedInConfig = { ...config.updateConfig, signedInUsersCollection: config.updateConfig.signedInUsersCollection || 'signed_in_users' };
253
+ await runUpdates('signed_in_user', signedInConfig, config, deps);
258
254
  }
259
255
  }
260
256
 
261
- /** Stage 3: Run discovery for a single user type */
262
257
  async function runDiscovery(userType, userConfig, globalConfig, deps) {
263
258
  const { logger } = deps;
264
- logger.log('INFO', `[Orchestrator] Starting discovery for ${userType} users...`);
265
-
266
- // Step 3.1: Check if discovery is needed
267
259
  const { needsDiscovery, blocksToFill } = await checkDiscoveryNeed(userType, userConfig, deps);
268
- if (!needsDiscovery) { logger.log('INFO', `[Orchestrator] No discovery needed for ${userType}.`); return; }
269
-
270
- // Step 3.2: Get discovery candidates
260
+ if (!needsDiscovery) return;
271
261
  const candidates = await getDiscoveryCandidates(userType, blocksToFill, userConfig, deps);
272
-
273
- // Step 3.3: Dispatch discovery tasks
274
262
  await dispatchDiscovery(userType, candidates, userConfig, deps);
275
- logger.log('SUCCESS', `[Orchestrator] Dispatched discovery tasks for ${userType}.`);
276
263
  }
277
264
 
278
- /** Stage 4: Run updates for a single user type */
279
265
  async function runUpdates(userType, updateConfig, globalConfig, deps) {
280
- const { logger } = deps;
281
- logger.log('INFO', `[Orchestrator] Collecting users for daily update (${userType})...`);
282
-
283
- // Step 4.1: Compute thresholds
284
266
  const now = new Date();
285
267
  const startOfTodayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
286
- const DaysAgoUTC = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
287
- const thresholds = { dateThreshold: startOfTodayUTC, gracePeriodThreshold: DaysAgoUTC };
288
-
289
- // Step 4.2: Get update targets
268
+ const thresholds = { dateThreshold: startOfTodayUTC, gracePeriodThreshold: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) };
290
269
  const targets = await getUpdateTargets(userType, thresholds, updateConfig, deps);
291
-
292
- // Step 4.3: Dispatch update tasks
293
270
  await dispatchUpdates(targets, userType, updateConfig, deps);
294
- logger.log('SUCCESS', `[Orchestrator] Dispatched update tasks for ${userType}.`);
295
271
  }
296
272
 
297
- /**
298
- * Helper function to check if a user type is enabled
299
- * @param {string} userType - The user type to check ('normal', 'speculator', 'popular_investor', 'signed_in_user')
300
- * @param {Array<string>} enabledTypes - Array of enabled user types from config
301
- * @returns {boolean} True if the user type is enabled
302
- */
303
273
  function isUserTypeEnabled(userType, enabledTypes) {
304
- if (!enabledTypes || !Array.isArray(enabledTypes) || enabledTypes.length === 0) {
305
- // If no enabled types specified, default to all enabled (backward compatibility)
306
- return true;
307
- }
274
+ if (!enabledTypes || !Array.isArray(enabledTypes) || enabledTypes.length === 0) return true;
308
275
  return enabledTypes.includes(userType);
309
276
  }
310
277