bulltrackers-module 1.0.122 → 1.0.123

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.
@@ -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,7 @@
1
1
  /**
2
2
  * @fileoverview Sub-pipe: pipe.taskEngine.handleDiscover
3
3
  * REFACTORED: Now stateless and receives dependencies.
4
+ * --- MODIFIED: Passes username to the 'verify' task. ---
4
5
  */
5
6
 
6
7
  /**
@@ -103,13 +104,21 @@ async function handleDiscover(task, taskId, dependencies, config) {
103
104
  }
104
105
 
105
106
  if (finalActiveUsers.length > 0) {
107
+ // --- START MODIFICATION ---
108
+ // Include the UserName in the task payload for 'verify'
106
109
  const verificationTask = {
107
110
  type: 'verify',
108
- users: finalActiveUsers.map(u => ({ cid: u.CID, isBronze: u.Value.IsBronze })),
111
+ users: finalActiveUsers.map(u => ({
112
+ cid: u.CID,
113
+ isBronze: u.Value.IsBronze,
114
+ username: u.Value.UserName // <-- ADD THIS
115
+ })),
109
116
  blockId,
110
117
  instrument,
111
118
  userType
112
119
  };
120
+ // --- END MODIFICATION ---
121
+
113
122
  // Use pubsub from dependencies
114
123
  await pubsub.topic(config.PUBSUB_TOPIC_USER_FETCH)
115
124
  .publishMessage({ json: verificationTask });
@@ -122,4 +131,4 @@ async function handleDiscover(task, taskId, dependencies, config) {
122
131
  }
123
132
  }
124
133
 
125
- module.exports = { handleDiscover };
134
+ module.exports = { handleDiscover };
@@ -2,49 +2,151 @@
2
2
  * @fileoverview Sub-pipe: pipe.taskEngine.handleUpdate
3
3
  * REFACTORED: Now stateless and receives dependencies.
4
4
  * OPTIMIZED: Removed immediate batch commit for speculator timestamp fix.
5
+ * --- MODIFIED: Fetches portfolio AND trade history in parallel. ---
6
+ * --- MODIFIED: Includes helper to look up usernames from CIDs. ---
5
7
  */
6
8
  const { FieldValue } = require('@google-cloud/firestore');
7
9
 
10
+ /**
11
+ * --- NEW HELPER ---
12
+ * Fetches user details (including username) from the rankings API for a batch of CIDs.
13
+ * @param {Array<string>} cids - An array of user CIDs (as strings or numbers).
14
+ * @param {object} dependencies - Contains proxyManager, headerManager, logger.
15
+ * @param {object} config - The configuration object.
16
+ * @returns {Promise<Array<object>>} An array of the raw user objects from the API.
17
+ */
18
+ async function lookupUsernames(cids, dependencies, config) {
19
+ const { logger, headerManager, proxyManager } = dependencies;
20
+ const { USERNAME_LOOKUP_BATCH_SIZE, ETORO_API_RANKINGS_URL } = config;
21
+
22
+ if (!cids || cids.length === 0) {
23
+ return [];
24
+ }
25
+
26
+ logger.log('INFO', `[lookupUsernames] Looking up usernames for ${cids.length} CIDs.`);
27
+ const allPublicUsers = [];
28
+
29
+ for (let i = 0; i < cids.length; i += USERNAME_LOOKUP_BATCH_SIZE) {
30
+ const batchCids = cids.slice(i, i + USERNAME_LOOKUP_BATCH_SIZE).map(Number);
31
+
32
+ const url = `${ETORO_API_RANKINGS_URL}?Period=LastTwoYears`;
33
+ const selectedHeader = await headerManager.selectHeader();
34
+ if (!selectedHeader) {
35
+ logger.log('ERROR', '[lookupUsernames] Could not select a header.');
36
+ continue; // Skip this batch
37
+ }
38
+
39
+ let wasSuccess = false;
40
+ try {
41
+ const response = await proxyManager.fetch(url, {
42
+ method: 'POST',
43
+ headers: { ...selectedHeader.header, 'Content-Type': 'application/json' },
44
+ body: JSON.stringify(batchCids),
45
+ });
46
+
47
+ if (!response.ok) {
48
+ throw new Error(`[lookupUsernames] API status ${response.status}`);
49
+ }
50
+ wasSuccess = true;
51
+
52
+ const publicUsers = await response.json();
53
+ if (Array.isArray(publicUsers)) {
54
+ allPublicUsers.push(...publicUsers);
55
+ }
56
+ } catch (error) {
57
+ logger.log('WARN', `[lookupUsernames] Failed to fetch batch of usernames.`, { errorMessage: error.message });
58
+ } finally {
59
+ if (selectedHeader) {
60
+ headerManager.updatePerformance(selectedHeader.id, wasSuccess);
61
+ }
62
+ }
63
+ }
64
+
65
+ logger.log('INFO', `[lookupUsernames] Found ${allPublicUsers.length} public users out of ${cids.length}.`);
66
+ return allPublicUsers;
67
+ }
68
+
69
+
8
70
  /**
9
71
  * Sub-pipe: pipe.taskEngine.handleUpdate
10
72
  * @param {object} task The Pub/Sub task payload.
11
73
  * @param {string} taskId A unique ID for logging.
12
74
  * @param {object} dependencies - Contains db, pubsub, logger, headerManager, proxyManager, batchManager.
13
75
  * @param {object} config The configuration object.
76
+ * @param {string} username - The user's eToro username.
14
77
  */
15
- async function handleUpdate(task, taskId, dependencies, config) {
78
+ async function handleUpdate(task, taskId, dependencies, config, username) {
16
79
  const { logger, headerManager, proxyManager, db, batchManager } = dependencies;
17
80
  const { userId, instrumentId, userType } = task;
81
+
82
+ // --- START MODIFICATION: Define both URLs ---
83
+ const portfolioUrl = userType === 'speculator'
84
+ ? `${config.ETORO_API_POSITIONS_URL}?cid=${userId}&InstrumentID=${instrumentId}`
85
+ : `${config.ETORO_API_PORTFOLIO_URL}?cid=${userId}`;
86
+
87
+ const historyUrl = `${config.ETORO_API_USERSTATS_URL}${username}/trades/oneYearAgo?CopyAsAsset=true`;
18
88
 
19
- const selectedHeader = await headerManager.selectHeader();
20
- if (!selectedHeader) throw new Error("Could not select a header.");
89
+ const today = new Date().toISOString().slice(0, 10);
90
+ const portfolioStorageBlockId = `${Math.floor(parseInt(userId) / 1000000)}M`;
91
+
92
+ let portfolioHeader = null;
93
+ let historyHeader = null;
94
+ let wasPortfolioSuccess = false;
95
+ let wasHistorySuccess = false;
96
+ let isPrivate = false;
21
97
 
22
- let wasSuccess = false;
23
98
  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 });
99
+ logger.log('INFO', `[UPDATE] Fetching data for user ${userId} (${username}, ${userType})`);
32
100
 
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}.`);
101
+ // Select headers for both requests
102
+ portfolioHeader = await headerManager.selectHeader();
103
+ historyHeader = await headerManager.selectHeader();
104
+ if (!portfolioHeader || !historyHeader) {
105
+ throw new Error("Could not select headers for update requests.");
36
106
  }
37
107
 
38
- const responseBody = await response.text();
108
+ // --- Fetch both endpoints in parallel ---
109
+ const [portfolioResult, historyResult] = await Promise.allSettled([
110
+ proxyManager.fetch(portfolioUrl, { headers: portfolioHeader.header }),
111
+ proxyManager.fetch(historyUrl, { headers: historyHeader.header })
112
+ ]);
39
113
 
40
- if (responseBody.includes("user is PRIVATE")) {
41
- logger.log('WARN', `User ${userId} is private. Removing from future updates and decrementing block count.`);
114
+ // --- Process Portfolio Result ---
115
+ if (portfolioResult.status === 'fulfilled' && portfolioResult.value.ok) {
116
+ const responseBody = await portfolioResult.value.text();
42
117
 
43
- // Use batchManager from dependencies
118
+ // Check for private user
119
+ if (responseBody.includes("user is PRIVATE")) {
120
+ isPrivate = true;
121
+ } else {
122
+ wasPortfolioSuccess = true;
123
+ const portfolioData = JSON.parse(responseBody);
124
+ // Add to portfolio batch
125
+ await batchManager.addToPortfolioBatch(userId, portfolioStorageBlockId, today, portfolioData, userType, instrumentId);
126
+ }
127
+ } else {
128
+ // Log failure
129
+ const errorMsg = portfolioResult.status === 'rejected' ? portfolioResult.reason.message : `API status ${portfolioResult.value.status}`;
130
+ logger.log('WARN', `[UPDATE] Failed to fetch PORTFOLIO for user ${userId}.`, { error: errorMsg });
131
+ }
132
+
133
+ // --- Process History Result ---
134
+ if (historyResult.status === 'fulfilled' && historyResult.value.ok) {
135
+ const historyData = await historyResult.value.json();
136
+ wasHistorySuccess = true;
137
+ // Add to history batch
138
+ await batchManager.addToTradingHistoryBatch(userId, portfolioStorageBlockId, today, historyData, userType);
139
+ } else {
140
+ // Log failure
141
+ const errorMsg = historyResult.status === 'rejected' ? historyResult.reason.message : `API status ${historyResult.value.status}`;
142
+ logger.log('WARN', `[UPDATE] Failed to fetch HISTORY for user ${userId} (${username}).`, { error: errorMsg });
143
+ }
144
+
145
+ // --- Handle Private User (if detected) ---
146
+ if (isPrivate) {
147
+ logger.log('WARN', `User ${userId} is private. Removing from future updates and decrementing block count.`);
44
148
  batchManager.deleteFromTimestampBatch(userId, userType, instrumentId);
45
149
 
46
- // <<< START FIX for private user blockId format >>>
47
- // The blockId format for Speculator Blocks is numeric (e.g., "1000000")
48
150
  let blockId;
49
151
  let incrementField;
50
152
 
@@ -52,68 +154,42 @@ async function handleUpdate(task, taskId, dependencies, config) {
52
154
  blockId = String(Math.floor(parseInt(userId) / 1000000) * 1000000);
53
155
  incrementField = `counts.${instrumentId}_${blockId}`;
54
156
  } 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
157
  blockId = String(Math.floor(parseInt(userId) / 1000000) * 1000000);
62
- // <<< END FIX for Normal User Block ID >>>
63
-
64
158
  incrementField = `counts.${blockId}`;
65
159
  }
66
160
 
67
- // Use db from dependencies
68
161
  const blockCountsRef = db.doc(userType === 'speculator'
69
162
  ? config.FIRESTORE_DOC_SPECULATOR_BLOCK_COUNTS
70
163
  : config.FIRESTORE_DOC_BLOCK_COUNTS);
71
- // <<< END FIX for private user blockId format >>>
72
164
 
73
165
  // This is a single, immediate write, which is fine for this rare case.
166
+ // We use .set here because batchManager.flushBatches() might not run if this is the only user.
167
+ // To be safer, this should also be added to a batch, but this is an edge case.
74
168
  await blockCountsRef.set({ [incrementField]: FieldValue.increment(-1) }, { merge: true });
75
-
76
169
  return;
77
170
  }
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
171
 
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);
172
+ // --- If either fetch was successful, update timestamps ---
173
+ if (wasPortfolioSuccess || wasHistorySuccess) {
174
+ // This call is correct for 'normal' users.
175
+ await batchManager.updateUserTimestamp(userId, userType, instrumentId);
103
176
 
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.`);
177
+ // This is the fix for speculator timestamps
178
+ if (userType === 'speculator') {
179
+ const orchestratorBlockId = String(Math.floor(parseInt(userId) / 1000000) * 1000000);
180
+ logger.log('INFO', `[UPDATE] Applying speculator timestamp fix for user ${userId} in block ${orchestratorBlockId}`);
181
+ await batchManager.addSpeculatorTimestampFix(userId, orchestratorBlockId);
182
+ }
111
183
  }
112
- // <<< END OPTIMIZATION / FULL CODE FIX >>>
113
-
184
+
114
185
  } finally {
115
- if (selectedHeader) headerManager.updatePerformance(selectedHeader.id, wasSuccess);
186
+ // Update performance for both headers used
187
+ if (portfolioHeader) headerManager.updatePerformance(portfolioHeader.id, wasPortfolioSuccess);
188
+ if (historyHeader) headerManager.updatePerformance(historyHeader.id, wasHistorySuccess);
116
189
  }
117
190
  }
118
191
 
119
- module.exports = { handleUpdate };
192
+ module.exports = {
193
+ handleUpdate,
194
+ lookupUsernames // <-- EXPORT NEW HELPER
195
+ };
@@ -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.123",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [