bulltrackers-module 1.0.154 → 1.0.156

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.
@@ -3,8 +3,10 @@
3
3
  * It selects an available (unlocked) proxy for each request and locks it upon failure.
4
4
  * * This module is designed to be reusable and receives all dependencies
5
5
  * (firestore, logger) and configuration via its constructor.
6
+ * --- MODIFIED: Now includes exponential backoff and retries specifically for rate-limit errors. ---
6
7
  */
7
8
  const { FieldValue } = require('@google-cloud/firestore');
9
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
8
10
 
9
11
  class IntelligentProxyManager {
10
12
  /**
@@ -29,6 +31,8 @@ class IntelligentProxyManager {
29
31
  this.proxyLockingEnabled = config.proxyLockingEnabled !== false;
30
32
  this.proxies = {};
31
33
  this.configLastLoaded = 0;
34
+ this.MAX_RETRIES = 3;
35
+ this.INITIAL_BACKOFF_MS = 1000;
32
36
  if (this.proxyUrls.length === 0) { this.logger.log('WARN', '[ProxyManager] No proxy URLs provided in config.');
33
37
  } else { const lockingStatus = this.proxyLockingEnabled ? "Locking Mechanism Enabled" : "Locking Mechanism DISABLED"; this.logger.log('INFO', `[ProxyManager] Initialized with ${this.proxyUrls.length} proxies and ${lockingStatus}.`); }
34
38
  }
@@ -59,10 +63,8 @@ class IntelligentProxyManager {
59
63
  */
60
64
  async _selectProxy() {
61
65
  await this._loadConfig();
62
-
63
66
  const availableProxies = this.proxyLockingEnabled ? Object.values(this.proxies).filter(p => p.status === 'unlocked') : Object.values(this.proxies);
64
- if (availableProxies.length === 0) { const errorMsg = this.proxyLockingEnabled ? "All proxies are locked. No proxy available." : "No proxies are loaded. Cannot make request.";
65
- this.logger.log('ERROR', `[ProxyManager] ${errorMsg}`); throw new Error(errorMsg); }
67
+ if (availableProxies.length === 0) { const errorMsg = this.proxyLockingEnabled ? "All proxies are locked. No proxy available." : "No proxies are loaded. Cannot make request."; this.logger.log('ERROR', `[ProxyManager] ${errorMsg}`); throw new Error(errorMsg); }
66
68
  const selected = availableProxies[Math.floor(Math.random() * availableProxies.length)];
67
69
  return { owner: selected.owner, url: selected.url };
68
70
  }
@@ -75,28 +77,41 @@ class IntelligentProxyManager {
75
77
  if (!this.proxyLockingEnabled) { this.logger.log('TRACE', `[ProxyManager] Locking skipped for ${owner} (locking is disabled).`); return; }
76
78
  if (this.proxies[owner]) { this.proxies[owner].status = 'locked'; }
77
79
  this.logger.log('WARN', `[ProxyManager] Locking proxy: ${owner}`);
78
- try { const docRef = this.firestore.doc(this.PERFORMANCE_DOC_PATH);
79
- await docRef.set({ locks: { [owner]: { locked: true, lastLocked: FieldValue.serverTimestamp() } } }, { merge: true });
80
+ try { const docRef = this.firestore.doc(this.PERFORMANCE_DOC_PATH); await docRef.set({ locks: { [owner]: { locked: true, lastLocked: FieldValue.serverTimestamp() } } }, { merge: true });
80
81
  } catch (error) { this.logger.log('ERROR', `[ProxyManager] Failed to write lock for ${owner} to Firestore.`, { errorMessage: error.message }); }
81
82
  }
82
83
 
83
84
  /**
84
- * Makes a fetch request using a selected proxy.
85
+ * --- MODIFIED: Makes a fetch request with exponential backoff for rate limits ---
85
86
  * @param {string} targetUrl - The URL to fetch.
86
87
  * @param {object} options - Fetch options (e.g., headers).
87
88
  * @returns {Promise<object>} A mock Response object.
88
89
  */
89
90
  async fetch(targetUrl, options = {}) {
90
91
  let proxy = null;
91
- try { proxy = await this._selectProxy();
92
- } catch (error) { return { ok: false, status: 503, error: { message: error.message }, headers: new Headers() }; }
93
- const response = await this._fetchViaAppsScript(proxy.url, targetUrl, options);
94
- if (!response.ok && response.isUrlFetchError) { await this.lockProxy(proxy.owner); }
95
- return response;
92
+ try { proxy = await this._selectProxy(); } catch (error) { return { ok: false, status: 503, error: { message: error.message }, headers: new Headers() }; }
93
+ let backoff = this.INITIAL_BACKOFF_MS;
94
+ let lastResponse = null;
95
+ for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
96
+ const response = await this._fetchViaAppsScript(proxy.url, targetUrl, options);
97
+ lastResponse = response;
98
+ // 1. Success
99
+ if (response.ok) { return response; }
100
+ // 2. Rate Limit Error (Retryable)
101
+ if (response.isRateLimitError) { this.logger.log('WARN', `[ProxyManager] Rate limit hit on proxy ${proxy.owner} (Attempt ${attempt}/${this.MAX_RETRIES}). Backing off for ${backoff}ms...`, { url: targetUrl }); await sleep(backoff); backoff *= 2; continue; }
102
+ // 3. Other Fetch Error (Non-Retryable, Lock Proxy)
103
+ if (response.isUrlFetchError) { this.logger.log('ERROR', `[ProxyManager] Proxy ${proxy.owner} failed (non-rate-limit). Locking proxy.`, { url: targetUrl, status: response.status }); await this.lockProxy(proxy.owner); return response; }
104
+ // 4. Standard Error (e.g., 404, 500 from *target* URL, not proxy)
105
+ return response; }
106
+ // If loop finishes, all retries failed (likely all were rate-limit errors)
107
+ this.logger.log('ERROR', `[ProxyManager] Request failed after ${this.MAX_RETRIES} rate-limit retries.`, { url: targetUrl });
108
+ return lastResponse;
96
109
  }
97
110
 
111
+
98
112
  /**
99
113
  * Internal function to call the Google AppScript proxy.
114
+ * --- MODIFIED: Now adds `isRateLimitError` flag to response ---
100
115
  * @private
101
116
  */
102
117
  async _fetchViaAppsScript(proxyUrl, targetUrl, options) {
@@ -106,19 +121,22 @@ class IntelligentProxyManager {
106
121
  if (!response.ok) {
107
122
  const errorText = await response.text();
108
123
  this.logger.log('WARN', `[ProxyManager] Proxy infrastructure itself failed.`, { status: response.status, proxy: proxyUrl, error: errorText });
109
- return { ok: false, status: response.status, isUrlFetchError: true, error: { message: `Proxy infrastructure failed with status ${response.status}` }, headers: response.headers, text: () => Promise.resolve(errorText) }; }
124
+ const isRateLimit = response.status === 429;
125
+ return { ok: false, status: response.status, isUrlFetchError: true, isRateLimitError: isRateLimit, error: { message: `Proxy infrastructure failed with status ${response.status}` }, headers: response.headers, text: () => Promise.resolve(errorText) }; }
110
126
  const proxyResponse = await response.json();
111
127
  if (proxyResponse.error) {
112
128
  const errorMsg = proxyResponse.error.message || '';
113
- if (errorMsg.toLowerCase().includes('service invoked too many times')) {
114
- this.logger.log('WARN', `[ProxyManager] Proxy quota error: ${proxyUrl}`, { error: proxyResponse.error });
115
- return { ok: false, status: 500, error: proxyResponse.error, isUrlFetchError: true, headers: new Headers() }; }
116
- return { ok: false, status: 500, error: proxyResponse.error, headers: new Headers(), text: () => Promise.resolve(errorMsg) }; }
117
- return { ok: proxyResponse.statusCode >= 200 && proxyResponse.statusCode < 300, status: proxyResponse.statusCode, headers: new Headers(proxyResponse.headers || {}), json: () => Promise.resolve(JSON.parse(proxyResponse.body)), text: () => Promise.resolve(proxyResponse.body), };
118
- } catch (networkError) {
119
- this.logger.log('ERROR', `[ProxyManager] Network error calling proxy: ${proxyUrl}`, { errorMessage: networkError.message });
120
- return { ok: false, status: 0, isUrlFetchError: true, error: { message: `Network error: ${networkError.message}` }, headers: new Headers() }; }
121
- }
129
+ if (errorMsg.toLowerCase().includes('service invoked too many times')) { this.logger.log('WARN', `[ProxyManager] Proxy quota error: ${proxyUrl}`, { error: proxyResponse.error }); return { ok: false, status: 500, error: proxyResponse.error, isUrlFetchError: true, isRateLimitError: true, headers: new Headers() }; }
130
+ return { ok: false, status: 500, error: proxyResponse.error, isUrlFetchError: true, isRateLimitError: false, headers: new Headers(), text: () => Promise.resolve(errorMsg) }; }
131
+ return {
132
+ ok: proxyResponse.statusCode >= 200 && proxyResponse.statusCode < 300,
133
+ status: proxyResponse.statusCode,
134
+ headers: new Headers(proxyResponse.headers || {}),
135
+ json: () => Promise.resolve(JSON.parse(proxyResponse.body)),
136
+ text: () => Promise.resolve(proxyResponse.body),
137
+ isUrlFetchError: false,
138
+ isRateLimitError: false };
139
+ } catch (networkError) { this.logger.log('ERROR', `[ProxyManager] Network error calling proxy: ${proxyUrl}`, { errorMessage: networkError.message }); return { ok: false, status: 0, isUrlFetchError: true, isRateLimitError: false, error: { message: `Network error: ${networkError.message}` }, headers: new Headers() }; } }
122
140
  }
123
141
 
124
142
  module.exports = { IntelligentProxyManager };
@@ -3,6 +3,7 @@
3
3
  * (MODIFIED: To conditionally fetch history API once per user per batch)
4
4
  * (MODIFIED: `lookupUsernames` runs batches in parallel)
5
5
  * (MODIFIED: `handleUpdate` fetches history and all portfolios in parallel)
6
+ * (MODIFIED: `handleUpdate` now uses batchManager for history cache)
6
7
  */
7
8
 
8
9
  /**
@@ -14,7 +15,7 @@
14
15
  * --- MODIFIED: Conditionally fetches history only once per user per batch. ---
15
16
  */
16
17
  const { FieldValue } = require('@google-cloud/firestore');
17
- const pLimit = require('p-limit'); // <--- IMPORT p-limit
18
+ const pLimit = require('p-limit');
18
19
 
19
20
  /**
20
21
  * (MODIFIED: Runs lookup batches in parallel)
@@ -22,26 +23,45 @@ const pLimit = require('p-limit'); // <--- IMPORT p-limit
22
23
  async function lookupUsernames(cids, { logger, headerManager, proxyManager }, config) {
23
24
  if (!cids?.length) return [];
24
25
  logger.log('INFO', `[lookupUsernames] Looking up usernames for ${cids.length} CIDs.`);
26
+
27
+ // Use a new config value, falling back to 5
25
28
  const limit = pLimit(config.USERNAME_LOOKUP_CONCURRENCY || 5);
26
29
  const { USERNAME_LOOKUP_BATCH_SIZE, ETORO_API_RANKINGS_URL } = config;
30
+
27
31
  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)); }
32
+ for (let i = 0; i < cids.length; i += USERNAME_LOOKUP_BATCH_SIZE) {
33
+ batches.push(cids.slice(i, i + USERNAME_LOOKUP_BATCH_SIZE).map(Number));
34
+ }
29
35
 
30
36
  const batchPromises = batches.map(batch => limit(async () => {
31
37
  const header = await headerManager.selectHeader();
32
- if (!header) { logger.log('ERROR', '[lookupUsernames] Could not select a header.'); return null; }
38
+ if (!header) {
39
+ logger.log('ERROR', '[lookupUsernames] Could not select a header.');
40
+ return null; // Return null to skip this batch
41
+ }
42
+
33
43
  let success = false;
34
44
  try {
35
45
  const res = await proxyManager.fetch(`${ETORO_API_RANKINGS_URL}?Period=LastTwoYears`, { method: 'POST', headers: { ...header.header, 'Content-Type': 'application/json' }, body: JSON.stringify(batch) });
36
46
  if (!res.ok) throw new Error(`API status ${res.status}`);
37
47
  const data = await res.json();
38
48
  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); } }));
49
+ logger.log('DEBUG', 'Looked up usernames', { batch: batch.slice(0, 5) }); // Log only a few
50
+ return data; // Return data on success
51
+ } catch (err) {
52
+ logger.log('WARN', `[lookupUsernames] Failed batch`, { error: err.message });
53
+ return null; // Return null on failure
54
+ } finally {
55
+ headerManager.updatePerformance(header.id, success);
56
+ }
57
+ }));
58
+
43
59
  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);
60
+
61
+ const allUsers = results
62
+ .filter(r => r.status === 'fulfilled' && r.value && Array.isArray(r.value))
63
+ .flatMap(r => r.value); // Flatten all successful batch results
64
+
45
65
  logger.log('INFO', `[lookupUsernames] Found ${allUsers.length} public users out of ${cids.length}.`);
46
66
  return allUsers;
47
67
  }
@@ -49,8 +69,9 @@ async function lookupUsernames(cids, { logger, headerManager, proxyManager }, co
49
69
 
50
70
  /**
51
71
  * (MODIFIED: Fetches history and all portfolios in parallel)
72
+ * (MODIFIED: Uses batchManager for history cache)
52
73
  */
53
- async function handleUpdate(task, taskId, { logger, headerManager, proxyManager, db, batchManager }, config, username, historyFetchedForUser) {
74
+ async function handleUpdate(task, taskId, { logger, headerManager, proxyManager, db, batchManager }, config, username) { // <--- REMOVED historyFetchedForUser
54
75
  const { userId, instruments, instrumentId, userType } = task;
55
76
  const instrumentsToProcess = userType === 'speculator' ? (instruments || [instrumentId]) : [undefined];
56
77
  const today = new Date().toISOString().slice(0, 10);
@@ -63,59 +84,111 @@ async function handleUpdate(task, taskId, { logger, headerManager, proxyManager,
63
84
 
64
85
  try {
65
86
  // --- 1. Prepare History Fetch (if needed) ---
66
- if (!historyFetchedForUser.has(userId)) {
87
+ // (MODIFIED: Use batchManager's cross-invocation cache)
88
+ if (!batchManager.checkAndSetHistoryFetched(userId)) {
89
+ // This user has NOT been fetched in the last 10 mins (by this instance)
67
90
  historyHeader = await headerManager.selectHeader();
68
- if (historyHeader) { historyFetchedForUser.add(userId);
91
+ if (historyHeader) {
92
+ // No need to add to a local set, batchManager did it.
69
93
  const historyUrl = `${config.ETORO_API_USERSTATS_URL}${username}/trades/oneYearAgo?CopyAsAsset=true`;
70
- historyFetchPromise = proxyManager.fetch(historyUrl, { headers: historyHeader.header }); } }
94
+ historyFetchPromise = proxyManager.fetch(historyUrl, { headers: historyHeader.header });
95
+ } else {
96
+ logger.log('WARN', `[handleUpdate] Could not select history header for ${userId}. History will be skipped for this task.`);
97
+ }
98
+ } else {
99
+ logger.log('TRACE', `[handleUpdate] History fetch for ${userId} skipped (already fetched by this instance).`);
100
+ }
71
101
 
72
102
  // --- 2. Prepare All Portfolio Fetches ---
73
103
  const portfolioRequests = [];
74
104
  for (const instId of instrumentsToProcess) {
75
105
  const portfolioHeader = await headerManager.selectHeader();
76
106
  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 }) }); }
107
+
108
+ const portfolioUrl = userType === 'speculator'
109
+ ? `${config.ETORO_API_POSITIONS_URL}?cid=${userId}&InstrumentID=${instId}`
110
+ : `${config.ETORO_API_PORTFOLIO_URL}?cid=${userId}`;
111
+
112
+ portfolioRequests.push({
113
+ instrumentId: instId,
114
+ url: portfolioUrl,
115
+ header: portfolioHeader,
116
+ promise: proxyManager.fetch(portfolioUrl, { headers: portfolioHeader.header })
117
+ });
118
+ }
79
119
 
80
120
  // --- 3. Execute All API Calls in Parallel ---
81
- const allPromises = [ ...(historyFetchPromise ? [historyFetchPromise] : []), ...portfolioRequests.map(r => r.promise) ];
121
+ const allPromises = [
122
+ ...(historyFetchPromise ? [historyFetchPromise] : []),
123
+ ...portfolioRequests.map(r => r.promise)
124
+ ];
82
125
  const allResults = await Promise.allSettled(allPromises);
83
126
 
84
127
  // --- 4. Process History Result ---
85
128
  let resultIndex = 0;
86
129
  if (historyFetchPromise) {
87
130
  const historyRes = allResults[resultIndex++];
88
- if (historyRes.status === 'fulfilled' && historyRes.value.ok) { const data = await historyRes.value.json(); wasHistorySuccess = true; await batchManager.addToTradingHistoryBatch(userId, portfolioBlockId, today, data, userType); } }
131
+ if (historyRes.status === 'fulfilled' && historyRes.value.ok) {
132
+ const data = await historyRes.value.json();
133
+ wasHistorySuccess = true;
134
+ await batchManager.addToTradingHistoryBatch(userId, portfolioBlockId, today, data, userType);
135
+ } else {
136
+ logger.log('WARN', `[handleUpdate] History fetch failed for ${userId}`, { error: historyRes.reason || `status ${historyRes.value?.status}` });
137
+ }
138
+ }
89
139
 
90
140
  // --- 5. Process Portfolio Results ---
91
141
  for (let i = 0; i < portfolioRequests.length; i++) {
92
142
  const requestInfo = portfolioRequests[i];
93
143
  const portfolioRes = allResults[resultIndex++];
94
144
  let wasPortfolioSuccess = false;
145
+
95
146
  if (portfolioRes.status === 'fulfilled' && portfolioRes.value.ok) {
96
147
  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); }
148
+ if (body.includes("user is PRIVATE")) {
149
+ isPrivate = true;
150
+ logger.log('WARN', `User ${userId} is private. Removing from updates.`);
151
+ break; // Stop processing more portfolios for this private user
152
+ } else {
153
+ wasPortfolioSuccess = true;
154
+ await batchManager.addToPortfolioBatch(userId, portfolioBlockId, today, JSON.parse(body), userType, requestInfo.instrumentId);
155
+ }
99
156
  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}` }); }
157
+ } else {
158
+ logger.log('WARN', `Failed to fetch portfolio`, { userId, url: requestInfo.url, error: portfolioRes.reason || `status ${portfolioRes.value?.status}` });
159
+ }
160
+ // Update performance for this specific header
101
161
  headerManager.updatePerformance(requestInfo.header.id, wasPortfolioSuccess);
102
162
  }
103
163
 
104
164
  // --- 6. Handle Private Users & Timestamps ---
105
165
  if (isPrivate) {
106
166
  logger.log('WARN', `User ${userId} is private. Removing from updates.`);
107
- for (const instrumentId of instrumentsToProcess) { await batchManager.deleteFromTimestampBatch(userId, userType, instrumentId); }
167
+ for (const instrumentId of instrumentsToProcess) {
168
+ await batchManager.deleteFromTimestampBatch(userId, userType, instrumentId);
169
+ }
108
170
  const blockCountsRef = db.doc(config.FIRESTORE_DOC_SPECULATOR_BLOCK_COUNTS);
109
171
  for (const instrumentId of instrumentsToProcess) {
110
172
  const incrementField = `counts.${instrumentId}_${Math.floor(userId/1e6)*1e6}`;
111
- await blockCountsRef.set({ [incrementField]: FieldValue.increment(-1) }, { merge: true }); }
112
- return;
173
+ // This is not batched, but it's a rare event.
174
+ await blockCountsRef.set({ [incrementField]: FieldValue.increment(-1) }, { merge: true });
175
+ }
176
+ return; // Don't update timestamps
113
177
  }
114
178
 
115
- for (const instrumentId of instrumentsToProcess) { await batchManager.updateUserTimestamp(userId, userType, instrumentId); }
116
- if (userType === 'speculator') { await batchManager.addSpeculatorTimestampFix(userId, String(Math.floor(userId/1e6)*1e6)); }
179
+ // If not private, update all timestamps
180
+ for (const instrumentId of instrumentsToProcess) {
181
+ await batchManager.updateUserTimestamp(userId, userType, instrumentId);
182
+ }
183
+ if (userType === 'speculator') {
184
+ await batchManager.addSpeculatorTimestampFix(userId, String(Math.floor(userId/1e6)*1e6));
185
+ }
117
186
 
118
- } finally { if (historyHeader) { headerManager.updatePerformance(historyHeader.id, wasHistorySuccess); } }
187
+ } finally {
188
+ if (historyHeader) { // historyHeader is only set if a fetch was attempted
189
+ headerManager.updatePerformance(historyHeader.id, wasHistorySuccess);
190
+ }
191
+ }
119
192
  }
120
193
 
121
194
  module.exports = { handleUpdate, lookupUsernames };
@@ -3,6 +3,7 @@
3
3
  * REFACTORED: Renamed 'firestore' to 'db' for consistency.
4
4
  * OPTIMIZED: Added logic to handle speculator timestamp fixes within the batch.
5
5
  * --- MODIFIED: Added username map caching and trading history batching. ---
6
+ * --- MODIFIED: Added cross-invocation cache for history fetches. ---
6
7
  */
7
8
  const { FieldValue } = require('@google-cloud/firestore');
8
9
 
@@ -16,9 +17,18 @@ class FirestoreBatchManager {
16
17
  this.timestampBatch = {};
17
18
  this.tradingHistoryBatch = {};
18
19
  this.speculatorTimestampFixBatch = {};
20
+
21
+ // Username map cache
19
22
  this.usernameMap = new Map();
20
23
  this.usernameMapUpdates = {};
21
24
  this.usernameMapLastLoaded = 0;
25
+
26
+ // History fetch cache (NEW)
27
+ this.historyFetchedUserIds = new Set();
28
+ this.historyCacheTimestamp = Date.now();
29
+ // Set a 10-minute TTL on this cache (600,000 ms)
30
+ this.HISTORY_CACHE_TTL_MS = config.HISTORY_CACHE_TTL_MS || 600000;
31
+
22
32
  this.processedSpeculatorCids = new Set();
23
33
  this.usernameMapCollectionName = config.FIRESTORE_COLLECTION_USERNAME_MAP;
24
34
  this.normalHistoryCollectionName = config.FIRESTORE_COLLECTION_NORMAL_HISTORY;
@@ -27,6 +37,29 @@ class FirestoreBatchManager {
27
37
  logger.log('INFO', 'FirestoreBatchManager initialized.');
28
38
  }
29
39
 
40
+ /*
41
+ * NEW: Checks if a user's history has been fetched in the last 10 minutes.
42
+ * If not, it logs them as fetched and returns false (to trigger a fetch).
43
+ * @param {string} userId
44
+ * @returns {boolean} True if already fetched, false if not.
45
+ */
46
+ checkAndSetHistoryFetched(userId) {
47
+ // Check if the cache is stale
48
+ if (Date.now() - this.historyCacheTimestamp > this.HISTORY_CACHE_TTL_MS) {
49
+ this.logger.log('INFO', '[BATCH] History fetch cache (10m TTL) expired. Clearing set.');
50
+ this.historyFetchedUserIds.clear();
51
+ this.historyCacheTimestamp = Date.now();
52
+ }
53
+
54
+ if (this.historyFetchedUserIds.has(userId)) {
55
+ return true; // Yes, already fetched
56
+ }
57
+
58
+ // Not fetched yet. Mark as fetched and return false.
59
+ this.historyFetchedUserIds.add(userId);
60
+ return false;
61
+ }
62
+
30
63
  _getUsernameShardId(cid) { return `cid_map_shard_${Math.floor(parseInt(cid) / 10000) % 10}`; }
31
64
 
32
65
  // _scheduleFlush() { if (!this.batchTimeout) this.batchTimeout = setTimeout(() => this.flushBatches(), this.config.TASK_ENGINE_FLUSH_INTERVAL_MS); } Old version
@@ -2,6 +2,8 @@
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
4
  * (MODIFIED: To run all update tasks in parallel with a concurrency limit)
5
+ * (MODIFIED: To use a SINGLE parallel work pool for ALL tasks)
6
+ * (MODIFIED: To remove local history cache set)
5
7
  */
6
8
 
7
9
  /**
@@ -13,7 +15,7 @@
13
15
  const { handleDiscover } = require('../helpers/discover_helpers');
14
16
  const { handleVerify } = require('../helpers/verify_helpers');
15
17
  const { handleUpdate, lookupUsernames } = require('../helpers/update_helpers');
16
- const pLimit = require('p-limit');
18
+ const pLimit = require('p-limit'); // <--- IMPORT p-limit
17
19
 
18
20
  /**
19
21
  * Parses Pub/Sub message into task array.
@@ -45,32 +47,72 @@ async function prepareTaskBatches(tasks, batchManager, logger) {
45
47
  async function runUsernameLookups(tasksToRun, cidsToLookup, dependencies, config, batchManager, logger) {
46
48
  if (!cidsToLookup.size) return;
47
49
  logger.log('INFO', `[TaskEngine] Looking up ${cidsToLookup.size} usernames...`);
48
- const foundUsers = await lookupUsernames([...cidsToLookup.keys()], dependencies, config);
50
+ // Pass config to lookupUsernames
51
+ const foundUsers = await lookupUsernames([...cidsToLookup.keys()], dependencies, config); // <--- PASS FULL CONFIG
49
52
  for (const u of foundUsers) { const cid = String(u.CID), username = u.Value.UserName; batchManager.addUsernameMapUpdate(cid, username); const task = cidsToLookup.get(cid); if (task) { tasksToRun.push({ task, username }); cidsToLookup.delete(cid); } }
50
53
  if (cidsToLookup.size) logger.log('WARN', `[TaskEngine] Could not find ${cidsToLookup.size} usernames (likely private).`, { skippedCids: [...cidsToLookup.keys()] });
51
54
  }
52
55
 
53
56
  /**
54
57
  * Executes all tasks.
58
+ * (MODIFIED: Runs ALL tasks in a single parallel pool)
55
59
  */
56
60
  async function executeTasks(tasksToRun, otherTasks, dependencies, config, taskId) {
57
- const { logger } = dependencies;
58
- const historyFetchedForUser = new Set();
61
+ const { logger, batchManager } = dependencies; // <--- Get batchManager
62
+
63
+ // REMOVED: const historyFetchedForUser = new Set();
64
+
65
+ // Create one unified parallel pool
66
+ const limit = pLimit(config.TASK_ENGINE_CONCURRENCY || 10);
67
+ const allTaskPromises = [];
68
+ let taskCounters = { update: 0, discover: 0, verify: 0, unknown: 0, failed: 0 };
69
+
70
+ // 1. Queue 'other' tasks (discover, verify)
59
71
  for (const task of otherTasks) {
60
72
  const subTaskId = `${task.type}-${task.userType || 'unknown'}-${task.userId || task.cids?.[0] || 'sub'}`;
61
73
  const handler = { discover: handleDiscover, verify: handleVerify }[task.type];
62
- if (handler) try { await handler(task, subTaskId, dependencies, config); }
63
- catch (err) { logger.log('ERROR', `[TaskEngine/${taskId}] Error in ${task.type} for ${subTaskId}`, { errorMessage: err.message }); }
64
- else logger.log('ERROR', `[TaskEngine/${taskId}] Unknown task type: ${task.type}`); }
65
- const limit = pLimit(config.TASK_ENGINE_CONCURRENCY || 10);
66
- let successCount = 0;
67
- let errorCount = 0;
68
- const updatePromises = tasksToRun.map(({ task, username }) => {
74
+
75
+ if (handler) {
76
+ allTaskPromises.push(
77
+ limit(() =>
78
+ handler(task, subTaskId, dependencies, config)
79
+ .then(() => taskCounters[task.type]++)
80
+ .catch(err => {
81
+ logger.log('ERROR', `[TaskEngine/${taskId}] Error in ${task.type} for ${subTaskId}`, { errorMessage: err.message });
82
+ taskCounters.failed++;
83
+ })
84
+ )
85
+ );
86
+ } else {
87
+ logger.log('ERROR', `[TaskEngine/${taskId}] Unknown task type: ${task.type}`);
88
+ taskCounters.unknown++;
89
+ }
90
+ }
91
+
92
+ // 2. Queue 'update' tasks
93
+ for (const { task, username } of tasksToRun) {
69
94
  const subTaskId = `${task.type}-${task.userType || 'unknown'}-${task.userId}`;
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}.` );
95
+ allTaskPromises.push(
96
+ limit(() =>
97
+ // Pass batchManager instead of the local set
98
+ handleUpdate(task, subTaskId, dependencies, config, username) // <--- REMOVED historyFetchedForUser
99
+ .then(() => taskCounters.update++)
100
+ .catch(err => {
101
+ logger.log('ERROR', `[TaskEngine/${taskId}] Error in handleUpdate for ${task.userId}`, { errorMessage: err.message });
102
+ taskCounters.failed++;
103
+ })
104
+ )
105
+ );
106
+ }
107
+
108
+ // 3. Wait for ALL tasks to complete
109
+ await Promise.all(allTaskPromises);
110
+
111
+ // 4. Log final summary
112
+ logger.log(
113
+ taskCounters.failed > 0 ? 'WARN' : 'SUCCESS',
114
+ `[TaskEngine/${taskId}] Processed all tasks. Updates: ${taskCounters.update}, Discovers: ${taskCounters.discover}, Verifies: ${taskCounters.verify}, Unknown: ${taskCounters.unknown}, Failed: ${taskCounters.failed}.`
115
+ );
74
116
  }
75
117
 
76
118
  module.exports = { parseTaskPayload, prepareTaskBatches, runUsernameLookups, executeTasks };
package/index.js CHANGED
@@ -1,101 +1,45 @@
1
1
  /**
2
2
  * @fileoverview Main entry point for the Bulltrackers shared module.
3
- * This module consolidates all core logic into a single 'pipe' object
4
- * to enforce a clear naming convention and dependency injection pattern.
3
+ * Export the pipes!
5
4
  */
6
-
7
- // --- Core Utilities (Classes and Stateless Helpers) ---
8
-
9
- const core = {
10
- IntelligentHeaderManager : require('./functions/core/utils/intelligent_header_manager') .IntelligentHeaderManager,
11
- IntelligentProxyManager : require('./functions/core/utils/intelligent_proxy_manager') .IntelligentProxyManager,
12
- FirestoreBatchManager : require('./functions/task-engine/utils/firestore_batch_manager').FirestoreBatchManager,
13
- firestoreUtils : require('./functions/core/utils/firestore_utils'),
14
- pubsubUtils : require('./functions/core/utils/pubsub_utils'),
15
- };
16
-
17
- // --- Pipe 1: Orchestrator ---
18
-
19
- const orchestrator = {
20
- // Main Pipes (Entry points for Cloud Functions)
21
- runDiscoveryOrchestrator : require('./functions/orchestrator/index').runDiscoveryOrchestrator,
22
- runUpdateOrchestrator : require('./functions/orchestrator/index').runUpdateOrchestrator,
23
-
24
- // Sub-Pipes (Discovery)
25
- checkDiscoveryNeed : require('./functions/orchestrator/helpers/discovery_helpers').checkDiscoveryNeed,
26
- getDiscoveryCandidates : require('./functions/orchestrator/helpers/discovery_helpers').getDiscoveryCandidates,
27
- dispatchDiscovery : require('./functions/orchestrator/helpers/discovery_helpers').dispatchDiscovery,
28
-
29
- // Sub-Pipes (Updates)
30
- getUpdateTargets : require('./functions/orchestrator/helpers/update_helpers').getUpdateTargets,
31
- dispatchUpdates : require('./functions/orchestrator/helpers/update_helpers').dispatchUpdates,
32
- };
33
-
34
-
35
- // --- Pipe 2: Dispatcher ---
36
-
37
- const dispatcher = {
38
- handleRequest : require('./functions/dispatcher/index').handleRequest,
39
- dispatchTasksInBatches : require('./functions/dispatcher/helpers/dispatch_helpers').dispatchTasksInBatches,
40
- };
41
-
42
-
43
- // --- Pipe 3: Task Engine ---
44
-
45
- const taskEngine = {
46
- handleRequest : require('./functions/task-engine/handler_creator').handleRequest,
47
- handleDiscover : require('./functions/task-engine/helpers/discover_helpers').handleDiscover,
48
- handleVerify : require('./functions/task-engine/helpers/verify_helpers').handleVerify,
49
- handleUpdate : require('./functions/task-engine/helpers/update_helpers').handleUpdate,
50
- };
51
-
52
-
53
- // --- Pipe 4: Computation System ---
54
-
55
- const computationSystem = {
56
- runComputationPass : require('./functions/computation-system/helpers/computation_pass_runner').runComputationPass,
57
- dataLoader : require('./functions/computation-system/utils/data_loader'),
58
- computationUtils : require('./functions/computation-system/utils/utils'),
59
- };
60
-
61
-
62
- // --- Pipe 5: API ---
63
-
64
- const api = {
65
- createApiApp : require('./functions/generic-api/index').createApiApp,
66
- helpers : require('./functions/generic-api/helpers/api_helpers'),
67
- };
68
-
69
-
70
- // --- Pipe 6: Maintenance ---
71
-
72
- const maintenance = {
73
- runSpeculatorCleanup : require('./functions/speculator-cleanup-orchestrator/helpers/cleanup_helpers') .runCleanup,
74
- handleInvalidSpeculator : require('./functions/invalid-speculator-handler/helpers/handler_helpers') .handleInvalidSpeculator,
75
- runFetchInsights : require('./functions/fetch-insights/helpers/handler_helpers').fetchAndStoreInsights,
76
- runFetchPrices : require('./functions/etoro-price-fetcher/helpers/handler_helpers').fetchAndStorePrices,
77
- runSocialOrchestrator : require('./functions/social-orchestrator/helpers/orchestrator_helpers') .runSocialOrchestrator,
78
- handleSocialTask : require('./functions/social-task-handler/helpers/handler_helpers') .handleSocialTask,
79
- runBackfillAssetPrices : require('./functions/price-backfill/helpers/handler_helpers') .runBackfillAssetPrices,
80
- };
81
-
82
-
83
- // --- Pipe 7: Proxy ---
84
-
85
- const proxy = {
86
- handlePost : require('./functions/appscript-api/index').handlePost,
87
- };
88
-
89
-
90
- module.exports = {
91
- pipe: {
92
- core,
93
- orchestrator,
94
- dispatcher,
95
- taskEngine,
96
- computationSystem,
97
- api,
98
- maintenance,
99
- proxy,
100
- }
101
- };
5
+ // Core
6
+ const core = { IntelligentHeaderManager : require('./functions/core/utils/intelligent_header_manager') .IntelligentHeaderManager,
7
+ IntelligentProxyManager : require('./functions/core/utils/intelligent_proxy_manager') .IntelligentProxyManager,
8
+ FirestoreBatchManager : require('./functions/task-engine/utils/firestore_batch_manager') .FirestoreBatchManager,
9
+ firestoreUtils : require('./functions/core/utils/firestore_utils'),
10
+ pubsubUtils : require('./functions/core/utils/pubsub_utils') };
11
+ // Orchestrator
12
+ const orchestrator = { runDiscoveryOrchestrator : require('./functions/orchestrator/index') .runDiscoveryOrchestrator,
13
+ runUpdateOrchestrator : require('./functions/orchestrator/index') .runUpdateOrchestrator,
14
+ checkDiscoveryNeed : require('./functions/orchestrator/helpers/discovery_helpers') .checkDiscoveryNeed,
15
+ getDiscoveryCandidates : require('./functions/orchestrator/helpers/discovery_helpers') .getDiscoveryCandidates,
16
+ dispatchDiscovery : require('./functions/orchestrator/helpers/discovery_helpers') .dispatchDiscovery,
17
+ getUpdateTargets : require('./functions/orchestrator/helpers/update_helpers') .getUpdateTargets,
18
+ dispatchUpdates : require('./functions/orchestrator/helpers/update_helpers') .dispatchUpdates };
19
+ // Dispatcher
20
+ const dispatcher = { handleRequest : require('./functions/dispatcher/index') .handleRequest ,
21
+ dispatchTasksInBatches : require('./functions/dispatcher/helpers/dispatch_helpers') .dispatchTasksInBatches };
22
+ // Task Engine
23
+ const taskEngine = { handleRequest : require('./functions/task-engine/handler_creator') .handleRequest ,
24
+ handleDiscover : require('./functions/task-engine/helpers/discover_helpers') .handleDiscover,
25
+ handleVerify : require('./functions/task-engine/helpers/verify_helpers') .handleVerify ,
26
+ handleUpdate : require('./functions/task-engine/helpers/update_helpers') .handleUpdate };
27
+ // Computation System
28
+ const computationSystem = { runComputationPass : require('./functions/computation-system/helpers/computation_pass_runner') .runComputationPass,
29
+ dataLoader : require('./functions/computation-system/utils/data_loader'),
30
+ computationUtils : require('./functions/computation-system/utils/utils') };
31
+ // API
32
+ const api = { createApiApp : require('./functions/generic-api/index') .createApiApp,
33
+ helpers : require('./functions/generic-api/helpers/api_helpers') };
34
+ // Maintenance
35
+ const maintenance = { runSpeculatorCleanup : require('./functions/speculator-cleanup-orchestrator/helpers/cleanup_helpers') .runCleanup,
36
+ handleInvalidSpeculator : require('./functions/invalid-speculator-handler/helpers/handler_helpers') .handleInvalidSpeculator,
37
+ runFetchInsights : require('./functions/fetch-insights/helpers/handler_helpers') .fetchAndStoreInsights,
38
+ runFetchPrices : require('./functions/etoro-price-fetcher/helpers/handler_helpers') .fetchAndStorePrices,
39
+ runSocialOrchestrator : require('./functions/social-orchestrator/helpers/orchestrator_helpers') .runSocialOrchestrator,
40
+ handleSocialTask : require('./functions/social-task-handler/helpers/handler_helpers') .handleSocialTask,
41
+ runBackfillAssetPrices : require('./functions/price-backfill/helpers/handler_helpers') .runBackfillAssetPrices };
42
+ // Proxy
43
+ const proxy = { handlePost : require('./functions/appscript-api/index') .handlePost };
44
+
45
+ module.exports = { pipe: { core, orchestrator, dispatcher, taskEngine, computationSystem, api, maintenance, proxy } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.154",
3
+ "version": "1.0.156",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [