bulltrackers-module 1.0.96 → 1.0.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Main pipe: pipe.maintenance.runFetchPrices
|
|
3
3
|
* REFACTORED: Now writes to the new sharded `asset_prices` collection.
|
|
4
|
+
* FIXED: Uses docRef.update() to correctly merge nested price maps
|
|
5
|
+
* instead of docRef.set({ merge: true }) which creates flat keys.
|
|
4
6
|
*/
|
|
5
7
|
const { FieldValue } = require('@google-cloud/firestore');
|
|
6
8
|
|
|
@@ -77,7 +79,7 @@ exports.fetchAndStorePrices = async (config, dependencies) => {
|
|
|
77
79
|
shardUpdates[shardId] = {};
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
// Use dot notation to
|
|
82
|
+
// Use dot notation to define the update path
|
|
81
83
|
const pricePath = `${instrumentIdStr}.prices.${dateKey}`;
|
|
82
84
|
const updatePath = `${instrumentIdStr}.lastUpdated`;
|
|
83
85
|
|
|
@@ -92,8 +94,11 @@ exports.fetchAndStorePrices = async (config, dependencies) => {
|
|
|
92
94
|
const docRef = db.collection(priceCollectionName).doc(shardId);
|
|
93
95
|
const payload = shardUpdates[shardId];
|
|
94
96
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
+
// --- THIS IS THE FIX ---
|
|
98
|
+
// Use .update() to correctly merge data into nested maps.
|
|
99
|
+
// Using .set(payload, { merge: true }) creates the flat, broken keys.
|
|
100
|
+
batchPromises.push(docRef.update(payload));
|
|
101
|
+
// --- END FIX ---
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
await Promise.all(batchPromises);
|
package/index.js
CHANGED
|
@@ -80,8 +80,6 @@ const maintenance = {
|
|
|
80
80
|
handleInvalidSpeculator: require('./functions/invalid-speculator-handler/helpers/handler_helpers').handleInvalidSpeculator,
|
|
81
81
|
runFetchInsights: require('./functions/fetch-insights/helpers/handler_helpers').fetchAndStoreInsights,
|
|
82
82
|
runFetchPrices: require('./functions/etoro-price-fetcher/helpers/handler_helpers').fetchAndStorePrices,
|
|
83
|
-
runUserActivitySamplerOrchestrator: require('./functions/user-activity-sampler/helpers/sampler_helpers').runUserActivitySamplerOrchestrator,
|
|
84
|
-
handleSampleBlockTask: require('./functions/user-activity-sampler/helpers/sampler_helpers').handleSampleBlockTask,
|
|
85
83
|
runSocialOrchestrator: require('./functions/social-orchestrator/helpers/orchestrator_helpers').runSocialOrchestrator,
|
|
86
84
|
handleSocialTask: require('./functions/social-task-handler/helpers/handler_helpers').handleSocialTask,
|
|
87
85
|
runBackfillAssetPrices: require('./functions/price-backfill/helpers/handler_helpers').runBackfillAssetPrices,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bulltrackers-module",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.98",
|
|
4
4
|
"description": "Helper Functions for Bulltrackers.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
"functions/fetch-insights/",
|
|
17
17
|
"functions/etoro-price-fetcher/",
|
|
18
18
|
"functions/appscript-api/",
|
|
19
|
-
"functions/user-activity-sampler/",
|
|
20
19
|
"functions/social-orchestrator/",
|
|
21
20
|
"functions/social-task-handler/",
|
|
22
21
|
"functions/price-backfill/"
|
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Main pipe: pipe.maintenance.runUserActivitySampler
|
|
3
|
-
* REFACTORED: Now split into an orchestrator and a task handler.
|
|
4
|
-
* - runUserActivitySamplerOrchestrator: Publishes one task per block.
|
|
5
|
-
* - handleSampleBlockTask: Processes a single block with parallel fetching.
|
|
6
|
-
*/
|
|
7
|
-
const { FieldValue } = require('@google-cloud/firestore');
|
|
8
|
-
const pLimit = require('p-limit'); // npm install p-limit
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Helper: delay
|
|
12
|
-
*/
|
|
13
|
-
function delay(ms) {
|
|
14
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Helper: fetch with retry and exponential backoff on 429
|
|
19
|
-
*/
|
|
20
|
-
async function fetchWithRetry(fetchFn, maxRetries = 5, logger, taskId) {
|
|
21
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
22
|
-
try {
|
|
23
|
-
const response = await fetchFn();
|
|
24
|
-
if (response.status !== 429) return response;
|
|
25
|
-
|
|
26
|
-
const backoff = 1000 * Math.pow(2, attempt); // 1s, 2s, 4s, 8s...
|
|
27
|
-
logger.log('WARN', `[SamplerTask/${taskId}] Received 429. Retrying in ${backoff}ms (attempt ${attempt + 1}/${maxRetries})`);
|
|
28
|
-
await delay(backoff);
|
|
29
|
-
} catch (err) {
|
|
30
|
-
logger.log('ERROR', `[SamplerTask/${taskId}] Fetch failed on attempt ${attempt + 1}.`, { errorMessage: err.message });
|
|
31
|
-
if (attempt === maxRetries) throw err;
|
|
32
|
-
const backoff = 500 * Math.pow(2, attempt); // slightly faster backoff for network errors
|
|
33
|
-
await delay(backoff);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
throw new Error('Max retries reached for fetchWithRetry');
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Main pipe (Orchestrator): pipe.maintenance.runUserActivitySamplerOrchestrator
|
|
41
|
-
* This function is triggered by a schedule. It fans out the work by
|
|
42
|
-
* publishing one Pub/Sub message for each block to be sampled.
|
|
43
|
-
*
|
|
44
|
-
* @param {object} config - Configuration object.
|
|
45
|
-
* @param {object} dependencies - Contains db, logger, firestoreUtils, pubsubUtils.
|
|
46
|
-
* @returns {Promise<object>} Summary of the orchestration.
|
|
47
|
-
*/
|
|
48
|
-
exports.runUserActivitySamplerOrchestrator = async (config, dependencies) => {
|
|
49
|
-
const { logger, firestoreUtils, pubsubUtils } = dependencies;
|
|
50
|
-
logger.log('INFO', '[SamplerOrchestrator] Starting user activity sampling orchestration...');
|
|
51
|
-
|
|
52
|
-
// Validate configuration
|
|
53
|
-
if (!config.allHighValueBlocks || !Array.isArray(config.allHighValueBlocks) || !config.samplerTaskTopicName) {
|
|
54
|
-
logger.log('ERROR', '[SamplerOrchestrator] Missing required configuration: allHighValueBlocks (array) or samplerTaskTopicName.');
|
|
55
|
-
throw new Error('Missing required configuration for Sampler Orchestrator.');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
// Reset locks once for all tasks
|
|
60
|
-
await firestoreUtils.resetProxyLocks(dependencies, config);
|
|
61
|
-
|
|
62
|
-
const tasks = [];
|
|
63
|
-
for (const block of config.allHighValueBlocks) {
|
|
64
|
-
// Ensure block and block.startId exist
|
|
65
|
-
if (!block || typeof block.startId === 'undefined') {
|
|
66
|
-
logger.log('WARN', '[SamplerOrchestrator] Skipping invalid block configuration:', block);
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
tasks.push({
|
|
70
|
-
type: 'sample-block',
|
|
71
|
-
blockId: block.startId
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (tasks.length === 0) {
|
|
76
|
-
logger.log('WARN', '[SamplerOrchestrator] No valid blocks found to sample.');
|
|
77
|
-
return { success: true, message: "No valid blocks configured." };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Use pubsubUtils to batch publish all block tasks
|
|
81
|
-
await pubsubUtils.batchPublishTasks(dependencies, {
|
|
82
|
-
topicName: config.samplerTaskTopicName,
|
|
83
|
-
tasks: tasks,
|
|
84
|
-
taskType: 'sampler-block-task'
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
logger.log('SUCCESS', `[SamplerOrchestrator] Successfully published ${tasks.length} block sampling tasks.`);
|
|
88
|
-
return { success: true, blocksQueued: tasks.length };
|
|
89
|
-
|
|
90
|
-
} catch (error) {
|
|
91
|
-
logger.log('ERROR', '[SamplerOrchestrator] Fatal error during orchestration.', { errorMessage: error.message, errorStack: error.stack });
|
|
92
|
-
throw error;
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Internal Helper: Fetches a single batch of CIDs for the sampler.
|
|
98
|
-
* This is designed to be called in parallel.
|
|
99
|
-
*/
|
|
100
|
-
async function fetchSampleBatch(blockId, config, dependencies, processedInThisRun) {
|
|
101
|
-
const { logger, headerManager, proxyManager } = dependencies;
|
|
102
|
-
const cidsToSample = [];
|
|
103
|
-
|
|
104
|
-
// 1. Generate CIDs for this batch
|
|
105
|
-
while (cidsToSample.length < config.apiBatchSize) {
|
|
106
|
-
const randomId = String(Math.floor(Math.random() * 1000000) + blockId);
|
|
107
|
-
// Use a Set to prevent processing the same ID twice *within this run*
|
|
108
|
-
if (!processedInThisRun.has(randomId)) {
|
|
109
|
-
cidsToSample.push(parseInt(randomId, 10));
|
|
110
|
-
processedInThisRun.add(randomId);
|
|
111
|
-
}
|
|
112
|
-
// Note: If this loops too long, it could be inefficient, but it's
|
|
113
|
-
// unlikely with a large ID space and a reasonable sample size.
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
let selectedHeader = null;
|
|
117
|
-
let wasSuccess = false;
|
|
118
|
-
try {
|
|
119
|
-
selectedHeader = await headerManager.selectHeader();
|
|
120
|
-
if (!selectedHeader) throw new Error("Could not select header.");
|
|
121
|
-
|
|
122
|
-
const urlWithParam = `${config.rankingsApiUrl}?Period=LastTwoYears`;
|
|
123
|
-
const response = await proxyManager.fetch(urlWithParam, {
|
|
124
|
-
method: 'POST',
|
|
125
|
-
headers: { ...selectedHeader.header, 'Content-Type': 'application/json' },
|
|
126
|
-
body: JSON.stringify(cidsToSample),
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
if (!response || typeof response.json !== 'function') {
|
|
130
|
-
logger.log('WARN', `[SamplerTask] Invalid response structure from proxy for block ${blockId}.`);
|
|
131
|
-
logger.log('DEBUG', `[SamplerTask] Response details: ${JSON.stringify(response)}`);
|
|
132
|
-
return { success: false, cidsSent: cidsToSample.length, publicUsers: [] };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (!response.ok) {
|
|
136
|
-
const errorText = await response.text();
|
|
137
|
-
logger.log('WARN', `[SamplerTask] API error ${response.status} for block ${blockId}. Error: ${errorText}`);
|
|
138
|
-
wasSuccess = false;
|
|
139
|
-
return { success: false, cidsSent: cidsToSample.length, publicUsers: [] };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
wasSuccess = true;
|
|
143
|
-
const publicUsersBatch = await response.json();
|
|
144
|
-
|
|
145
|
-
if (!Array.isArray(publicUsersBatch)) {
|
|
146
|
-
logger.log('WARN', `[SamplerTask] API response was not an array for block ${blockId}.`);
|
|
147
|
-
return { success: false, cidsSent: cidsToSample.length, publicUsers: [] };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Return a successful result
|
|
151
|
-
return {
|
|
152
|
-
success: true,
|
|
153
|
-
cidsSent: cidsToSample.length,
|
|
154
|
-
publicUsers: publicUsersBatch.map(u => ({
|
|
155
|
-
CID: u.CID,
|
|
156
|
-
LastActivity: u.Value?.LastActivity
|
|
157
|
-
}))
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
} catch (fetchError) {
|
|
161
|
-
logger.log('ERROR', `[SamplerTask] Fetch failed for block ${blockId}.`, { errorMessage: fetchError.message });
|
|
162
|
-
wasSuccess = false;
|
|
163
|
-
return { success: false, cidsSent: cidsToSample.length, publicUsers: [] };
|
|
164
|
-
} finally {
|
|
165
|
-
if (selectedHeader) {
|
|
166
|
-
headerManager.updatePerformance(selectedHeader.id, wasSuccess);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Updated Task Handler: handleSampleBlockTask
|
|
173
|
-
*/
|
|
174
|
-
exports.handleSampleBlockTask = async (message, context, config, dependencies) => {
|
|
175
|
-
const { db, logger, headerManager, proxyManager } = dependencies;
|
|
176
|
-
|
|
177
|
-
let task;
|
|
178
|
-
try {
|
|
179
|
-
task = JSON.parse(Buffer.from(message.data, 'base64').toString('utf-8'));
|
|
180
|
-
} catch (e) {
|
|
181
|
-
logger.log('ERROR', '[SamplerTask] Failed to parse Pub/Sub message data.', { error: e.message, data: message.data });
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const { blockId } = task;
|
|
186
|
-
const taskId = `block-${blockId}-${context.eventId || Date.now()}`;
|
|
187
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
188
|
-
logger.log('INFO', `[SamplerTask/${taskId}] Processing block ${blockId}...`);
|
|
189
|
-
|
|
190
|
-
// Config validation
|
|
191
|
-
if (!config.rankingsApiUrl || !config.targetPublicUsersPerBlock || !config.apiBatchSize || !config.outputCollectionName || !config.parallelRequests) {
|
|
192
|
-
logger.log('ERROR', `[SamplerTask/${taskId}] Missing required configuration for task execution.`);
|
|
193
|
-
throw new Error('Missing required configuration for Sampler Task.');
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const processedInThisRun = new Set();
|
|
197
|
-
let N_public_sampled_block = 0;
|
|
198
|
-
let N_sampled_total_block = 0;
|
|
199
|
-
const public_users_data = [];
|
|
200
|
-
const MAX_ATTEMPTS = config.maxSamplingAttemptsPerBlock || 1000;
|
|
201
|
-
let totalBatchesAttempted = 0;
|
|
202
|
-
|
|
203
|
-
const limit = pLimit(config.parallelRequests || 3);
|
|
204
|
-
|
|
205
|
-
try {
|
|
206
|
-
while (N_public_sampled_block < config.targetPublicUsersPerBlock && totalBatchesAttempted < MAX_ATTEMPTS) {
|
|
207
|
-
const numRequests = Math.min(config.parallelRequests, MAX_ATTEMPTS - totalBatchesAttempted);
|
|
208
|
-
const promises = [];
|
|
209
|
-
|
|
210
|
-
for (let i = 0; i < numRequests; i++) {
|
|
211
|
-
promises.push(limit(async () => {
|
|
212
|
-
// Wrap fetchSampleBatch to include fetchWithRetry
|
|
213
|
-
async function fetchBatchWithRetry() {
|
|
214
|
-
return await fetchSampleBatch(blockId, config, {
|
|
215
|
-
...dependencies,
|
|
216
|
-
proxyManager: {
|
|
217
|
-
fetch: (url, options) =>
|
|
218
|
-
fetchWithRetry(() => proxyManager.fetch(url, options), 5, logger, taskId)
|
|
219
|
-
}
|
|
220
|
-
}, processedInThisRun);
|
|
221
|
-
}
|
|
222
|
-
return await fetchBatchWithRetry();
|
|
223
|
-
}));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const results = await Promise.allSettled(promises);
|
|
227
|
-
totalBatchesAttempted += numRequests;
|
|
228
|
-
|
|
229
|
-
// Process results
|
|
230
|
-
for (const result of results) {
|
|
231
|
-
if (result.status === 'fulfilled' && result.value.success) {
|
|
232
|
-
const batchResult = result.value;
|
|
233
|
-
N_sampled_total_block += batchResult.cidsSent;
|
|
234
|
-
N_public_sampled_block += batchResult.publicUsers.length;
|
|
235
|
-
public_users_data.push(...batchResult.publicUsers);
|
|
236
|
-
} else if (result.status === 'fulfilled' && !result.value.success) {
|
|
237
|
-
N_sampled_total_block += result.value.cidsSent;
|
|
238
|
-
} else {
|
|
239
|
-
logger.log('WARN', `[SamplerTask/${taskId}] A sample fetch promise was rejected.`, { reason: result.reason });
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
logger.log('INFO', `[SamplerTask/${taskId}] Batch complete. Total public sampled: ${N_public_sampled_block}/${config.targetPublicUsersPerBlock}`);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (totalBatchesAttempted >= MAX_ATTEMPTS) {
|
|
247
|
-
logger.log('WARN', `[SamplerTask/${taskId}] Reached max sampling attempts (${MAX_ATTEMPTS}). Proceeding with ${N_public_sampled_block} users.`);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// --- Calculate and Store Results ---
|
|
251
|
-
const f_private_block = N_sampled_total_block > 0 ? 1 - (N_public_sampled_block / N_sampled_total_block) : 0;
|
|
252
|
-
const counts = { A1: 0, A2: 0, A3: 0, A4: 0 };
|
|
253
|
-
const now = new Date();
|
|
254
|
-
const oneDayAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
|
255
|
-
const oneWeekAgo = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000));
|
|
256
|
-
const threeMonthsAgo = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000));
|
|
257
|
-
|
|
258
|
-
public_users_data.forEach(user => {
|
|
259
|
-
if (!user.LastActivity) { counts.A4++; return; }
|
|
260
|
-
try {
|
|
261
|
-
const lastActivityDate = new Date(user.LastActivity);
|
|
262
|
-
if (isNaN(lastActivityDate)) { counts.A4++; return; }
|
|
263
|
-
if (lastActivityDate >= oneDayAgo) counts.A1++;
|
|
264
|
-
else if (lastActivityDate >= oneWeekAgo) counts.A2++;
|
|
265
|
-
else if (lastActivityDate >= threeMonthsAgo) counts.A3++;
|
|
266
|
-
else counts.A4++;
|
|
267
|
-
} catch {
|
|
268
|
-
counts.A4++;
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
const fractions = {};
|
|
273
|
-
for (const category in counts) fractions[category] = N_public_sampled_block > 0 ? (counts[category] / N_public_sampled_block) : 0;
|
|
274
|
-
|
|
275
|
-
const N_block = 1000000;
|
|
276
|
-
const estimatedCounts = {};
|
|
277
|
-
for (const category in fractions) estimatedCounts[category] = Math.round(fractions[category] * N_block);
|
|
278
|
-
|
|
279
|
-
const result = {
|
|
280
|
-
blockId,
|
|
281
|
-
sampledDate: today,
|
|
282
|
-
f_private: f_private_block,
|
|
283
|
-
publicSampleSize: N_public_sampled_block,
|
|
284
|
-
totalSampleSize: N_sampled_total_block,
|
|
285
|
-
totalUsersInBlock: N_block,
|
|
286
|
-
activityCounts_Sample: counts,
|
|
287
|
-
activityFractions_Sample: fractions,
|
|
288
|
-
estimatedCounts_TotalBlock: estimatedCounts,
|
|
289
|
-
lastUpdated: FieldValue.serverTimestamp()
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
const docRef = db.collection(config.outputCollectionName).doc(`${blockId}_${today}`);
|
|
293
|
-
await docRef.set(result);
|
|
294
|
-
logger.log('SUCCESS', `[SamplerTask/${taskId}] Stored results for block ${blockId}. Sampled: ${N_public_sampled_block}. Private fraction: ${f_private_block.toFixed(3)}.`);
|
|
295
|
-
|
|
296
|
-
} catch (error) {
|
|
297
|
-
logger.log('ERROR', `[SamplerTask/${taskId}] Fatal error during task execution.`, { errorMessage: error.message, errorStack: error.stack });
|
|
298
|
-
throw error;
|
|
299
|
-
} finally {
|
|
300
|
-
try {
|
|
301
|
-
await headerManager.flushPerformanceUpdates();
|
|
302
|
-
logger.log('INFO', `[SamplerTask/${taskId}] Header performance flushed.`);
|
|
303
|
-
} catch (flushError) {
|
|
304
|
-
logger.log('ERROR', `[SamplerTask/${taskId}] Failed to flush header performance.`, { errorMessage: flushError.message });
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
};
|