bulltrackers-module 1.0.0

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.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * @fileoverview Core Firestore utility functions for the Bulltrackers Module.
3
+ */
4
+ const { Firestore, FieldValue, FieldPath } = require('@google-cloud/firestore');
5
+ const { logger } = require("sharedsetup")(__filename);
6
+
7
+ const db = new Firestore();
8
+
9
+ const MAX_FIRESTORE_BATCH_SIZE = 500; // Firestore batch limit
10
+
11
+ /**
12
+ * Fetches and merges the most recent portfolio data for all normal users.
13
+ * This function is required by getPrioritizedSpeculators.
14
+ * @async
15
+ * @param {string} normalUserCollectionName - e.g., 'NormalUserPortfolios'.
16
+ * @param {string} snapshotsSubCollectionName - e.g., 'snapshots'.
17
+ * @param {string} partsSubCollectionName - e.g., 'parts'.
18
+ * @returns {Promise<object>} A single object containing all user portfolios.
19
+ */
20
+ async function getLatestNormalUserPortfolios(normalUserCollectionName, snapshotsSubCollectionName, partsSubCollectionName) {
21
+ logger.log('INFO', `[Core Utils] Fetching latest portfolios from ${normalUserCollectionName}...`);
22
+ const allPortfolios = {};
23
+
24
+ // Get yesterday's date string
25
+ const yesterday = new Date();
26
+ yesterday.setDate(yesterday.getDate() - 1);
27
+ const dateString = yesterday.toISOString().slice(0, 10);
28
+
29
+ const blockDocs = await db.collection(normalUserCollectionName).listDocuments();
30
+
31
+ for (const blockDoc of blockDocs) {
32
+ const snapshotDocRef = blockDoc.collection(snapshotsSubCollectionName).doc(dateString);
33
+ const partsCollectionRef = snapshotDocRef.collection(partsSubCollectionName);
34
+ const partsSnapshot = await partsCollectionRef.get();
35
+
36
+ if (!partsSnapshot.empty) {
37
+ partsSnapshot.forEach(partDoc => {
38
+ Object.assign(allPortfolios, partDoc.data());
39
+ });
40
+ }
41
+ }
42
+
43
+ logger.log('INFO', `[Core Utils] Found ${Object.keys(allPortfolios).length} user portfolios from ${dateString}'s snapshot.`);
44
+ return allPortfolios;
45
+ }
46
+
47
+
48
+ /**
49
+ * Resets the isLocked status for all proxies.
50
+ * Note: This implementation differs from the original orchestrator's FIRESTORE_DOC_PROXY_PERFORMANCE logic.
51
+ * This logic matches the original file provided for the module.
52
+ * @async
53
+ * @param {string} proxiesCollectionName - e.g., 'Proxies'.
54
+ * @returns {Promise<void>}
55
+ */
56
+ async function resetProxyLocks(proxiesCollectionName) {
57
+ logger.log('INFO','[Core Utils] Resetting proxy locks...');
58
+ try {
59
+ const proxiesRef = db.collection(proxiesCollectionName);
60
+ const snapshot = await proxiesRef.where('status', '==', 1).get(); // Only fetch locked ones
61
+
62
+ if (snapshot.empty) {
63
+ logger.log('INFO','[Core Utils] No proxies found in a locked state.');
64
+ return;
65
+ }
66
+
67
+ const batch = db.batch();
68
+ snapshot.docs.forEach(doc => {
69
+ batch.update(doc.ref, { status: 0 }); // Use 0 for unlocked
70
+ });
71
+
72
+ await batch.commit();
73
+ logger.log('INFO',`[Core Utils] Proxy locks reset for ${snapshot.size} proxies.`);
74
+ } catch (error) {
75
+ logger.log('ERROR','[Core Utils] Error resetting proxy locks', { errorMessage: error.message });
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Fetches and aggregates block capacities.
82
+ * @async
83
+ * @param {string} userType - 'normal' or 'speculator'.
84
+ * @param {string} speculatorBlockCountsDocPath - Path to speculator counts doc.
85
+ * @param {string} normalBlockCountsDocPath - Path to normal counts doc.
86
+ * @returns {Promise<object>} Object containing counts.
87
+ */
88
+ async function getBlockCapacities(userType, speculatorBlockCountsDocPath, normalBlockCountsDocPath) {
89
+ logger.log('INFO',`[Core Utils] Getting block capacities for ${userType}...`);
90
+ try {
91
+ const docPath = userType === 'speculator'
92
+ ? speculatorBlockCountsDocPath
93
+ : normalBlockCountsDocPath;
94
+
95
+ if (!docPath) {
96
+ logger.log('ERROR', `[Core Utils] Missing block counts document path for ${userType}.`);
97
+ return {};
98
+ }
99
+
100
+ const countsRef = db.doc(docPath);
101
+ const countsDoc = await countsRef.get();
102
+ if (!countsDoc.exists) {
103
+ logger.log('WARN',`[Core Utils] Block counts document not found for ${userType} at ${docPath}. Returning empty.`);
104
+ return {};
105
+ }
106
+ return countsDoc.data().counts || {};
107
+ } catch (error) {
108
+ logger.log('ERROR',`[Core Utils] Error getting block capacities for ${userType}`, { errorMessage: error.message });
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Fetches user IDs from multiple sources for exclusion lists.
115
+ * @async
116
+ * @param {string} userType - 'normal' or 'speculator'.
117
+ * @param {object} config - Configuration object.
118
+ * @param {string} config.specBlocksCollection - e.g., 'SpeculatorBlocks'.
119
+ * @param {string} config.pendingSpecCollection - e.g., 'PendingSpeculators'.
120
+ * @param {string} config.normalUserCollection - e.g., 'NormalUserPortfolios'.
121
+ * @param {string} config.invalidSpecCollection - e.g., 'InvalidSpeculators'.
122
+ * @returns {Promise<Set<string>>} A Set containing unique user IDs.
123
+ */
124
+ async function getExclusionIds(userType, config) {
125
+ logger.log('INFO',`[Core Utils] Getting exclusion IDs for ${userType} discovery...`);
126
+ const {
127
+ specBlocksCollection,
128
+ pendingSpecCollection,
129
+ normalUserCollection,
130
+ invalidSpecCollection
131
+ } = config;
132
+
133
+ const exclusionIds = new Set();
134
+ const promises = [];
135
+
136
+ try {
137
+ // 1. Existing Speculators
138
+ const specBlocksRef = db.collection(specBlocksCollection);
139
+ promises.push(specBlocksRef.get().then(snapshot => {
140
+ snapshot.forEach(doc => {
141
+ const users = doc.data().users || {};
142
+ Object.keys(users).forEach(key => exclusionIds.add(key.split('.')[1]));
143
+ });
144
+ logger.log('TRACE','[Core Utils] Fetched existing speculator IDs for exclusion.');
145
+ }));
146
+
147
+ // 2. Pending Speculators
148
+ if (userType === 'speculator') {
149
+ const pendingRef = db.collection(pendingSpecCollection);
150
+ promises.push(pendingRef.get().then(snapshot => {
151
+ snapshot.forEach(doc => {
152
+ Object.keys(doc.data().users || {}).forEach(cid => exclusionIds.add(cid));
153
+ });
154
+ logger.log('TRACE','[Core Utils] Fetched pending speculator IDs for exclusion.');
155
+ }));
156
+ }
157
+
158
+ // 3. Existing Normal Users
159
+ const normalPortfoliosRef = db.collection(normalUserCollection);
160
+ promises.push(normalPortfoliosRef.listDocuments().then(async blockRefs => {
161
+ for(const blockRef of blockRefs) {
162
+ // Assuming 'snapshots' and 'parts' subcollections as per original logic
163
+ const snapshotRef = blockRef.collection('snapshots');
164
+ const latestSnapshotQuery = snapshotRef.orderBy(FieldPath.documentId(), 'desc').limit(1);
165
+ const latestSnapshot = await latestSnapshotQuery.get();
166
+ if (!latestSnapshot.empty) {
167
+ const partsRef = latestSnapshot.docs[0].ref.collection('parts');
168
+ const partsSnapshot = await partsRef.get();
169
+ partsSnapshot.forEach(partDoc => {
170
+ Object.keys(partDoc.data()).forEach(uid => exclusionIds.add(uid));
171
+ });
172
+ }
173
+ }
174
+ logger.log('TRACE','[Core Utils] Fetched existing normal user IDs for exclusion.');
175
+ }));
176
+
177
+
178
+ // 4. Invalid Speculators
179
+ const invalidRef = db.collection(invalidSpecCollection);
180
+ promises.push(invalidRef.get().then(snapshot => {
181
+ snapshot.forEach(doc => {
182
+ Object.keys(doc.data().users || {}).forEach(cid => exclusionIds.add(cid));
183
+ });
184
+ logger.log('TRACE','[Core Utils] Fetched invalid speculator IDs for exclusion.');
185
+ }));
186
+
187
+
188
+ await Promise.all(promises);
189
+ logger.log('INFO',`[Core Utils] Total unique exclusion IDs found: ${exclusionIds.size}`);
190
+ return exclusionIds;
191
+
192
+ } catch (error) {
193
+ logger.log('ERROR','[Core Utils] Error getting exclusion IDs', { errorMessage: error.message });
194
+ throw error;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Scans normal user portfolios for potential speculator candidates.
200
+ * @async
201
+ * @param {Set<string>} exclusionIds - IDs to exclude.
202
+ * @param {Set<number>} speculatorInstrumentSet - Set of instrument IDs.
203
+ * @param {string} normalUserCollectionName - e.g., 'NormalUserPortfolios'.
204
+ * @param {string} snapshotsSubCollectionName - e.g., 'snapshots'.
205
+ * @param {string} partsSubCollectionName - e.g., 'parts'.
206
+ * @returns {Promise<string[]>} Array of prioritized speculator CIDs.
207
+ */
208
+ async function getPrioritizedSpeculators(exclusionIds, speculatorInstrumentSet, normalUserCollectionName, snapshotsSubCollectionName, partsSubCollectionName) {
209
+ logger.log('INFO','[Core Utils] Scanning normal users for prioritized speculators...');
210
+ const candidates = new Set();
211
+
212
+ try {
213
+ const latestNormalPortfolios = await getLatestNormalUserPortfolios(
214
+ normalUserCollectionName,
215
+ snapshotsSubCollectionName,
216
+ partsSubCollectionName
217
+ );
218
+
219
+ for (const userId in latestNormalPortfolios) {
220
+ if (exclusionIds.has(userId)) continue;
221
+
222
+ const portfolio = latestNormalPortfolios[userId];
223
+ const holdsSpeculatorAsset = portfolio?.AggregatedPositions?.some(p =>
224
+ speculatorInstrumentSet.has(p.InstrumentID)
225
+ );
226
+
227
+ if (holdsSpeculatorAsset) {
228
+ candidates.add(userId);
229
+ }
230
+ }
231
+ logger.log('INFO',`[Core Utils] Found ${candidates.size} potential prioritized speculators.`);
232
+ return Array.from(candidates);
233
+ } catch (error) {
234
+ logger.log('ERROR','[Core Utils] Error getting prioritized speculators', { errorMessage: error.message });
235
+ throw error;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Deletes all documents within a specified collection path (scorched earth).
241
+ * @async
242
+ * @param {string} collectionPath - The path to the collection to clear.
243
+ * @returns {Promise<void>}
244
+ */
245
+ async function clearCollection(collectionPath) {
246
+ logger.log('WARN', `[Core Utils] Starting SCORCHED EARTH delete for collection: ${collectionPath}...`);
247
+ try {
248
+ const collectionRef = db.collection(collectionPath);
249
+ const batchSize = MAX_FIRESTORE_BATCH_SIZE;
250
+ let query = collectionRef.limit(batchSize);
251
+ let snapshot;
252
+ let deleteCount = 0;
253
+
254
+ while (true) {
255
+ snapshot = await query.get();
256
+ if (snapshot.size === 0) {
257
+ break;
258
+ }
259
+
260
+ const batch = db.batch();
261
+ snapshot.docs.forEach(doc => batch.delete(doc.ref));
262
+ await batch.commit();
263
+ deleteCount += snapshot.size;
264
+
265
+ if (snapshot.size < batchSize) {
266
+ break;
267
+ }
268
+ query = collectionRef.limit(batchSize);
269
+ }
270
+ logger.log('SUCCESS', `[Core Utils] Scorched earth complete. Deleted ${deleteCount} documents from ${collectionPath}.`);
271
+ } catch (error) {
272
+ logger.log('ERROR', `[Core Utils] Error clearing collection ${collectionPath}`, { errorMessage: error.message });
273
+ throw error;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Writes an array of user IDs into sharded Firestore documents, matching the original
279
+ * orchestrator's "ultra aggressive" small batch logic.
280
+ * @async
281
+ * @param {string} collectionPath - Base path for sharded documents (e.g., 'PendingSpeculators').
282
+ * @param {string[]} items - The user IDs to write.
283
+ * @param {Date} timestamp - Timestamp to associate with each user ID.
284
+ * @param {number} maxFieldsPerDoc - Max user IDs per sharded document.
285
+ * @param {number} maxWritesPerBatch - Max documents to write per batch commit.
286
+ * @returns {Promise<void>}
287
+ */
288
+ async function batchWriteShardedIds(collectionPath, items, timestamp, maxFieldsPerDoc, maxWritesPerBatch) {
289
+ logger.log('INFO', `[Core Utils] Batch writing ${items.length} IDs to sharded path: ${collectionPath} (max ${maxFieldsPerDoc}/doc, ${maxWritesPerBatch} docs/batch)...`);
290
+ if (items.length === 0) return;
291
+
292
+ try {
293
+ const collectionRef = db.collection(collectionPath);
294
+ let batch = db.batch();
295
+ let currentDocFields = {};
296
+ let currentFieldCount = 0;
297
+ let batchWriteCount = 0;
298
+ let docCounter = 0;
299
+
300
+ for (let i = 0; i < items.length; i++) {
301
+ const userId = items[i];
302
+ const key = `users.${userId}`;
303
+ currentDocFields[key] = timestamp;
304
+ currentFieldCount++;
305
+
306
+ if (currentFieldCount >= maxFieldsPerDoc || i === items.length - 1) {
307
+ const docRef = collectionRef.doc(`pending_${docCounter}_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`);
308
+ batch.set(docRef, currentDocFields);
309
+ batchWriteCount++;
310
+
311
+ currentDocFields = {};
312
+ currentFieldCount = 0;
313
+ docCounter++;
314
+
315
+ if (batchWriteCount >= maxWritesPerBatch || i === items.length - 1) {
316
+ await batch.commit();
317
+ batch = db.batch();
318
+ batchWriteCount = 0;
319
+ }
320
+ }
321
+ }
322
+ logger.log('SUCCESS', `[Core Utils] Sharded write complete for ${collectionPath}. Created ${docCounter} documents.`);
323
+ } catch (error) {
324
+ logger.log('ERROR', `[Core Utils] Error during sharded write to ${collectionPath}`, { errorMessage: error.message });
325
+ throw error;
326
+ }
327
+ }
328
+
329
+
330
+ /**
331
+ * Gets normal users whose timestamp indicates they need updating.
332
+ * @async
333
+ * @param {Date} dateThreshold - Users processed before this date will be returned.
334
+ * @param {string} normalUserCollectionName - e.g., 'NormalUserPortfolios'.
335
+ * @param {string} normalUserTimestampsDocId - e.g., 'normal'.
336
+ * @returns {Promise<string[]>} Array of user IDs to update.
337
+ */
338
+ async function getNormalUsersToUpdate(dateThreshold, normalUserCollectionName, normalUserTimestampsDocId) {
339
+ logger.log('INFO','[Core Utils] Getting normal users to update...');
340
+ const usersToUpdate = [];
341
+ try {
342
+ const timestampDocRef = db.collection(normalUserCollectionName)
343
+ .doc('timestamps')
344
+ .collection('users')
345
+ .doc(normalUserTimestampsDocId);
346
+ const timestampDoc = await timestampDocRef.get();
347
+
348
+ if (!timestampDoc.exists) {
349
+ logger.log('WARN',`[Core Utils] Normal user timestamp document not found at ${timestampDocRef.path}.`);
350
+ return [];
351
+ }
352
+
353
+ const timestamps = timestampDoc.data().users || {};
354
+ for (const userId in timestamps) {
355
+ const lastProcessed = timestamps[userId]?.toDate ? timestamps[userId].toDate() : new Date(0);
356
+ if (lastProcessed < dateThreshold) {
357
+ usersToUpdate.push(userId);
358
+ }
359
+ }
360
+ logger.log('INFO',`[Core Utils] Found ${usersToUpdate.length} normal users to update.`);
361
+ return usersToUpdate;
362
+ } catch (error) {
363
+ logger.log('ERROR','[Core Utils] Error getting normal users to update', { errorMessage: error.message });
364
+ throw error;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Gets speculator users/instruments whose data needs updating.
370
+ * @async
371
+ * @param {Date} dateThreshold - Verification date threshold.
372
+ * @param {Date} gracePeriodThreshold - Last held asset date threshold.
373
+ * @param {string} speculatorBlocksCollectionName - e.g., 'SpeculatorBlocks'.
374
+ * @returns {Promise<object[]>} Array of objects like { userId: string, instrumentId: number }.
375
+ */
376
+ async function getSpeculatorsToUpdate(dateThreshold, gracePeriodThreshold, speculatorBlocksCollectionName) {
377
+ logger.log('INFO','[Core Utils] Getting speculators to update...');
378
+ const updates = [];
379
+ try {
380
+ const blocksRef = db.collection(speculatorBlocksCollectionName);
381
+ const snapshot = await blocksRef.get();
382
+
383
+ if (snapshot.empty) {
384
+ logger.log('INFO','[Core Utils] No speculator blocks found.');
385
+ return [];
386
+ }
387
+
388
+ snapshot.forEach(doc => {
389
+ const blockData = doc.data();
390
+ const users = blockData.users || {};
391
+
392
+ for (const key in users) {
393
+ const userId = key.split('.')[1];
394
+ const userData = users[key];
395
+
396
+ const lastVerified = userData.lastVerified?.toDate ? userData.lastVerified.toDate() : new Date(0);
397
+ const lastHeld = userData.lastHeldSpeculatorAsset?.toDate ? userData.lastHeldSpeculatorAsset.toDate() : new Date(0);
398
+
399
+ if (lastVerified < dateThreshold && lastHeld > gracePeriodThreshold) {
400
+ if (userData.instruments && Array.isArray(userData.instruments)) {
401
+ userData.instruments.forEach(instrumentId => {
402
+ updates.push({ userId, instrumentId });
403
+ });
404
+ }
405
+ }
406
+ }
407
+ });
408
+
409
+ logger.log('INFO',`[Core Utils] Found ${updates.length} speculator user/instrument pairs to update.`);
410
+ return updates;
411
+ } catch (error) {
412
+ logger.log('ERROR','[Core Utils] Error getting speculators to update', { errorMessage: error.message });
413
+ throw error;
414
+ }
415
+ }
416
+
417
+ module.exports = {
418
+ getLatestNormalUserPortfolios, // Export new function
419
+ resetProxyLocks,
420
+ getBlockCapacities,
421
+ getExclusionIds,
422
+ getPrioritizedSpeculators,
423
+ clearCollection,
424
+ batchWriteShardedIds,
425
+ getNormalUsersToUpdate,
426
+ getSpeculatorsToUpdate,
427
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @fileoverview Exports core utility modules.
3
+ */
4
+
5
+ const firestoreUtils = require('./firestore_utils');
6
+ const pubsubUtils = require('./pubsub_utils');
7
+ const loggingWrapper = require('./logging_wrapper');
8
+
9
+ module.exports = {
10
+ firestore: firestoreUtils,
11
+ pubsub: pubsubUtils,
12
+ logging: loggingWrapper,
13
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @fileoverview Wrappers for Cloud Functions and logic stages for consistent logging and error handling.
3
+ */
4
+
5
+ // Placeholder for a potential shared logger instance if needed
6
+ // const { logger } = require('shared-logger-package'); // Example
7
+
8
+ /**
9
+ * Wraps an entire HTTP Cloud Function for standardized logging and response handling.
10
+ * @async
11
+ * @param {string} functionName - The name of the Cloud Function.
12
+ * @param {Function} logicCallback - The async function containing the core logic.
13
+ * @returns {Function} An Express middleware function.
14
+ */
15
+ function handleCloudFunction(functionName, logicCallback) {
16
+ return async (req, res) => {
17
+ console.log(`[${functionName}] Function triggered.`); // Replace with actual logger
18
+ try {
19
+ await logicCallback(req, res); // Pass req and res if needed by the logic
20
+ if (!res.headersSent) {
21
+ console.log(`[${functionName}] Function completed successfully.`);
22
+ res.status(200).send(`${functionName} completed.`);
23
+ }
24
+ } catch (error) {
25
+ console.error(`[${functionName}] FATAL Error: ${error.message}`, error.stack); // Replace with actual logger
26
+ if (!res.headersSent) {
27
+ res.status(500).send(`Internal Server Error in ${functionName}.`);
28
+ }
29
+ }
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Wraps a specific stage within a Cloud Function's logic for logging start/end and errors.
35
+ * @async
36
+ * @param {string} stageName - A descriptive name for the stage.
37
+ * @param {Function} logicCallback - The async function containing the stage's logic.
38
+ * @returns {Promise<any>} The result of the logicCallback.
39
+ * @throws Will re-throw any errors caught during the stage execution.
40
+ */
41
+ async function handleLogicStage(stageName, logicCallback) {
42
+ console.log(`[Stage: ${stageName}] Starting...`); // Replace with actual logger
43
+ try {
44
+ const result = await logicCallback();
45
+ console.log(`[Stage: ${stageName}] Completed successfully.`); // Replace with actual logger
46
+ return result;
47
+ } catch (error) {
48
+ console.error(`[Stage: ${stageName}] Error: ${error.message}`, error.stack); // Replace with actual logger
49
+ throw error; // Re-throw to allow the main handler to catch it
50
+ }
51
+ }
52
+
53
+ module.exports = {
54
+ handleCloudFunction,
55
+ handleLogicStage,
56
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @fileoverview Core Pub/Sub utility functions for the Bulltrackers Module.
3
+ */
4
+ const { PubSub } = require('@google-cloud/pubsub');
5
+ const { logger } = require("sharedsetup")(__filename);
6
+
7
+ const pubsub = new PubSub();
8
+ // const MAX_PUBSUB_BATCH_SIZE = 500; // Removed from here
9
+
10
+ /**
11
+ * Publishes an array of tasks to a specified Pub/Sub topic in batches.
12
+ * @async
13
+ * @param {string} topicName - The name of the Pub/Sub topic.
14
+ * @param {Array<object>} tasks - The tasks to publish (each object will be JSON serialized).
15
+ * @param {string} taskType - A descriptor for the task type (for logging).
16
+ * @param {number} [maxPubsubBatchSize=500] - Max messages to publish in one client batch.
17
+ * @returns {Promise<void>}
18
+ */
19
+ async function batchPublishTasks(topicName, tasks, taskType, maxPubsubBatchSize = 500) {
20
+ if (!tasks || tasks.length === 0) {
21
+ logger.log('INFO',`[Core Utils] No ${taskType} tasks to publish to ${topicName}.`);
22
+ return;
23
+ }
24
+ logger.log('INFO',`[Core Utils] Publishing ${tasks.length} ${taskType} tasks to ${topicName}...`);
25
+ const topic = pubsub.topic(topicName);
26
+ let messagesPublished = 0;
27
+
28
+ try {
29
+ for (let i = 0; i < tasks.length; i += maxPubsubBatchSize) {
30
+ const batchTasks = tasks.slice(i, i + maxPubsubBatchSize);
31
+ const batchPromises = batchTasks.map(task => {
32
+ const dataBuffer = Buffer.from(JSON.stringify(task));
33
+ return topic.publishMessage({ data: dataBuffer })
34
+ .catch(err => logger.log('ERROR', `[Core Utils] Failed to publish single message for ${taskType}`, { error: err.message, task: task }));
35
+ });
36
+ await Promise.all(batchPromises);
37
+ messagesPublished += batchTasks.length;
38
+ logger.log('TRACE', `[Core Utils] Published batch ${Math.ceil((i + 1) / maxPubsubBatchSize)} for ${taskType} (${batchTasks.length} messages)`);
39
+ }
40
+
41
+ logger.log('SUCCESS', `[Core Utils] Finished publishing ${messagesPublished} ${taskType} tasks to ${topicName}.`);
42
+
43
+ } catch (error) {
44
+ logger.log('ERROR', `[Core Utils] Error during batch publishing of ${taskType} tasks to ${topicName}`, { errorMessage: error.message });
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ module.exports = {
50
+ batchPublishTasks,
51
+ };
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @fileoverview Helper functions specific to the Orchestrator's discovery logic.
3
+ */
4
+ const { logger } = require("sharedsetup")(__filename);
5
+ const coreUtils = require('../../core/utils');
6
+ const { firestore: firestoreUtils, pubsub: pubsubUtils } = coreUtils;
7
+
8
+ // --- All Constants Removed ---
9
+
10
+ /**
11
+ * Checks if discovery is needed for a given user type based on block capacities.
12
+ * @async
13
+ * @param {string} userType - 'normal' or 'speculator'.
14
+ * @param {number} targetUsersPerBlock - Target number of users per block.
15
+ * @param {object} config - Configuration object.
16
+ * @param {string} config.speculatorBlockCountsDocPath - Path to speculator counts doc.
17
+ * @param {string} config.normalBlockCountsDocPath - Path to normal counts doc.
18
+ * @param {Array<number>} config.speculatorInstruments - Array of instrument IDs.
19
+ * @param {Array<object>} config.allHighValueBlocks - Array of block objects (e.g., [{ startId: 19000000 }]).
20
+ * @returns {Promise<{needsDiscovery: boolean, blocksToFill: Array<object>}>}
21
+ */
22
+ async function checkDiscoveryNeed(userType, targetUsersPerBlock, config) {
23
+ logger.log('INFO', `[Orchestrator Helpers] Checking discovery need for ${userType}...`);
24
+ const {
25
+ speculatorBlockCountsDocPath,
26
+ normalBlockCountsDocPath,
27
+ speculatorInstruments,
28
+ allHighValueBlocks
29
+ } = config;
30
+
31
+ const isSpeculator = userType === 'speculator';
32
+ const blockCounts = await firestoreUtils.getBlockCapacities(
33
+ userType,
34
+ speculatorBlockCountsDocPath,
35
+ normalBlockCountsDocPath
36
+ );
37
+
38
+ let underPopulatedBlocks = [];
39
+ if (isSpeculator) {
40
+ for (const instrument of speculatorInstruments) {
41
+ for (const block of allHighValueBlocks) {
42
+ const instrumentBlockKey = `${instrument}_${block.startId}`;
43
+ if ((blockCounts[instrumentBlockKey] || 0) < targetUsersPerBlock) {
44
+ underPopulatedBlocks.push({ ...block, instrument });
45
+ }
46
+ }
47
+ }
48
+ } else {
49
+ underPopulatedBlocks = allHighValueBlocks.filter(
50
+ block => (blockCounts[block.startId] || 0) < targetUsersPerBlock
51
+ );
52
+ }
53
+
54
+ const needsDiscovery = underPopulatedBlocks.length > 0;
55
+ if (!needsDiscovery) {
56
+ logger.log('SUCCESS', `✅ All blocks are at target capacity for ${userType} users.`);
57
+ } else {
58
+ logger.log('INFO', `Found ${underPopulatedBlocks.length} underpopulated blocks/instruments for ${userType}.`);
59
+ }
60
+ return { needsDiscovery, blocksToFill: underPopulatedBlocks };
61
+ }
62
+
63
+ /**
64
+ * Gathers candidate user IDs for discovery.
65
+ * @async
66
+ * @param {string} userType - 'normal' or 'speculator'.
67
+ * @param {Array<object>} blocksToFill - From checkDiscoveryNeed.
68
+ * @param {object} config - Configuration object.
69
+ * @param {number} config.maxTasksPerRun - Max total candidates to generate.
70
+ * @param {number} config.discoveryBatchSize - Size of each random batch to generate.
71
+ * @param {number} config.maxRandomCidsToDiscover - Max random CIDs to add.
72
+ * @param {string} config.specBlocksCollection - e.g., 'SpeculatorBlocks'.
73
+ * @param {string} config.pendingSpecCollection - e.g., 'PendingSpeculators'.
74
+ * @param {string} config.normalUserCollection - e.g., 'NormalUserPortfolios'.
75
+ * @param {string} config.invalidSpecCollection - e.g., 'InvalidSpeculators'.
76
+ * @param {Set<number>} config.speculatorInstrumentSet - Set of instrument IDs.
77
+ * @param {string} config.snapshotsSubCollectionName - e.g., 'snapshots'.
78
+ * @param {string} config.partsSubCollectionName - e.g., 'parts'.
79
+ * @returns {Promise<Set<string>>} Set of candidate CIDs.
80
+ */
81
+ async function getDiscoveryCandidates(userType, blocksToFill, config) {
82
+ logger.log('INFO', `[Orchestrator Helpers] Getting discovery candidates for ${userType}...`);
83
+ const {
84
+ maxTasksPerRun,
85
+ discoveryBatchSize,
86
+ maxRandomCidsToDiscover,
87
+ specBlocksCollection,
88
+ pendingSpecCollection,
89
+ normalUserCollection,
90
+ invalidSpecCollection,
91
+ speculatorInstrumentSet,
92
+ snapshotsSubCollectionName,
93
+ partsSubCollectionName
94
+ } = config;
95
+
96
+ const isSpeculator = userType === 'speculator';
97
+ const dispatchedCids = new Set();
98
+
99
+ const exclusionIds = await firestoreUtils.getExclusionIds(userType, {
100
+ specBlocksCollection,
101
+ pendingSpecCollection,
102
+ normalUserCollection,
103
+ invalidSpecCollection
104
+ });
105
+
106
+ // --- Prioritization for Speculators ---
107
+ if (isSpeculator) {
108
+ const prioritizedCandidates = await firestoreUtils.getPrioritizedSpeculators(
109
+ exclusionIds,
110
+ speculatorInstrumentSet,
111
+ normalUserCollection,
112
+ snapshotsSubCollectionName,
113
+ partsSubCollectionName
114
+ );
115
+ logger.log('INFO', `Found ${prioritizedCandidates.length} potential new speculators from existing user pool.`);
116
+
117
+ prioritizedCandidates.forEach(cidStr => {
118
+ if (dispatchedCids.size < maxTasksPerRun) {
119
+ dispatchedCids.add(cidStr);
120
+ }
121
+ });
122
+ logger.log('INFO', `Added ${dispatchedCids.size} prioritized speculators.`);
123
+ }
124
+
125
+ // --- Random Generation ---
126
+ let dispatchedRandomCidCount = 0;
127
+ const initialDispatchedCount = dispatchedCids.size;
128
+
129
+ while ((dispatchedRandomCidCount + initialDispatchedCount) < maxRandomCidsToDiscover &&
130
+ dispatchedCids.size < maxTasksPerRun && blocksToFill.length > 0)
131
+ {
132
+ const blockIndex = dispatchedCids.size % blocksToFill.length;
133
+ const block = blocksToFill[blockIndex];
134
+ if (!block) break;
135
+
136
+ for (let j = 0; j < discoveryBatchSize; j++) {
137
+ if ((dispatchedRandomCidCount + initialDispatchedCount) >= maxRandomCidsToDiscover || dispatchedCids.size >= maxTasksPerRun) break;
138
+
139
+ let randomId;
140
+ let retryCount = 0;
141
+ const MAX_RETRIES = 50; // This can remain as it's algorithm-specific
142
+ do {
143
+ if (++retryCount > MAX_RETRIES) break;
144
+ randomId = String(Math.floor(Math.random() * 1000000) + block.startId);
145
+ } while (
146
+ exclusionIds.has(randomId) ||
147
+ dispatchedCids.has(randomId)
148
+ );
149
+
150
+ if (retryCount <= MAX_RETRIES) {
151
+ dispatchedCids.add(randomId);
152
+ dispatchedRandomCidCount++;
153
+ }
154
+ }
155
+ }
156
+
157
+ logger.log('INFO', `Generated ${dispatchedRandomCidCount} random CIDs for ${userType} discovery.`);
158
+ logger.log('INFO', `Total candidates for dispatch: ${dispatchedCids.size}`);
159
+ return dispatchedCids;
160
+ }
161
+
162
+ /**
163
+ * Manages pending lists and dispatches discovery tasks.
164
+ * @async
165
+ * @param {string} userType - 'normal' or 'speculator'.
166
+ * @param {Set<string>} candidates - Set of candidate CIDs.
167
+ * @param {object} config - Configuration object.
168
+ * @param {string} config.topicName - The Pub/Sub topic to publish tasks to.
169
+ * @param {number} config.dispatchBatchSize - How many CIDs per Pub/Sub message (task).
170
+ * @param {number} config.pubsubBatchSize - Max messages per Pub/Sub publish call.
171
+ * @param {string} config.pendingSpeculatorsCollection - e.g., 'PendingSpeculators'.
172
+ * @param {number} config.pendingMaxFieldsPerDoc - Max fields for sharded doc.
173
+ * @param {number} config.pendingMaxWritesPerBatch - Max docs for sharded write batch.
174
+ * @returns {Promise<void>}
175
+ */
176
+ async function dispatchDiscovery(userType, candidates, config) {
177
+ const {
178
+ topicName,
179
+ dispatchBatchSize,
180
+ pubsubBatchSize,
181
+ pendingSpeculatorsCollection,
182
+ pendingMaxFieldsPerDoc,
183
+ pendingMaxWritesPerBatch
184
+ } = config;
185
+
186
+ if (candidates.size === 0) {
187
+ logger.log('INFO', `[Orchestrator Helpers] No ${userType} candidates to dispatch.`);
188
+ return;
189
+ }
190
+ logger.log('INFO', `[Orchestrator Helpers] Dispatching ${candidates.size} discovery tasks for ${userType}...`);
191
+
192
+ const isSpeculator = userType === 'speculator';
193
+ const cidsArray = Array.from(candidates);
194
+
195
+ if (isSpeculator) {
196
+ await firestoreUtils.clearCollection(pendingSpeculatorsCollection);
197
+ await firestoreUtils.batchWriteShardedIds(
198
+ pendingSpeculatorsCollection,
199
+ cidsArray,
200
+ new Date(),
201
+ pendingMaxFieldsPerDoc,
202
+ pendingMaxWritesPerBatch
203
+ );
204
+ }
205
+
206
+ // Format candidates into tasks IN BATCHES
207
+ const tasks = [];
208
+ for (let i = 0; i < cidsArray.length; i += dispatchBatchSize) {
209
+ const batchCids = cidsArray.slice(i, i + dispatchBatchSize).map(cid => parseInt(cid));
210
+ if (batchCids.length > 0) {
211
+ const blockId = Math.floor(batchCids[0] / 1000000) * 1000000;
212
+ tasks.push({
213
+ type: 'discover',
214
+ cids: batchCids,
215
+ blockId,
216
+ userType
217
+ });
218
+ }
219
+ }
220
+
221
+ await pubsubUtils.batchPublishTasks(
222
+ topicName,
223
+ tasks,
224
+ `${userType} discovery`,
225
+ pubsubBatchSize
226
+ );
227
+ logger.log('SUCCESS', `[Orchestrator Helpers] Dispatched ${tasks.length} task messages (${candidates.size} CIDs) for ${userType} discovery.`);
228
+ }
229
+
230
+
231
+ module.exports = {
232
+ checkDiscoveryNeed,
233
+ getDiscoveryCandidates,
234
+ dispatchDiscovery,
235
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @fileoverview Exports Orchestrator helper modules.
3
+ */
4
+
5
+ const discoveryHelpers = require('./discovery_helpers');
6
+ const updateHelpers = require('./update_helpers');
7
+
8
+ module.exports = {
9
+ discovery: discoveryHelpers,
10
+ updates: updateHelpers,
11
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @fileoverview Helper functions specific to the Orchestrator's update logic.
3
+ */
4
+ const { logger } = require("sharedsetup")(__filename);
5
+ const coreUtils = require('../../core/utils');
6
+ const { firestore: firestoreUtils, pubsub: pubsubUtils } = coreUtils;
7
+
8
+ // --- Constants Removed ---
9
+
10
+ /**
11
+ * Gets the target users/instruments that need updating based on thresholds.
12
+ * @async
13
+ * @param {string} userType - 'normal' or 'speculator'.
14
+ * @param {object} thresholds - Contains date thresholds.
15
+ * @param {Date} thresholds.dateThreshold - The primary date threshold.
16
+ * @param {Date} [thresholds.gracePeriodThreshold] - Speculator inactivity grace period.
17
+ * @param {object} config - Configuration object.
18
+ * @param {string} config.normalUserCollectionName - e.g., 'NormalUserPortfolios'.
19
+ * @param {string} config.normalUserTimestampsDocId - e.g., 'normal'.
20
+ * @param {string} config.speculatorBlocksCollectionName - e.g., 'SpeculatorBlocks'.
21
+ * @returns {Promise<Array<any>>} Array of targets (user IDs or user/instrument objects).
22
+ */
23
+ async function getUpdateTargets(userType, thresholds, config) {
24
+ logger.log('INFO', `[Orchestrator Helpers] Getting update targets for ${userType}...`);
25
+ let targets = [];
26
+
27
+ try {
28
+ if (userType === 'normal') {
29
+ targets = await firestoreUtils.getNormalUsersToUpdate(
30
+ thresholds.dateThreshold,
31
+ config.normalUserCollectionName,
32
+ config.normalUserTimestampsDocId
33
+ );
34
+ } else if (userType === 'speculator') {
35
+ targets = await firestoreUtils.getSpeculatorsToUpdate(
36
+ thresholds.dateThreshold,
37
+ thresholds.gracePeriodThreshold,
38
+ config.speculatorBlocksCollectionName
39
+ );
40
+ }
41
+ logger.log('SUCCESS', `[Orchestrator Helpers] Found ${targets.length} targets for ${userType} update.`);
42
+ return targets;
43
+ } catch (error) {
44
+ logger.log('ERROR', `[Orchestrator Helpers] Error getting update targets for ${userType}`, { errorMessage: error.message });
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Formats targets into tasks and dispatches them to the specified topic.
51
+ * @async
52
+ * @param {Array<any>} targets - Array of targets from getUpdateTargets.
53
+ * @param {string} userType - 'normal' or 'speculator'.
54
+ * @param {string} topicName - The Pub/Sub topic to publish tasks to (Dispatcher topic).
55
+ * @param {number} taskBatchSize - Max tasks per dispatcher message (e.g., 500).
56
+ * @param {number} pubsubBatchSize - Max messages per Pub/Sub publish call.
57
+ * @returns {Promise<void>}
58
+ */
59
+ async function dispatchUpdates(targets, userType, topicName, taskBatchSize, pubsubBatchSize) {
60
+ if (targets.length === 0) {
61
+ logger.log('INFO', `[Orchestrator Helpers] No ${userType} update targets to dispatch.`);
62
+ return;
63
+ }
64
+ logger.log('INFO', `[Orchestrator Helpers] Dispatching ${targets.length} update tasks for ${userType} to ${topicName}...`);
65
+
66
+ const individualTasks = targets.map(target => ({
67
+ type: 'update',
68
+ userId: userType === 'normal' ? target : target.userId,
69
+ instrumentId: userType === 'speculator' ? target.instrumentId : undefined,
70
+ userType,
71
+ }));
72
+
73
+ const dispatcherMessages = [];
74
+ for (let i = 0; i < individualTasks.length; i += taskBatchSize) {
75
+ const batch = individualTasks.slice(i, i + taskBatchSize);
76
+ dispatcherMessages.push({ tasks: batch }); // Wrap the batch
77
+ }
78
+
79
+ try {
80
+ await pubsubUtils.batchPublishTasks(
81
+ topicName,
82
+ dispatcherMessages,
83
+ `${userType} update batch`,
84
+ pubsubBatchSize // Pass this down to the core util
85
+ );
86
+ logger.log('SUCCESS', `[Orchestrator Helpers] Published ${dispatcherMessages.length} messages (${individualTasks.length} tasks) for ${userType} updates to the dispatcher.`);
87
+ } catch (error) {
88
+ logger.log('ERROR', `[Orchestrator Helpers] Error dispatching update tasks for ${userType}`, { errorMessage: error.message });
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ module.exports = {
94
+ getUpdateTargets,
95
+ dispatchUpdates,
96
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @fileoverview Exports modules related to the Orchestrator function.
3
+ */
4
+
5
+ const helpers = require('./helpers');
6
+ // const utils = require('./utils'); // If function-specific utils are needed
7
+
8
+ module.exports = {
9
+ helpers,
10
+ // utils,
11
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @fileoverview Exports Orchestrator-specific utility functions.
3
+ * (Currently empty as per design, but structure is ready).
4
+ */
5
+
6
+ // Add Orchestrator-specific utils here if needed.
7
+
8
+ module.exports = {
9
+ // export utils
10
+ };
package/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @fileoverview Main entry point for the BulltrackersModule package.
3
+ * Exports core utilities and function-specific modules.
4
+ */
5
+
6
+ const coreUtils = require('./functions/core/utils');
7
+ const orchestrator = require('./functions/orchestrator'); // Adjust path as needed
8
+
9
+ module.exports = {
10
+ core: {
11
+ utils: coreUtils,
12
+ logging: coreUtils.logging // Expose logging wrapper directly under core
13
+ },
14
+ Orchestrator: orchestrator,
15
+ // Add other function modules here as they are refactored (e.g., TaskEngine, ComputationSystem)
16
+ };
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "bulltrackers-module",
3
+ "version": "1.0.0",
4
+ "description": "Helper Functions for Bulltrackers.",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "functions/",
9
+ "utils/"
10
+ ],
11
+ "scripts": {
12
+ "test": "echo \"Error: no test specified\" && exit 1"
13
+ },
14
+ "keywords": [
15
+ "bulltrackers",
16
+ "etoro",
17
+ "precompute",
18
+ "calculations",
19
+ "finance"
20
+ ],
21
+ "dependencies": {
22
+ "@google-cloud/firestore": "^7.11.3",
23
+ "sharedsetup": "latest",
24
+ "require-all": "^3.0.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }