bulltrackers-module 1.0.122 → 1.0.124

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,3 +1,8 @@
1
+ /*
2
+ * FILENAME: CloudFunctions/NpmWrappers/bulltrackers-module/functions/orchestrator/helpers/discovery_helpers.js
3
+ * (FIXED: Publishes 'discover' tasks in the correct batch format { tasks: [...] })
4
+ */
5
+
1
6
  /**
2
7
  * @fileoverview Orchestrator's discovery sub-pipes.
3
8
  * REFACTORED: All functions are now stateless and receive dependencies.
@@ -164,11 +169,11 @@ async function getDiscoveryCandidates(userType, blocksToFill, config, dependenci
164
169
  * @returns {Promise<void>}
165
170
  */
166
171
  async function dispatchDiscovery(userType, candidates, config, dependencies) {
167
- const { logger, firestoreUtils, pubsubUtils } = dependencies;
172
+ const { logger, firestoreUtils, pubsubUtils, pubsub } = dependencies; // <-- Add pubsub
168
173
  const {
169
174
  topicName,
170
175
  dispatchBatchSize,
171
- pubsubBatchSize,
176
+ pubsubBatchSize, // This is no longer used by this function
172
177
  pendingSpecCollection,
173
178
  pendingMaxFieldsPerDoc,
174
179
  pendingMaxWritesPerBatch
@@ -208,14 +213,43 @@ async function dispatchDiscovery(userType, candidates, config, dependencies) {
208
213
  }
209
214
  }
210
215
 
211
- // Pass dependencies and config to core util
212
- await pubsubUtils.batchPublishTasks(dependencies, {
213
- topicName,
214
- tasks,
215
- taskType: `${userType} discovery`,
216
- maxPubsubBatchSize: pubsubBatchSize
217
- });
218
- logger.log('SUCCESS', `[Orchestrator Helpers] Dispatched ${tasks.length} task messages (${candidates.size} CIDs) for ${userType} discovery.`);
216
+ // --- START FIX: RE-IMPLEMENT BATCHING LOGIC ---
217
+ const topic = pubsub.topic(topicName);
218
+ let totalCidsPublished = 0;
219
+ let messagesPublished = 0;
220
+
221
+ // 'tasks' is an array of 'discover' task objects. We group them into
222
+ // batches and publish each batch as a single message.
223
+ // We use `dispatchBatchSize` here to determine how many 'discover' tasks go into one message.
224
+ // This value should probably be small, like 1-10.
225
+
226
+ // Let's re-use `dispatchBatchSize` as the number of *tasks* per message.
227
+ // If `tasks.length` is 200 and `dispatchBatchSize` is 50, this will create 4 messages.
228
+ // This seems correct.
229
+
230
+ for (let i = 0; i < tasks.length; i += dispatchBatchSize) {
231
+ // This batch contains multiple 'discover' tasks
232
+ const batchOfTasks = tasks.slice(i, i + dispatchBatchSize);
233
+
234
+ // Wrap this batch in the new payload format
235
+ const messagePayload = { tasks: batchOfTasks };
236
+
237
+ try {
238
+ await topic.publishMessage({ json: messagePayload });
239
+
240
+ // Log the number of CIDs in this message
241
+ const cidsInThisMessage = batchOfTasks.reduce((acc, task) => acc + task.cids.length, 0);
242
+ totalCidsPublished += cidsInThisMessage;
243
+ messagesPublished++;
244
+
245
+ logger.log('INFO', `[Orchestrator Helpers] Dispatched batch ${messagesPublished} with ${batchOfTasks.length} discover tasks (${cidsInThisMessage} CIDs) as 1 Pub/Sub message.`);
246
+ } catch (publishError) {
247
+ logger.log('ERROR', `[Orchestrator Helpers] Failed to publish discover batch ${messagesPublished + 1}.`, { error: publishError.message });
248
+ }
249
+ }
250
+
251
+ logger.log('SUCCESS', `[Orchestrator Helpers] Dispatched ${totalCidsPublished} CIDs in ${tasks.length} tasks, grouped into ${messagesPublished} Pub/Sub messages for ${userType} discovery.`);
252
+ // --- END FIX ---
219
253
  }
220
254
 
221
255
 
@@ -223,4 +257,4 @@ module.exports = {
223
257
  checkDiscoveryNeed,
224
258
  getDiscoveryCandidates,
225
259
  dispatchDiscovery,
226
- };
260
+ };
@@ -10,11 +10,17 @@
10
10
  * FirestoreBatchManager to accumulate state. It then explicitly
11
11
  * calls `batchManager.flushBatches()` in the `finally` block
12
12
  * to commit all changes.
13
+ * --- V2 MODIFICATION ---
14
+ * - Loads username map at start.
15
+ * - Sorts tasks into "run now" (username known) and "lookup needed".
16
+ * - Runs a batch lookup for missing usernames.
17
+ * - Runs all update tasks *after* lookups are complete.
13
18
  */
14
19
 
15
20
  const { handleDiscover } = require('./helpers/discover_helpers');
16
21
  const { handleVerify } = require('./helpers/verify_helpers');
17
- const { handleUpdate } = require('./helpers/update_helpers');
22
+ // --- MODIFIED: Import both helpers from update_helpers ---
23
+ const { handleUpdate, lookupUsernames } = require('./helpers/update_helpers');
18
24
 
19
25
  /**
20
26
  * Main pipe: pipe.taskEngine.handleRequest
@@ -25,7 +31,6 @@ const { handleUpdate } = require('./helpers/update_helpers');
25
31
  * @param {object} dependencies - Contains all clients: db, pubsub, logger, headerManager, proxyManager, batchManager.
26
32
  */
27
33
  async function handleRequest(message, context, config, dependencies) {
28
- // --- MODIFICATION: Destructure batchManager and headerManager ---
29
34
  const { logger, batchManager, headerManager } = dependencies;
30
35
 
31
36
  if (!message || !message.data) {
@@ -41,9 +46,6 @@ async function handleRequest(message, context, config, dependencies) {
41
46
  return;
42
47
  }
43
48
 
44
- // --- START MODIFICATION: Handle Batch Payload ---
45
-
46
- // Check if this is a batched task payload
47
49
  if (!payload.tasks || !Array.isArray(payload.tasks)) {
48
50
  logger.log('ERROR', `[TaskEngine] Received invalid payload. Expected { tasks: [...] }`, { payload });
49
51
  return;
@@ -58,48 +60,100 @@ async function handleRequest(message, context, config, dependencies) {
58
60
  logger.log('INFO', `[TaskEngine/${taskId}] Received batch of ${payload.tasks.length} tasks.`);
59
61
 
60
62
  try {
61
- // Loop through all tasks in this single function instance
63
+ // --- START V2 MODIFICATION ---
64
+
65
+ // 1. Load the username map into the batch manager's memory
66
+ await batchManager.loadUsernameMap();
67
+
68
+ // 2. Sort tasks
69
+ const tasksToRun = []; // { task, username }
70
+ const cidsToLookup = new Map(); // Map<string, object>
71
+ const otherTasks = []; // for 'discover' and 'verify'
72
+
62
73
  for (const task of payload.tasks) {
63
-
64
- // Generate a sub-task ID for logging
65
- const subTaskId = `${task.type || 'unknown'}-${task.userType || 'unknown'}-${task.userId || task.cids?.[0] || 'sub'}`;
74
+ if (task.type === 'update') {
75
+ const username = batchManager.getUsername(task.userId);
76
+ if (username) {
77
+ tasksToRun.push({ task, username });
78
+ } else {
79
+ cidsToLookup.set(String(task.userId), task);
80
+ }
81
+ } else if (task.type === 'discover' || task.type === 'verify') {
82
+ otherTasks.push(task);
83
+ }
84
+ }
85
+
86
+ logger.log('INFO', `[TaskEngine/${taskId}] Usernames known for ${tasksToRun.length} users. Looking up ${cidsToLookup.size} users. Processing ${otherTasks.length} other tasks.`);
66
87
 
88
+ // 3. Run username lookups if needed
89
+ if (cidsToLookup.size > 0) {
90
+ const foundUsers = await lookupUsernames(Array.from(cidsToLookup.keys()), dependencies, config);
91
+
92
+ for (const user of foundUsers) {
93
+ const cidStr = String(user.CID);
94
+ const username = user.Value.UserName;
95
+
96
+ // Add to batch manager to save this new mapping
97
+ batchManager.addUsernameMapUpdate(cidStr, username);
98
+
99
+ // Get the original task
100
+ const task = cidsToLookup.get(cidStr);
101
+ if (task) {
102
+ // Add to the list of tasks to run
103
+ tasksToRun.push({ task, username });
104
+ // Remove from lookup map
105
+ cidsToLookup.delete(cidStr);
106
+ }
107
+ }
108
+
109
+ if (cidsToLookup.size > 0) {
110
+ logger.log('WARN', `[TaskEngine/${taskId}] Could not find usernames for ${cidsToLookup.size} CIDs (likely private). They will be skipped.`, { skippedCids: Array.from(cidsToLookup.keys()) });
111
+ }
112
+ }
113
+
114
+ // 4. Process all tasks
115
+
116
+ // Process discover/verify tasks
117
+ for (const task of otherTasks) {
118
+ const subTaskId = `${task.type || 'unknown'}-${task.userType || 'unknown'}-${task.userId || task.cids?.[0] || 'sub'}`;
67
119
  const handlerFunction = {
68
120
  discover: handleDiscover,
69
121
  verify: handleVerify,
70
- update: handleUpdate
71
122
  }[task.type];
72
123
 
73
124
  if (handlerFunction) {
74
- // We await each one sequentially to manage API load,
75
- // but they all use the *same* batchManager instance.
76
125
  await handlerFunction(task, subTaskId, dependencies, config);
77
126
  } else {
78
- logger.log('ERROR', `[TaskEngine/${taskId}] Unknown task type in batch: ${task.type}`);
127
+ logger.log('ERROR', `[TaskEngine/${taskId}] Unknown task type in 'otherTasks': ${task.type}`);
128
+ }
129
+ }
130
+
131
+ // Process update tasks (now with usernames)
132
+ for (const { task, username } of tasksToRun) {
133
+ const subTaskId = `${task.type || 'unknown'}-${task.userType || 'unknown'}-${task.userId || 'sub'}`;
134
+ try {
135
+ // Pass the username to handleUpdate
136
+ await handleUpdate(task, subTaskId, dependencies, config, username);
137
+ } catch (updateError) {
138
+ logger.log('ERROR', `[TaskEngine/${taskId}] Error in handleUpdate for user ${task.userId}`, { errorMessage: updateError.message, errorStack: updateError.stack });
79
139
  }
80
140
  }
81
141
 
82
- logger.log('SUCCESS', `[TaskEngine/${taskId}] Processed ${payload.tasks.length} tasks. Done.`);
142
+ logger.log('SUCCESS', `[TaskEngine/${taskId}] Processed all tasks. Done.`);
143
+ // --- END V2 MODIFICATION ---
83
144
 
84
145
  } catch (error) {
85
146
  logger.log('ERROR', `[TaskEngine/${taskId}] Failed during batch processing.`, { errorMessage: error.message, errorStack: error.stack });
86
147
 
87
148
  } finally {
88
149
  try {
89
- // --- MODIFICATION ---
90
- // This is the most critical change.
91
- // After the loop (or if an error occurs), explicitly flush
92
- // all accumulated writes (portfolios, timestamps, etc.)
93
- // from the batchManager.
94
150
  logger.log('INFO', `[TaskEngine/${taskId}] Flushing all accumulated batches...`);
95
151
  await batchManager.flushBatches();
96
152
  logger.log('INFO', `[TaskEngine/${taskId}] Final batch and header flush complete.`);
97
- // --- END MODIFICATION ---
98
153
  } catch (flushError) {
99
154
  logger.log('ERROR', `[TaskEngine/${taskId}] Error during final flush attempt.`, { error: flushError.message });
100
155
  }
101
156
  }
102
- // --- END MODIFICATION ---
103
157
  }
104
158
 
105
159
  module.exports = {
@@ -1,6 +1,12 @@
1
+ /*
2
+ * FILENAME: CloudFunctions/NpmWrappers/bulltrackers-module/functions/task-engine/helpers/discover_helpers.js
3
+ * (FIXED: Publishes 'verify' task in the correct batch format { tasks: [...] })
4
+ */
5
+
1
6
  /**
2
7
  * @fileoverview Sub-pipe: pipe.taskEngine.handleDiscover
3
8
  * REFACTORED: Now stateless and receives dependencies.
9
+ * --- MODIFIED: Passes username to the 'verify' task. ---
4
10
  */
5
11
 
6
12
  /**
@@ -103,17 +109,27 @@ async function handleDiscover(task, taskId, dependencies, config) {
103
109
  }
104
110
 
105
111
  if (finalActiveUsers.length > 0) {
112
+ // --- START MODIFICATION ---
113
+ // Include the UserName in the task payload for 'verify'
106
114
  const verificationTask = {
107
115
  type: 'verify',
108
- users: finalActiveUsers.map(u => ({ cid: u.CID, isBronze: u.Value.IsBronze })),
116
+ users: finalActiveUsers.map(u => ({
117
+ cid: u.CID,
118
+ isBronze: u.Value.IsBronze,
119
+ username: u.Value.UserName // <-- ADD THIS
120
+ })),
109
121
  blockId,
110
122
  instrument,
111
123
  userType
112
124
  };
125
+ // --- END MODIFICATION ---
126
+
127
+ // --- FIX: WRAP IN BATCH FORMAT ---
113
128
  // Use pubsub from dependencies
114
129
  await pubsub.topic(config.PUBSUB_TOPIC_USER_FETCH)
115
- .publishMessage({ json: verificationTask });
116
- logger.log('INFO', `[DISCOVER] Verification message published was : ${JSON.stringify(verificationTask)} `);
130
+ .publishMessage({ json: { tasks: [verificationTask] } });
131
+ logger.log('INFO', `[DISCOVER] Verification message published was : ${JSON.stringify({ tasks: [verificationTask] })} `);
132
+ // --- END FIX ---
117
133
  logger.log('INFO', `[DISCOVER] Chaining to 'verify' task for ${finalActiveUsers.length} active users.`);
118
134
  }
119
135
 
@@ -122,4 +138,4 @@ async function handleDiscover(task, taskId, dependencies, config) {
122
138
  }
123
139
  }
124
140
 
125
- module.exports = { handleDiscover };
141
+ module.exports = { handleDiscover };
@@ -1,50 +1,192 @@
1
+ /*
2
+ * FILENAME: CloudFunctions/NpmWrappers/bulltrackers-module/functions/task-engine/helpers/update_helpers.js
3
+ * (FIXED: Enhanced error logging for portfolio and history fetches)
4
+ */
5
+
1
6
  /**
2
7
  * @fileoverview Sub-pipe: pipe.taskEngine.handleUpdate
3
8
  * REFACTORED: Now stateless and receives dependencies.
4
9
  * OPTIMIZED: Removed immediate batch commit for speculator timestamp fix.
10
+ * --- MODIFIED: Fetches portfolio AND trade history in parallel. ---
11
+ * --- MODIFIED: Includes helper to look up usernames from CIDs. ---
5
12
  */
6
13
  const { FieldValue } = require('@google-cloud/firestore');
7
14
 
15
+ /**
16
+ * --- NEW HELPER ---
17
+ * Fetches user details (including username) from the rankings API for a batch of CIDs.
18
+ * @param {Array<string>} cids - An array of user CIDs (as strings or numbers).
19
+ * @param {object} dependencies - Contains proxyManager, headerManager, logger.
20
+ * @param {object} config - The configuration object.
21
+ * @returns {Promise<Array<object>>} An array of the raw user objects from the API.
22
+ */
23
+ async function lookupUsernames(cids, dependencies, config) {
24
+ const { logger, headerManager, proxyManager } = dependencies;
25
+ const { USERNAME_LOOKUP_BATCH_SIZE, ETORO_API_RANKINGS_URL } = config;
26
+
27
+ if (!cids || cids.length === 0) {
28
+ return [];
29
+ }
30
+
31
+ logger.log('INFO', `[lookupUsernames] Looking up usernames for ${cids.length} CIDs.`);
32
+ const allPublicUsers = [];
33
+
34
+ for (let i = 0; i < cids.length; i += USERNAME_LOOKUP_BATCH_SIZE) {
35
+ const batchCids = cids.slice(i, i + USERNAME_LOOKUP_BATCH_SIZE).map(Number);
36
+
37
+ const url = `${ETORO_API_RANKINGS_URL}?Period=LastTwoYears`;
38
+ const selectedHeader = await headerManager.selectHeader();
39
+ if (!selectedHeader) {
40
+ logger.log('ERROR', '[lookupUsernames] Could not select a header.');
41
+ continue; // Skip this batch
42
+ }
43
+
44
+ let wasSuccess = false;
45
+ try {
46
+ const response = await proxyManager.fetch(url, {
47
+ method: 'POST',
48
+ headers: { ...selectedHeader.header, 'Content-Type': 'application/json' },
49
+ body: JSON.stringify(batchCids),
50
+ });
51
+
52
+ if (!response.ok) {
53
+ throw new Error(`[lookupUsernames] API status ${response.status}`);
54
+ }
55
+ wasSuccess = true;
56
+
57
+ const publicUsers = await response.json();
58
+ if (Array.isArray(publicUsers)) {
59
+ allPublicUsers.push(...publicUsers);
60
+ }
61
+ } catch (error) {
62
+ logger.log('WARN', `[lookupUsernames] Failed to fetch batch of usernames.`, { errorMessage: error.message });
63
+ } finally {
64
+ if (selectedHeader) {
65
+ headerManager.updatePerformance(selectedHeader.id, wasSuccess);
66
+ }
67
+ }
68
+ }
69
+
70
+ logger.log('INFO', `[lookupUsernames] Found ${allPublicUsers.length} public users out of ${cids.length}.`);
71
+ return allPublicUsers;
72
+ }
73
+
74
+
8
75
  /**
9
76
  * Sub-pipe: pipe.taskEngine.handleUpdate
10
77
  * @param {object} task The Pub/Sub task payload.
11
78
  * @param {string} taskId A unique ID for logging.
12
79
  * @param {object} dependencies - Contains db, pubsub, logger, headerManager, proxyManager, batchManager.
13
80
  * @param {object} config The configuration object.
81
+ * @param {string} username - The user's eToro username.
14
82
  */
15
- async function handleUpdate(task, taskId, dependencies, config) {
83
+ async function handleUpdate(task, taskId, dependencies, config, username) {
16
84
  const { logger, headerManager, proxyManager, db, batchManager } = dependencies;
17
85
  const { userId, instrumentId, userType } = task;
86
+
87
+ // --- START MODIFICATION: Define both URLs ---
88
+ const portfolioUrl = userType === 'speculator'
89
+ ? `${config.ETORO_API_POSITIONS_URL}?cid=${userId}&InstrumentID=${instrumentId}`
90
+ : `${config.ETORO_API_PORTFOLIO_URL}?cid=${userId}`;
91
+
92
+ const historyUrl = `${config.ETORO_API_USERSTATS_URL}${username}/trades/oneYearAgo?CopyAsAsset=true`;
18
93
 
19
- const selectedHeader = await headerManager.selectHeader();
20
- if (!selectedHeader) throw new Error("Could not select a header.");
94
+ const today = new Date().toISOString().slice(0, 10);
95
+ const portfolioStorageBlockId = `${Math.floor(parseInt(userId) / 1000000)}M`;
96
+
97
+ let portfolioHeader = null;
98
+ let historyHeader = null;
99
+ let wasPortfolioSuccess = false;
100
+ let wasHistorySuccess = false;
101
+ let isPrivate = false;
21
102
 
22
- let wasSuccess = false;
23
103
  try {
24
- const url = userType === 'speculator'
25
- ? `${config.ETORO_API_POSITIONS_URL}?cid=${userId}&InstrumentID=${instrumentId}`
26
- : `${config.ETORO_API_PORTFOLIO_URL}?cid=${userId}`;
27
-
28
- logger.log('INFO', `[UPDATE] Fetching portfolio for user ${userId} (${userType} with url ${url})`);
29
-
30
- // Use proxyManager from dependencies
31
- const response = await proxyManager.fetch(url, { headers: selectedHeader.header });
104
+ logger.log('INFO', `[UPDATE] Fetching data for user ${userId} (${username}, ${userType})`);
32
105
 
33
- if (!response || typeof response.text !== 'function') {
34
- logger.log('ERROR', `[UPDATE] Invalid or incomplete response received for user ${userId}`, { response });
35
- throw new Error(`Invalid response structure received from proxy for user ${userId}.`);
106
+ // Select headers for both requests
107
+ portfolioHeader = await headerManager.selectHeader();
108
+ historyHeader = await headerManager.selectHeader();
109
+ if (!portfolioHeader || !historyHeader) {
110
+ throw new Error("Could not select headers for update requests.");
36
111
  }
37
112
 
38
- const responseBody = await response.text();
113
+ // --- Fetch both endpoints in parallel ---
114
+ const [portfolioResult, historyResult] = await Promise.allSettled([
115
+ proxyManager.fetch(portfolioUrl, { headers: portfolioHeader.header }),
116
+ proxyManager.fetch(historyUrl, { headers: historyHeader.header })
117
+ ]);
39
118
 
40
- if (responseBody.includes("user is PRIVATE")) {
41
- logger.log('WARN', `User ${userId} is private. Removing from future updates and decrementing block count.`);
119
+ // --- Process Portfolio Result ---
120
+ if (portfolioResult.status === 'fulfilled' && portfolioResult.value.ok) {
121
+ const responseBody = await portfolioResult.value.text();
122
+
123
+ // Check for private user
124
+ if (responseBody.includes("user is PRIVATE")) {
125
+ isPrivate = true;
126
+ } else {
127
+ wasPortfolioSuccess = true;
128
+ const portfolioData = JSON.parse(responseBody);
129
+ // Add to portfolio batch
130
+ console.log('DEBUG', `[UPDATE] SUCCESS Adding portfolio data for user ${userId} to batch.`);
131
+ await batchManager.addToPortfolioBatch(userId, portfolioStorageBlockId, today, portfolioData, userType, instrumentId);
132
+
133
+ }
134
+ } else {
135
+ // --- FIX: ENHANCED LOGGING ---
136
+ let errorMsg = 'Unknown fetch error';
137
+ let rawErrorText = 'N/A';
138
+
139
+ if (portfolioResult.status === 'rejected') {
140
+ errorMsg = portfolioResult.reason.message;
141
+ } else if (portfolioResult.value) {
142
+ errorMsg = portfolioResult.value.error?.message || `API status ${portfolioResult.value.status}`;
143
+ // Get the raw text from the proxy manager's response
144
+ if (typeof portfolioResult.value.text === 'function') {
145
+ rawErrorText = await portfolioResult.value.text();
146
+ }
147
+ }
42
148
 
43
- // Use batchManager from dependencies
149
+ logger.log('WARN', `[UPDATE] Failed to fetch PORTFOLIO for user ${userId}.`, {
150
+ error: errorMsg,
151
+ proxyResponse: rawErrorText // This will contain "urlfetch" or other quota messages
152
+ });
153
+ // --- END FIX ---
154
+ }
155
+
156
+ // --- Process History Result ---
157
+ if (historyResult.status === 'fulfilled' && historyResult.value.ok) {
158
+ const historyData = await historyResult.value.json();
159
+ wasHistorySuccess = true;
160
+ // Add to history batch
161
+ console.log('DEBUG', `[UPDATE] SUCCESS Adding history data for user ${userId} to batch.`);
162
+ await batchManager.addToTradingHistoryBatch(userId, portfolioStorageBlockId, today, historyData, userType);
163
+ } else {
164
+ // --- FIX: ENHANCED LOGGING ---
165
+ let errorMsg = 'Unknown fetch error';
166
+ let rawErrorText = 'N/A';
167
+
168
+ if (historyResult.status === 'rejected') {
169
+ errorMsg = historyResult.reason.message;
170
+ } else if (historyResult.value) {
171
+ errorMsg = historyResult.value.error?.message || `API status ${historyResult.value.status}`;
172
+ // Get the raw text from the proxy manager's response
173
+ if (typeof historyResult.value.text === 'function') {
174
+ rawErrorText = await historyResult.value.text();
175
+ }
176
+ }
177
+
178
+ logger.log('WARN', `[UPDATE] Failed to fetch HISTORY for user ${userId} (${username}).`, {
179
+ error: errorMsg,
180
+ proxyResponse: rawErrorText // This will contain "urlfetch" or other quota messages
181
+ });
182
+ // --- END FIX ---
183
+ }
184
+
185
+ // --- Handle Private User (if detected) ---
186
+ if (isPrivate) {
187
+ logger.log('WARN', `User ${userId} is private. Removing from future updates and decrementing block count.`);
44
188
  batchManager.deleteFromTimestampBatch(userId, userType, instrumentId);
45
189
 
46
- // <<< START FIX for private user blockId format >>>
47
- // The blockId format for Speculator Blocks is numeric (e.g., "1000000")
48
190
  let blockId;
49
191
  let incrementField;
50
192
 
@@ -52,68 +194,42 @@ async function handleUpdate(task, taskId, dependencies, config) {
52
194
  blockId = String(Math.floor(parseInt(userId) / 1000000) * 1000000);
53
195
  incrementField = `counts.${instrumentId}_${blockId}`;
54
196
  } else {
55
-
56
- // <<< START FIX for Normal User Block ID >>>
57
- // BUG: This was calculating '19M', which is wrong for the counter.
58
- // blockId = `${Math.floor(parseInt(userId) / 1000000)}M`;
59
-
60
- // FIX: Use the numeric '19000000' format, just like the orchestrator.
61
197
  blockId = String(Math.floor(parseInt(userId) / 1000000) * 1000000);
62
- // <<< END FIX for Normal User Block ID >>>
63
-
64
198
  incrementField = `counts.${blockId}`;
65
199
  }
66
200
 
67
- // Use db from dependencies
68
201
  const blockCountsRef = db.doc(userType === 'speculator'
69
202
  ? config.FIRESTORE_DOC_SPECULATOR_BLOCK_COUNTS
70
203
  : config.FIRESTORE_DOC_BLOCK_COUNTS);
71
- // <<< END FIX for private user blockId format >>>
72
204
 
73
205
  // This is a single, immediate write, which is fine for this rare case.
206
+ // We use .set here because batchManager.flushBatches() might not run if this is the only user.
207
+ // To be safer, this should also be added to a batch, but this is an edge case.
74
208
  await blockCountsRef.set({ [incrementField]: FieldValue.increment(-1) }, { merge: true });
75
-
76
209
  return;
77
210
  }
78
-
79
- if (!response.ok) {
80
- throw new Error(`API Error: Status ${response.status}`);
81
- }
82
- wasSuccess = true;
83
- const portfolioData = JSON.parse(responseBody);
84
-
85
- const today = new Date().toISOString().slice(0, 10);
86
-
87
- // <<< START OPTIMIZATION / FULL CODE FIX >>>
88
-
89
- // BUG 1: The blockId format is different for portfolio storage ('1M')
90
- // vs. orchestrator logic ('1000000').
91
- const portfolioStorageBlockId = `${Math.floor(parseInt(userId) / 1000000)}M`;
92
-
93
- // This call is correct for storing portfolio data (which uses the 'M' format)
94
- await batchManager.addToPortfolioBatch(userId, portfolioStorageBlockId, today, portfolioData, userType, instrumentId);
95
-
96
- // This call is correct for 'normal' users.
97
- await batchManager.updateUserTimestamp(userId, userType, instrumentId);
98
211
 
99
- // BUG 2 (The Main Problem): We must *also* update the 'SpeculatorBlocks'
100
- // collection, which is what the orchestrator *does* read.
101
- if (userType === 'speculator') {
102
- const orchestratorBlockId = String(Math.floor(parseInt(userId) / 1000000) * 1000000);
212
+ // --- If either fetch was successful, update timestamps ---
213
+ if (wasPortfolioSuccess || wasHistorySuccess) {
214
+ // This call is correct for 'normal' users.
215
+ await batchManager.updateUserTimestamp(userId, userType, instrumentId);
103
216
 
104
- logger.log('INFO', `[UPDATE] Applying speculator timestamp fix for user ${userId} in block ${orchestratorBlockId}`);
105
-
106
- // --- OPTIMIZATION: Use the batch manager instead of committing immediately ---
107
- // This queues the write and allows the function to return,
108
- // instead of blocking on `await fixBatch.commit()`.
109
- await batchManager.addSpeculatorTimestampFix(userId, orchestratorBlockId);
110
- logger.log('INFO', `[UPDATE] Speculator timestamp fix for user ${userId} queued.`);
217
+ // This is the fix for speculator timestamps
218
+ if (userType === 'speculator') {
219
+ const orchestratorBlockId = String(Math.floor(parseInt(userId) / 1000000) * 1000000);
220
+ logger.log('INFO', `[UPDATE] Applying speculator timestamp fix for user ${userId} in block ${orchestratorBlockId}`);
221
+ await batchManager.addSpeculatorTimestampFix(userId, orchestratorBlockId);
222
+ }
111
223
  }
112
- // <<< END OPTIMIZATION / FULL CODE FIX >>>
113
-
224
+
114
225
  } finally {
115
- if (selectedHeader) headerManager.updatePerformance(selectedHeader.id, wasSuccess);
226
+ // Update performance for both headers used
227
+ if (portfolioHeader) headerManager.updatePerformance(portfolioHeader.id, wasPortfolioSuccess);
228
+ if (historyHeader) headerManager.updatePerformance(historyHeader.id, wasHistorySuccess);
116
229
  }
117
230
  }
118
231
 
119
- module.exports = { handleUpdate };
232
+ module.exports = {
233
+ handleUpdate,
234
+ lookupUsernames // <-- EXPORT NEW HELPER
235
+ };
@@ -2,13 +2,13 @@
2
2
  * @fileoverview Sub-pipe: pipe.taskEngine.handleVerify
3
3
  * REFACTORED: Now stateless and receives dependencies.
4
4
  * OPTIMIZED: Fetches all user portfolios in parallel to reduce function runtime.
5
+ * --- MODIFIED: Saves username to cid_username_map collection. ---
5
6
  */
6
7
  const { FieldValue } = require('@google-cloud/firestore');
7
8
 
8
9
  /**
9
10
  * Internal helper to fetch and process a single user's portfolio.
10
- * This allows the main function to run these in parallel.
11
- * @param {object} user - The user object { cid, isBronze }
11
+ * @param {object} user - The user object { cid, isBronze, username }
12
12
  * @param {object} dependencies - Contains proxyManager, headerManager
13
13
  * @param {object} config - The configuration object
14
14
  * @param {Set<number>} speculatorInstrumentSet - Pre-built Set of instrument IDs
@@ -46,6 +46,7 @@ async function fetchAndVerifyUser(user, dependencies, config, speculatorInstrume
46
46
  type: 'speculator',
47
47
  userId: userId,
48
48
  isBronze: user.isBronze,
49
+ username: user.username, // <-- PASS USERNAME
49
50
  updateData: {
50
51
  instruments: matchingInstruments,
51
52
  lastVerified: new Date(),
@@ -62,6 +63,7 @@ async function fetchAndVerifyUser(user, dependencies, config, speculatorInstrume
62
63
  type: 'normal',
63
64
  userId: userId,
64
65
  isBronze: user.isBronze,
66
+ username: user.username, // <-- PASS USERNAME
65
67
  updateData: {
66
68
  lastVerified: new Date()
67
69
  }
@@ -93,6 +95,10 @@ async function handleVerify(task, taskId, dependencies, config) {
93
95
  const speculatorUpdates = {};
94
96
  const normalUserUpdates = {};
95
97
  const bronzeStateUpdates = {}; // This can be one object
98
+
99
+ // --- NEW: Object to store username updates ---
100
+ const usernameMapUpdates = {};
101
+ // --- END NEW ---
96
102
 
97
103
  const speculatorInstrumentSet = new Set(config.SPECULATOR_INSTRUMENTS_ARRAY);
98
104
 
@@ -118,6 +124,13 @@ async function handleVerify(task, taskId, dependencies, config) {
118
124
 
119
125
  const data = result.value;
120
126
 
127
+ // --- NEW: Capture username ---
128
+ if (data.username) {
129
+ // We use the CID as the key for the username map
130
+ usernameMapUpdates[data.userId] = { username: data.username };
131
+ }
132
+ // --- END NEW ---
133
+
121
134
  if (data.type === 'speculator') {
122
135
  speculatorUpdates[`users.${data.userId}`] = data.updateData;
123
136
  bronzeStateUpdates[data.userId] = data.isBronze;
@@ -154,6 +167,18 @@ async function handleVerify(task, taskId, dependencies, config) {
154
167
  }
155
168
  }
156
169
 
170
+ // --- NEW: Add username map updates to the batch ---
171
+ // We will use a simple sharding strategy (e.g., by first letter of CID)
172
+ // For simplicity here, we'll write to one document.
173
+ // A better production strategy would shard.
174
+ if (Object.keys(usernameMapUpdates).length > 0) {
175
+ // Simple, non-scalable approach:
176
+ const mapDocRef = db.collection(config.FIRESTORE_COLLECTION_USERNAME_MAP).doc('cid_map_shard_1');
177
+ batch.set(mapDocRef, usernameMapUpdates, { merge: true });
178
+ logger.log('INFO', `[VERIFY] Staging ${Object.keys(usernameMapUpdates).length} username updates.`);
179
+ }
180
+ // --- END NEW ---
181
+
157
182
  await batch.commit();
158
183
  if(validUserCount > 0) {
159
184
  logger.log('INFO', `[VERIFY] Verified and stored ${validUserCount} new ${userType} users.`);
@@ -2,6 +2,7 @@
2
2
  * @fileoverview Utility class to manage stateful Firestore write batches.
3
3
  * REFACTORED: Renamed 'firestore' to 'db' for consistency.
4
4
  * OPTIMIZED: Added logic to handle speculator timestamp fixes within the batch.
5
+ * --- MODIFIED: Added username map caching and trading history batching. ---
5
6
  */
6
7
 
7
8
  const { FieldValue } = require('@google-cloud/firestore');
@@ -23,7 +24,18 @@ class FirestoreBatchManager {
23
24
  this.portfolioBatch = {};
24
25
  this.timestampBatch = {};
25
26
  this.processedSpeculatorCids = new Set();
26
- this.speculatorTimestampFixBatch = {}; // <-- OPTIMIZATION: ADD THIS
27
+ this.speculatorTimestampFixBatch = {};
28
+
29
+ // --- NEW STATE ---
30
+ this.tradingHistoryBatch = {}; // For the new trade history data
31
+ this.usernameMap = new Map(); // In-memory cache: Map<cid, username>
32
+ this.usernameMapUpdates = {}; // Batched updates for Firestore: { [cid]: { username } }
33
+ this.usernameMapLastLoaded = 0;
34
+ this.usernameMapCollectionName = config.FIRESTORE_COLLECTION_USERNAME_MAP;
35
+ this.normalHistoryCollectionName = config.FIRESTORE_COLLECTION_NORMAL_HISTORY;
36
+ this.speculatorHistoryCollectionName = config.FIRESTORE_COLLECTION_SPECULATOR_HISTORY;
37
+ // --- END NEW STATE ---
38
+
27
39
  this.batchTimeout = null;
28
40
 
29
41
  this.logger.log('INFO', 'FirestoreBatchManager initialized.');
@@ -41,6 +53,82 @@ class FirestoreBatchManager {
41
53
  }
42
54
  }
43
55
 
56
+ // --- NEW: Load the username map into memory ---
57
+ async loadUsernameMap() {
58
+ // Cache map for 1 hour to reduce reads
59
+ if (Date.now() - this.usernameMapLastLoaded < 3600000) {
60
+ return;
61
+ }
62
+
63
+ this.logger.log('INFO', '[BATCH] Refreshing username map from Firestore...');
64
+ this.usernameMap.clear();
65
+
66
+ try {
67
+ const snapshot = await this.db.collection(this.usernameMapCollectionName).get();
68
+ if (snapshot.empty) {
69
+ this.logger.log('WARN', '[BATCH] Username map collection is empty.');
70
+ } else {
71
+ snapshot.forEach(doc => {
72
+ const data = doc.data();
73
+ // Assumes doc.id is CID, or data is { [cid]: { username } }
74
+ for (const cid in data) {
75
+ if (data[cid] && data[cid].username) {
76
+ this.usernameMap.set(String(cid), data[cid].username);
77
+ }
78
+ }
79
+ });
80
+ }
81
+ this.usernameMapLastLoaded = Date.now();
82
+ this.logger.log('INFO', `[BATCH] Loaded ${this.usernameMap.size} usernames into memory cache.`);
83
+ } catch (error) {
84
+ this.logger.log('ERROR', '[BATCH] Failed to load username map.', { errorMessage: error.message });
85
+ }
86
+ }
87
+
88
+ // --- NEW: Get username from in-memory cache ---
89
+ getUsername(cid) {
90
+ return this.usernameMap.get(String(cid));
91
+ }
92
+
93
+ // --- NEW: Add a username to the cache and the write batch ---
94
+ addUsernameMapUpdate(cid, username) {
95
+ const cidStr = String(cid);
96
+ if (!username) return;
97
+
98
+ // Update in-memory cache immediately
99
+ this.usernameMap.set(cidStr, username);
100
+
101
+ // Add to Firestore write batch
102
+ // We'll write this to a single-field doc for simple merging
103
+ this.usernameMapUpdates[cidStr] = { username: username };
104
+ this.logger.log('TRACE', `[BATCH] Queued username update for ${cidStr}.`);
105
+ this._scheduleFlush();
106
+ }
107
+
108
+ // --- NEW: Add trading history to its own batch ---
109
+ async addToTradingHistoryBatch(userId, blockId, date, historyData, userType) {
110
+ const collection = userType === 'speculator'
111
+ ? this.speculatorHistoryCollectionName
112
+ : this.normalHistoryCollectionName;
113
+
114
+ const basePath = `${collection}/${blockId}/snapshots/${date}`;
115
+
116
+ if (!this.tradingHistoryBatch[basePath]) {
117
+ this.tradingHistoryBatch[basePath] = {};
118
+ }
119
+
120
+ this.tradingHistoryBatch[basePath][userId] = historyData;
121
+
122
+ // Trigger flush based on portfolio batch size, assuming they'll be roughly equal
123
+ const totalUsersInBatch = Object.values(this.portfolioBatch).reduce((sum, users) => sum + Object.keys(users).length, 0);
124
+
125
+ if (totalUsersInBatch >= this.config.TASK_ENGINE_MAX_BATCH_SIZE) {
126
+ await this.flushBatches();
127
+ } else {
128
+ this._scheduleFlush();
129
+ }
130
+ }
131
+
44
132
  /**
45
133
  * Adds a portfolio to the batch.
46
134
  * @param {string} userId
@@ -148,41 +236,56 @@ class FirestoreBatchManager {
148
236
  // --- END NEW METHOD ---
149
237
 
150
238
  /**
151
- * Flushes all pending writes to Firestore and updates header performance.
239
+ * Helper to flush a specific batch type (portfolio or history).
240
+ * @param {object} batchData - The batch object (e.g., this.portfolioBatch)
241
+ * @param {Firestore.Batch} firestoreBatch - The main Firestore batch object
242
+ * @param {string} logName - A name for logging (e.g., "Portfolio")
243
+ * @returns {number} The number of batch operations added.
152
244
  */
153
- async flushBatches() {
154
- if (this.batchTimeout) {
155
- clearTimeout(this.batchTimeout);
156
- this.batchTimeout = null;
157
- }
158
-
159
- const promises = [];
160
- const firestoreBatch = this.db.batch(); // Use this.db
161
- let batchOperationCount = 0;
162
-
163
- for (const basePath in this.portfolioBatch) {
164
- const userPortfolios = this.portfolioBatch[basePath];
245
+ _flushDataBatch(batchData, firestoreBatch, logName) {
246
+ let operationCount = 0;
247
+ for (const basePath in batchData) {
248
+ const userPortfolios = batchData[basePath];
165
249
  const userIds = Object.keys(userPortfolios);
166
250
 
167
251
  if (userIds.length > 0) {
168
252
  for (let i = 0; i < userIds.length; i += this.config.TASK_ENGINE_MAX_USERS_PER_SHARD) {
169
253
  const chunkUserIds = userIds.slice(i, i + this.config.TASK_ENGINE_MAX_USERS_PER_SHARD);
170
- const shardIndex = Math.floor(i / this.config.TASK_ENGINE_MAX_USERS_PER_SHARD);
171
-
254
+
172
255
  const chunkData = {};
173
256
  chunkUserIds.forEach(userId => {
174
257
  chunkData[userId] = userPortfolios[userId];
175
258
  });
176
259
 
177
- const docRef = this.db.collection(`${basePath}/parts`).doc(); // Now uses a reandomised ID from firestore to solve an overwrite issue with contention
178
- firestoreBatch.set(docRef, chunkData); // No merge: true needed, it's a new doc
179
- batchOperationCount++;
260
+ const docRef = this.db.collection(`${basePath}/parts`).doc(); // Use random ID
261
+ firestoreBatch.set(docRef, chunkData);
262
+ operationCount++;
180
263
  }
181
- // (Optional) Update the log message to reflect the path used for the data write
182
- this.logger.log('INFO', `[BATCH] Staged ${userIds.length} users into ${Math.ceil(userIds.length / this.config.TASK_ENGINE_MAX_USERS_PER_SHARD)} unique shard documents for ${basePath}.`);
264
+ this.logger.log('INFO', `[BATCH] Staged ${userIds.length} users' ${logName} data into ${Math.ceil(userIds.length / this.config.TASK_ENGINE_MAX_USERS_PER_SHARD)} shards for ${basePath}.`);
183
265
  }
184
- delete this.portfolioBatch[basePath];
266
+ delete batchData[basePath];
185
267
  }
268
+ return operationCount;
269
+ }
270
+
271
+
272
+ /**
273
+ * Flushes all pending writes to Firestore and updates header performance.
274
+ */
275
+ async flushBatches() {
276
+ if (this.batchTimeout) {
277
+ clearTimeout(this.batchTimeout);
278
+ this.batchTimeout = null;
279
+ }
280
+
281
+ const promises = [];
282
+ const firestoreBatch = this.db.batch(); // Use this.db
283
+ let batchOperationCount = 0;
284
+
285
+ // --- REFACTOR: Use helper for both batches ---
286
+ batchOperationCount += this._flushDataBatch(this.portfolioBatch, firestoreBatch, "Portfolio");
287
+ batchOperationCount += this._flushDataBatch(this.tradingHistoryBatch, firestoreBatch, "Trade History");
288
+ // --- END REFACTOR ---
186
289
 
187
290
  for (const docPath in this.timestampBatch) {
188
291
  if (Object.keys(this.timestampBatch[docPath]).length > 0) {
@@ -243,7 +346,18 @@ class FirestoreBatchManager {
243
346
  }
244
347
  delete this.speculatorTimestampFixBatch[docPath];
245
348
  }
246
- // --- END OPTIMIZATION ---
349
+
350
+ // --- NEW: Flush Username Map Updates ---
351
+ if (Object.keys(this.usernameMapUpdates).length > 0) {
352
+ this.logger.log('INFO', `[BATCH] Flushing ${Object.keys(this.usernameMapUpdates).length} username map updates.`);
353
+ // Simple sharding: just merge into one doc for now.
354
+ // A more robust solution would shard based on CID.
355
+ const mapDocRef = this.db.collection(this.usernameMapCollectionName).doc('cid_map_shard_1');
356
+ firestoreBatch.set(mapDocRef, this.usernameMapUpdates, { merge: true });
357
+ batchOperationCount++;
358
+ this.usernameMapUpdates = {}; // Clear the batch
359
+ }
360
+ // --- END NEW ---
247
361
 
248
362
  if (batchOperationCount > 0) {
249
363
  promises.push(firestoreBatch.commit());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.122",
3
+ "version": "1.0.124",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [