bulltrackers-module 1.0.153 → 1.0.154

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,6 +1,8 @@
1
1
  /*
2
2
  * FILENAME: CloudFunctions/NpmWrappers/bulltrackers-module/functions/task-engine/helpers/update_helpers.js
3
3
  * (MODIFIED: To conditionally fetch history API once per user per batch)
4
+ * (MODIFIED: `lookupUsernames` runs batches in parallel)
5
+ * (MODIFIED: `handleUpdate` fetches history and all portfolios in parallel)
4
6
  */
5
7
 
6
8
  /**
@@ -12,70 +14,108 @@
12
14
  * --- MODIFIED: Conditionally fetches history only once per user per batch. ---
13
15
  */
14
16
  const { FieldValue } = require('@google-cloud/firestore');
17
+ const pLimit = require('p-limit'); // <--- IMPORT p-limit
15
18
 
16
- async function lookupUsernames(cids, { logger, headerManager, proxyManager }, { USERNAME_LOOKUP_BATCH_SIZE, ETORO_API_RANKINGS_URL }) {
19
+ /**
20
+ * (MODIFIED: Runs lookup batches in parallel)
21
+ */
22
+ async function lookupUsernames(cids, { logger, headerManager, proxyManager }, config) {
17
23
  if (!cids?.length) return [];
18
24
  logger.log('INFO', `[lookupUsernames] Looking up usernames for ${cids.length} CIDs.`);
19
- const allUsers = [];
20
- for (let i = 0; i < cids.length; i += USERNAME_LOOKUP_BATCH_SIZE) {
21
- const batch = cids.slice(i, i + USERNAME_LOOKUP_BATCH_SIZE).map(Number);
25
+ const limit = pLimit(config.USERNAME_LOOKUP_CONCURRENCY || 5);
26
+ const { USERNAME_LOOKUP_BATCH_SIZE, ETORO_API_RANKINGS_URL } = config;
27
+ const batches = [];
28
+ for (let i = 0; i < cids.length; i += USERNAME_LOOKUP_BATCH_SIZE) { batches.push(cids.slice(i, i + USERNAME_LOOKUP_BATCH_SIZE).map(Number)); }
29
+
30
+ const batchPromises = batches.map(batch => limit(async () => {
22
31
  const header = await headerManager.selectHeader();
23
- if (!header) { logger.log('ERROR', '[lookupUsernames] Could not select a header.'); continue; }
32
+ if (!header) { logger.log('ERROR', '[lookupUsernames] Could not select a header.'); return null; }
24
33
  let success = false;
25
34
  try {
26
35
  const res = await proxyManager.fetch(`${ETORO_API_RANKINGS_URL}?Period=LastTwoYears`, { method: 'POST', headers: { ...header.header, 'Content-Type': 'application/json' }, body: JSON.stringify(batch) });
27
36
  if (!res.ok) throw new Error(`API status ${res.status}`);
28
37
  const data = await res.json();
29
- if (Array.isArray(data)) allUsers.push(...data);
30
- success = true; logger.log('DEBUG', 'Looked up usernames', { batch })
31
- } catch (err) {
32
- logger.log('WARN', `[lookupUsernames] Failed batch`, { error: err.message });
33
- } finally { headerManager.updatePerformance(header.id, success); }
34
- }
38
+ success = true;
39
+ logger.log('DEBUG', 'Looked up usernames', { batch: batch.slice(0, 5) });
40
+ return data;
41
+ } catch (err) { logger.log('WARN', `[lookupUsernames] Failed batch`, { error: err.message }); return null;
42
+ } finally { headerManager.updatePerformance(header.id, success); } }));
43
+ const results = await Promise.allSettled(batchPromises);
44
+ const allUsers = results.filter(r => r.status === 'fulfilled' && r.value && Array.isArray(r.value)).flatMap(r => r.value);
35
45
  logger.log('INFO', `[lookupUsernames] Found ${allUsers.length} public users out of ${cids.length}.`);
36
46
  return allUsers;
37
47
  }
38
48
 
49
+
50
+ /**
51
+ * (MODIFIED: Fetches history and all portfolios in parallel)
52
+ */
39
53
  async function handleUpdate(task, taskId, { logger, headerManager, proxyManager, db, batchManager }, config, username, historyFetchedForUser) {
40
54
  const { userId, instruments, instrumentId, userType } = task;
41
55
  const instrumentsToProcess = userType === 'speculator' ? (instruments || [instrumentId]) : [undefined];
42
56
  const today = new Date().toISOString().slice(0, 10);
43
57
  const portfolioBlockId = `${Math.floor(parseInt(userId) / 1000000)}M`;
44
- let portfolioHeader = await headerManager.selectHeader();
58
+
45
59
  let historyHeader = null;
46
- if (!portfolioHeader) throw new Error("Could not select portfolio header.");
47
- let wasHistorySuccess = false, isPrivate = false;
48
- let fetchHistory = false;
60
+ let wasHistorySuccess = false;
61
+ let historyFetchPromise = null;
62
+ let isPrivate = false;
63
+
49
64
  try {
50
- const promisesToRun = [];
65
+ // --- 1. Prepare History Fetch (if needed) ---
51
66
  if (!historyFetchedForUser.has(userId)) {
52
67
  historyHeader = await headerManager.selectHeader();
53
- if (historyHeader) { fetchHistory = true; historyFetchedForUser.add(userId); const historyUrl = `${config.ETORO_API_USERSTATS_URL}${username}/trades/oneYearAgo?CopyAsAsset=true`; promisesToRun.push(proxyManager.fetch(historyUrl, { headers: historyHeader.header })); } }
54
- if (fetchHistory) {
55
- const results = await Promise.allSettled(promisesToRun);
56
- const historyRes = results[0];
68
+ if (historyHeader) { historyFetchedForUser.add(userId);
69
+ const historyUrl = `${config.ETORO_API_USERSTATS_URL}${username}/trades/oneYearAgo?CopyAsAsset=true`;
70
+ historyFetchPromise = proxyManager.fetch(historyUrl, { headers: historyHeader.header }); } }
71
+
72
+ // --- 2. Prepare All Portfolio Fetches ---
73
+ const portfolioRequests = [];
74
+ for (const instId of instrumentsToProcess) {
75
+ const portfolioHeader = await headerManager.selectHeader();
76
+ if (!portfolioHeader) throw new Error(`Could not select portfolio header for ${userId}`);
77
+ const portfolioUrl = userType === 'speculator' ? `${config.ETORO_API_POSITIONS_URL}?cid=${userId}&InstrumentID=${instId}` : `${config.ETORO_API_PORTFOLIO_URL}?cid=${userId}`;
78
+ portfolioRequests.push({ instrumentId: instId, url: portfolioUrl, header: portfolioHeader, promise: proxyManager.fetch(portfolioUrl, { headers: portfolioHeader.header }) }); }
79
+
80
+ // --- 3. Execute All API Calls in Parallel ---
81
+ const allPromises = [ ...(historyFetchPromise ? [historyFetchPromise] : []), ...portfolioRequests.map(r => r.promise) ];
82
+ const allResults = await Promise.allSettled(allPromises);
83
+
84
+ // --- 4. Process History Result ---
85
+ let resultIndex = 0;
86
+ if (historyFetchPromise) {
87
+ const historyRes = allResults[resultIndex++];
57
88
  if (historyRes.status === 'fulfilled' && historyRes.value.ok) { const data = await historyRes.value.json(); wasHistorySuccess = true; await batchManager.addToTradingHistoryBatch(userId, portfolioBlockId, today, data, userType); } }
58
- for (const instrumentId of instrumentsToProcess) {
59
- const portfolioUrl = userType === 'speculator' ? `${config.ETORO_API_POSITIONS_URL}?cid=${userId}&InstrumentID=${instrumentId}` : `${config.ETORO_API_PORTFOLIO_URL}?cid=${userId}`;
89
+
90
+ // --- 5. Process Portfolio Results ---
91
+ for (let i = 0; i < portfolioRequests.length; i++) {
92
+ const requestInfo = portfolioRequests[i];
93
+ const portfolioRes = allResults[resultIndex++];
60
94
  let wasPortfolioSuccess = false;
61
- const portfolioRes = await proxyManager.fetch(portfolioUrl, { headers: portfolioHeader.header });
62
- if (portfolioRes.ok) {
63
- const body = await portfolioRes.text();
64
- if (body.includes("user is PRIVATE")) { isPrivate = true; logger.log('WARN', `User ${userId} is private. Removing from updates.`) ; break;
65
- } else { wasPortfolioSuccess = true; await batchManager.addToPortfolioBatch(userId, portfolioBlockId, today, JSON.parse(body), userType, instrumentId); } }
66
- logger.log('DEBUG', 'Processing portfolio for user', { userId, portfolioUrl })
67
- headerManager.updatePerformance(portfolioHeader.id, wasPortfolioSuccess);
68
- if (instrumentsToProcess.length > 1 && instrumentId !== instrumentsToProcess[instrumentsToProcess.length - 1]) {
69
- portfolioHeader = await headerManager.selectHeader(); } }
95
+ if (portfolioRes.status === 'fulfilled' && portfolioRes.value.ok) {
96
+ const body = await portfolioRes.value.text();
97
+ if (body.includes("user is PRIVATE")) { isPrivate = true; logger.log('WARN', `User ${userId} is private. Removing from updates.`); break;
98
+ } else { wasPortfolioSuccess = true; await batchManager.addToPortfolioBatch(userId, portfolioBlockId, today, JSON.parse(body), userType, requestInfo.instrumentId); }
99
+ logger.log('DEBUG', 'Processing portfolio for user', { userId, portfolioUrl: requestInfo.url });
100
+ } else { logger.log('WARN', `Failed to fetch portfolio`, { userId, url: requestInfo.url, error: portfolioRes.reason || `status ${portfolioRes.value?.status}` }); }
101
+ headerManager.updatePerformance(requestInfo.header.id, wasPortfolioSuccess);
102
+ }
103
+
104
+ // --- 6. Handle Private Users & Timestamps ---
70
105
  if (isPrivate) {
71
106
  logger.log('WARN', `User ${userId} is private. Removing from updates.`);
72
107
  for (const instrumentId of instrumentsToProcess) { await batchManager.deleteFromTimestampBatch(userId, userType, instrumentId); }
73
108
  const blockCountsRef = db.doc(config.FIRESTORE_DOC_SPECULATOR_BLOCK_COUNTS);
74
- for (const instrumentId of instrumentsToProcess) { const incrementField = `counts.${instrumentId}_${Math.floor(userId/1e6)*1e6}`; await blockCountsRef.set({ [incrementField]: FieldValue.increment(-1) }, { merge: true }); }
75
- return; }
109
+ for (const instrumentId of instrumentsToProcess) {
110
+ const incrementField = `counts.${instrumentId}_${Math.floor(userId/1e6)*1e6}`;
111
+ await blockCountsRef.set({ [incrementField]: FieldValue.increment(-1) }, { merge: true }); }
112
+ return;
113
+ }
114
+
76
115
  for (const instrumentId of instrumentsToProcess) { await batchManager.updateUserTimestamp(userId, userType, instrumentId); }
77
116
  if (userType === 'speculator') { await batchManager.addSpeculatorTimestampFix(userId, String(Math.floor(userId/1e6)*1e6)); }
78
- } finally { if (historyHeader && fetchHistory) { headerManager.updatePerformance(historyHeader.id, wasHistorySuccess); }}
117
+
118
+ } finally { if (historyHeader) { headerManager.updatePerformance(historyHeader.id, wasHistorySuccess); } }
79
119
  }
80
120
 
81
121
  module.exports = { handleUpdate, lookupUsernames };
@@ -29,7 +29,22 @@ class FirestoreBatchManager {
29
29
 
30
30
  _getUsernameShardId(cid) { return `cid_map_shard_${Math.floor(parseInt(cid) / 10000) % 10}`; }
31
31
 
32
- _scheduleFlush() { if (!this.batchTimeout) this.batchTimeout = setTimeout(() => this.flushBatches(), this.config.TASK_ENGINE_FLUSH_INTERVAL_MS); }
32
+ // _scheduleFlush() { if (!this.batchTimeout) this.batchTimeout = setTimeout(() => this.flushBatches(), this.config.TASK_ENGINE_FLUSH_INTERVAL_MS); } Old version
33
+
34
+ _scheduleFlush() {
35
+ const totalOps = this._estimateBatchSize();
36
+ if (totalOps >= 400) { this.flushBatches(); return; }
37
+ if (!this.batchTimeout) { this.batchTimeout = setTimeout(() => this.flushBatches(), this.config.TASK_ENGINE_FLUSH_INTERVAL_MS); }
38
+ }
39
+
40
+ _estimateBatchSize() {
41
+ let ops = 0;
42
+ ops += Object.keys(this.portfolioBatch).length;
43
+ ops += Object.keys(this.tradingHistoryBatch).length;
44
+ ops += Object.keys(this.timestampBatch).length;
45
+ ops += Object.keys(this.speculatorTimestampFixBatch).length;
46
+ return ops;
47
+ }
33
48
 
34
49
  async loadUsernameMap() {
35
50
  if (Date.now() - this.usernameMapLastLoaded < 3600000) return;
@@ -1,6 +1,7 @@
1
1
  /*
2
2
  * FILENAME: CloudFunctions/NpmWrappers/bulltrackers-module/functions/task-engine/utils/task_engine_utils.js
3
3
  * (MODIFIED: To pass down a Set to track history fetches)
4
+ * (MODIFIED: To run all update tasks in parallel with a concurrency limit)
4
5
  */
5
6
 
6
7
  /**
@@ -12,6 +13,7 @@
12
13
  const { handleDiscover } = require('../helpers/discover_helpers');
13
14
  const { handleVerify } = require('../helpers/verify_helpers');
14
15
  const { handleUpdate, lookupUsernames } = require('../helpers/update_helpers');
16
+ const pLimit = require('p-limit');
15
17
 
16
18
  /**
17
19
  * Parses Pub/Sub message into task array.
@@ -60,11 +62,15 @@ async function executeTasks(tasksToRun, otherTasks, dependencies, config, taskId
60
62
  if (handler) try { await handler(task, subTaskId, dependencies, config); }
61
63
  catch (err) { logger.log('ERROR', `[TaskEngine/${taskId}] Error in ${task.type} for ${subTaskId}`, { errorMessage: err.message }); }
62
64
  else logger.log('ERROR', `[TaskEngine/${taskId}] Unknown task type: ${task.type}`); }
63
- for (const { task, username } of tasksToRun) {
65
+ const limit = pLimit(config.TASK_ENGINE_CONCURRENCY || 10);
66
+ let successCount = 0;
67
+ let errorCount = 0;
68
+ const updatePromises = tasksToRun.map(({ task, username }) => {
64
69
  const subTaskId = `${task.type}-${task.userType || 'unknown'}-${task.userId}`;
65
- try { await handleUpdate(task, subTaskId, dependencies, config, username, historyFetchedForUser);}
66
- catch (err) { logger.log('ERROR', `[TaskEngine/${taskId}] Error in handleUpdate for ${task.userId}`, { errorMessage: err.message }); } }
67
- logger.log('SUCCESS', `[TaskEngine/${taskId}] Processed all tasks.`);
70
+ return limit(() => handleUpdate(task, subTaskId, dependencies, config, username, historyFetchedForUser) .catch(err => { logger.log('ERROR', `[TaskEngine/${taskId}] Error in handleUpdate for ${task.userId}`, { errorMessage: err.message }); throw err; }) ); });
71
+ const results = await Promise.allSettled(updatePromises);
72
+ results.forEach(result => { if (result.status === 'fulfilled') { successCount++; } else { errorCount++; } });
73
+ logger.log( errorCount > 0 ? 'WARN' : 'SUCCESS', `[TaskEngine/${taskId}] Processed all ${tasksToRun.length} update tasks. Success: ${successCount}, Failed: ${errorCount}.` );
68
74
  }
69
75
 
70
76
  module.exports = { parseTaskPayload, prepareTaskBatches, runUsernameLookups, executeTasks };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.153",
3
+ "version": "1.0.154",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [