bulltrackers-module 1.0.408 → 1.0.409
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,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Main entry point for the Task Engine Cloud Function.
|
|
3
|
+
* FIXED: Deduplication window increased to 20m to handle timeouts/redeliveries.
|
|
4
|
+
* FIXED: Robust timestamp extraction for Gen 1 & Gen 2 Cloud Functions.
|
|
3
5
|
*/
|
|
4
6
|
const { handleDiscover } = require('./helpers/discover_helpers');
|
|
5
7
|
const { handleVerify } = require('./helpers/verify_helpers');
|
|
@@ -8,10 +10,174 @@ const { handlePopularInvestorUpdate, handleOnDemandUserUpdate } = require('./hel
|
|
|
8
10
|
|
|
9
11
|
// IMPORT THE UTILS TO HANDLE BATCHES
|
|
10
12
|
const { executeTasks, prepareTaskBatches } = require('./utils/task_engine_utils');
|
|
13
|
+
|
|
11
14
|
async function handleRequest(message, context, config, dependencies) {
|
|
12
|
-
const { logger } = dependencies;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
const { logger, batchManager, db } = dependencies;
|
|
16
|
+
|
|
17
|
+
// [CRITICAL FIX] Max Age increased to 25m to match the larger dedup window.
|
|
18
|
+
// If a message is older than this, we drop it to stop infinite loops.
|
|
19
|
+
const MAX_MESSAGE_AGE_MS = 25 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
// [CRITICAL FIX] Dedup Window increased to 20m.
|
|
22
|
+
// Must be significantly larger than the Function Timeout (9m or 60m).
|
|
23
|
+
// This ensures that if a function times out and Pub/Sub redelivers it,
|
|
24
|
+
// we still recognize it as "recently processed" and skip it.
|
|
25
|
+
const DEDUP_WINDOW_MS = 20 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
let messageAge = null;
|
|
28
|
+
let publishTime = null;
|
|
29
|
+
const messageId = context.eventId || message.messageId || message.id || `msg-${Date.now()}-${Math.random()}`;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// [CRITICAL FIX] Robust Timestamp Extraction (Gen 1 & Gen 2 support)
|
|
33
|
+
let publishTimeStr = message.publishTime ||
|
|
34
|
+
message.time || // CloudEvents (Gen 2)
|
|
35
|
+
(message.attributes && message.attributes.dispatched_at) ||
|
|
36
|
+
context.timestamp;
|
|
37
|
+
|
|
38
|
+
if (publishTimeStr) {
|
|
39
|
+
publishTime = new Date(publishTimeStr);
|
|
40
|
+
messageAge = Date.now() - publishTime.getTime();
|
|
41
|
+
} else {
|
|
42
|
+
logger.log('WARN', '[TaskEngine] Could not determine message publish time. Using current time as proxy (risky).');
|
|
43
|
+
// If we can't find a time, we can't trust age checks, but we proceed cautiously.
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 1. Age Check
|
|
47
|
+
if (messageAge !== null && messageAge > MAX_MESSAGE_AGE_MS) {
|
|
48
|
+
const ageMinutes = Math.round(messageAge / 60000);
|
|
49
|
+
logger.log('WARN', `[TaskEngine] REJECTING stale message (${ageMinutes} minutes old). Acknowledging and skipping to prevent cost.`, {
|
|
50
|
+
messageId,
|
|
51
|
+
messageAge: `${ageMinutes} minutes`,
|
|
52
|
+
publishTime: publishTime?.toISOString(),
|
|
53
|
+
maxAge: '25 minutes',
|
|
54
|
+
action: 'acknowledged_and_skipped'
|
|
55
|
+
});
|
|
56
|
+
return; // Return immediately - Cloud Function will auto-acknowledge
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. Deduplication Check
|
|
60
|
+
const dedupCollection = db.collection('system_task_deduplication');
|
|
61
|
+
const dedupDocId = `msg_${messageId}`;
|
|
62
|
+
const dedupRef = dedupCollection.doc(dedupDocId);
|
|
63
|
+
const dedupDoc = await dedupRef.get();
|
|
64
|
+
|
|
65
|
+
if (dedupDoc.exists) {
|
|
66
|
+
const dedupData = dedupDoc.data();
|
|
67
|
+
const processedAt = dedupData.processedAt?.toDate?.() || new Date(dedupData.processedAt);
|
|
68
|
+
const timeSinceProcessed = Date.now() - processedAt.getTime();
|
|
69
|
+
|
|
70
|
+
if (timeSinceProcessed < DEDUP_WINDOW_MS) {
|
|
71
|
+
const minutesAgo = Math.round(timeSinceProcessed / 60000);
|
|
72
|
+
logger.log('WARN', `[TaskEngine] REJECTING duplicate message (processed ${minutesAgo} minutes ago). Acknowledging and skipping to prevent infinite loop.`, {
|
|
73
|
+
messageId,
|
|
74
|
+
processedAt: processedAt.toISOString(),
|
|
75
|
+
timeSinceProcessed: `${minutesAgo} minutes`,
|
|
76
|
+
dedupWindow: '20 minutes',
|
|
77
|
+
action: 'acknowledged_and_skipped_duplicate'
|
|
78
|
+
});
|
|
79
|
+
return; // Return immediately - Cloud Function will auto-acknowledge
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Mark message as being processed (with TTL)
|
|
84
|
+
await dedupRef.set({
|
|
85
|
+
messageId,
|
|
86
|
+
processedAt: new Date(),
|
|
87
|
+
publishTime: publishTime?.toISOString() || 'unknown',
|
|
88
|
+
expiresAt: new Date(Date.now() + DEDUP_WINDOW_MS)
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
} catch (ageCheckError) {
|
|
92
|
+
logger.log('WARN', '[TaskEngine] Could not check message age/deduplication, proceeding with caution', { error: ageCheckError.message });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. Parse the Message Payload
|
|
96
|
+
let payload;
|
|
97
|
+
try {
|
|
98
|
+
const rawData = message.data ? Buffer.from(message.data, 'base64').toString() : message;
|
|
99
|
+
payload = (typeof rawData === 'string') ? JSON.parse(rawData) : rawData;
|
|
100
|
+
} catch (e) {
|
|
101
|
+
logger.log('ERROR', '[TaskEngine] Failed to parse message payload.', e);
|
|
102
|
+
return; // Return to acknowledge
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// CASE A: Payload is a Batch (from Dispatcher)
|
|
106
|
+
if (payload.tasks && Array.isArray(payload.tasks)) {
|
|
107
|
+
const messagePublishTime = publishTime?.toISOString() || 'unknown';
|
|
108
|
+
const messageAgeMinutes = messageAge ? Math.round(messageAge / 60000) : 'unknown';
|
|
109
|
+
|
|
110
|
+
logger.log('INFO', `[TaskEngine] Received BATCH of ${payload.tasks.length} tasks.`, {
|
|
111
|
+
messageId,
|
|
112
|
+
messagePublishTime,
|
|
113
|
+
messageAgeMinutes: `${messageAgeMinutes} minutes`,
|
|
114
|
+
totalTasks: payload.tasks.length
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const taskId = context.eventId || 'batch-' + Date.now();
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const { tasksToRun, otherTasks } = await prepareTaskBatches(payload.tasks, null, logger);
|
|
121
|
+
await executeTasks(tasksToRun, otherTasks, dependencies, config, taskId);
|
|
122
|
+
} catch (batchError) {
|
|
123
|
+
logger.log('ERROR', `[TaskEngine] Error processing batch. Message will be acknowledged to prevent retry loop.`, {
|
|
124
|
+
error: batchError.message,
|
|
125
|
+
stack: batchError.stack,
|
|
126
|
+
messageId
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// CASE B: Payload is a Single Task (from Cron/On-Demand)
|
|
133
|
+
const { type, data } = payload;
|
|
134
|
+
|
|
135
|
+
if (!type) {
|
|
136
|
+
logger.log('WARN', '[TaskEngine] Received message with no type.', payload);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
logger.log('INFO', `[TaskEngine] Processing Single Task: ${type}`, {
|
|
141
|
+
type,
|
|
142
|
+
dataSummary: data ? JSON.stringify(data).substring(0, 200) : 'no data'
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
switch (type) {
|
|
147
|
+
case 'DISCOVER':
|
|
148
|
+
await handleDiscover(data, 'single-discover', dependencies, config);
|
|
149
|
+
break;
|
|
150
|
+
case 'VERIFY':
|
|
151
|
+
await handleVerify(data, 'single-verify', dependencies, config);
|
|
152
|
+
break;
|
|
153
|
+
case 'UPDATE':
|
|
154
|
+
await handleUpdate(data, 'single-update', dependencies, config);
|
|
155
|
+
break;
|
|
156
|
+
case 'POPULAR_INVESTOR_UPDATE':
|
|
157
|
+
await handlePopularInvestorUpdate(data, config, dependencies);
|
|
158
|
+
break;
|
|
159
|
+
case 'ON_DEMAND_USER_UPDATE':
|
|
160
|
+
const onDemandData = data || payload;
|
|
161
|
+
if (!onDemandData.cid || !onDemandData.username) {
|
|
162
|
+
logger.log('ERROR', `[TaskEngine] ON_DEMAND_USER_UPDATE missing required fields`, { data: onDemandData });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await handleOnDemandUserUpdate(onDemandData, config, dependencies);
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
logger.log('WARN', `[TaskEngine] Unknown task type: ${type}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (batchManager) {
|
|
172
|
+
await batchManager.flushBatches();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
} catch (err) {
|
|
176
|
+
logger.log('ERROR', `[TaskEngine] Error processing task ${type}. Message will be acknowledged.`, {
|
|
177
|
+
error: err.message,
|
|
178
|
+
type
|
|
179
|
+
});
|
|
180
|
+
}
|
|
15
181
|
}
|
|
16
182
|
|
|
17
183
|
module.exports = { handleRequest };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* FILENAME: CloudFunctions/NpmWrappers/bulltrackers-module/functions/task-engine/utils/task_engine_utils.js
|
|
3
|
-
* (REFACTORED: Concurrency limit
|
|
4
|
-
* FIXED:
|
|
3
|
+
* (REFACTORED: Concurrency limit increased to 5)
|
|
4
|
+
* FIXED: Increased concurrency to prevent timeouts on large batches.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -38,21 +38,9 @@ async function prepareTaskBatches(tasks, batchManager, logger) {
|
|
|
38
38
|
if (task.type === 'update') {
|
|
39
39
|
// Standard portfolio updates (Normal/Speculator ONLY)
|
|
40
40
|
// NOTE: Popular Investors use type 'POPULAR_INVESTOR_UPDATE', not 'update'
|
|
41
|
-
logger.log('WARN', `[TaskEngine] Processing OLD-STYLE UPDATE task (Normal/Speculator):`, {
|
|
42
|
-
taskType: task.type,
|
|
43
|
-
userType: task.userType || 'unknown',
|
|
44
|
-
userId: task.userId,
|
|
45
|
-
note: 'This is a normal/speculator task. Popular Investors use POPULAR_INVESTOR_UPDATE type.'
|
|
46
|
-
});
|
|
47
41
|
tasksToRun.push(task);
|
|
48
42
|
} else {
|
|
49
43
|
// Discover, Verify, Popular Investor (POPULAR_INVESTOR_UPDATE), Signed-In User (ON_DEMAND_USER_UPDATE)
|
|
50
|
-
logger.log('INFO', `[TaskEngine] Processing task type: ${task.type}`, {
|
|
51
|
-
taskType: task.type,
|
|
52
|
-
hasCid: !!task.cid,
|
|
53
|
-
hasUsername: !!task.username,
|
|
54
|
-
userId: task.userId || task.cid || 'unknown'
|
|
55
|
-
});
|
|
56
44
|
otherTasks.push(task);
|
|
57
45
|
}
|
|
58
46
|
}
|
|
@@ -64,12 +52,16 @@ async function prepareTaskBatches(tasks, batchManager, logger) {
|
|
|
64
52
|
|
|
65
53
|
/**
|
|
66
54
|
* Executes all tasks.
|
|
67
|
-
* (
|
|
55
|
+
* (FIXED: Concurrency limit increased to 5)
|
|
68
56
|
*/
|
|
69
57
|
async function executeTasks(tasksToRun, otherTasks, dependencies, config, taskId) {
|
|
70
58
|
const { logger, batchManager } = dependencies;
|
|
71
59
|
|
|
72
|
-
|
|
60
|
+
// [CRITICAL FIX] Increased from 1 to 5.
|
|
61
|
+
// A limit of 1 was causing timeouts on batches of 500 tasks (500s > 60s/540s timeout).
|
|
62
|
+
// 5 allows processing ~500 tasks in ~100 seconds (assuming 1s per task latency),
|
|
63
|
+
// which is well within the 9-minute Gen1 timeout.
|
|
64
|
+
const limit = pLimit(5);
|
|
73
65
|
|
|
74
66
|
const allTaskPromises = [];
|
|
75
67
|
let taskCounters = { update: 0, discover: 0, verify: 0, popular_investor: 0, on_demand: 0, unknown: 0, failed: 0 };
|
|
@@ -79,7 +71,6 @@ async function executeTasks(tasksToRun, otherTasks, dependencies, config, taskId
|
|
|
79
71
|
const subTaskId = `${task.type}-${task.userType || 'unknown'}-${task.userId || task.cids?.[0] || task.cid || 'sub'}`;
|
|
80
72
|
|
|
81
73
|
if (task.type === 'POPULAR_INVESTOR_UPDATE') {
|
|
82
|
-
// [FIX] Extract data from task if it's nested, otherwise use task directly
|
|
83
74
|
const taskData = task.data || task;
|
|
84
75
|
allTaskPromises.push(limit(() =>
|
|
85
76
|
handlePopularInvestorUpdate(taskData, config, dependencies)
|
|
@@ -93,9 +84,7 @@ async function executeTasks(tasksToRun, otherTasks, dependencies, config, taskId
|
|
|
93
84
|
}
|
|
94
85
|
|
|
95
86
|
if (task.type === 'ON_DEMAND_USER_UPDATE') {
|
|
96
|
-
// [FIX] Extract data from task if it's nested, otherwise use task directly
|
|
97
87
|
const taskData = task.data || task;
|
|
98
|
-
// [FIX] Validate task data before processing
|
|
99
88
|
if (!taskData.cid || !taskData.username) {
|
|
100
89
|
logger.log('ERROR', `[TaskEngine/${taskId}] ON_DEMAND_USER_UPDATE task missing required fields`, { task, taskData });
|
|
101
90
|
taskCounters.failed++;
|
|
@@ -135,18 +124,9 @@ async function executeTasks(tasksToRun, otherTasks, dependencies, config, taskId
|
|
|
135
124
|
}
|
|
136
125
|
|
|
137
126
|
// 2. Queue 'update' tasks (Standard Normal/Speculator)
|
|
138
|
-
// NOTE: These should ONLY be normal/speculator tasks. Popular Investors use POPULAR_INVESTOR_UPDATE type.
|
|
139
127
|
for (const task of tasksToRun) {
|
|
140
128
|
const subTaskId = `${task.type}-${task.userType || 'unknown'}-${task.userId}`;
|
|
141
129
|
|
|
142
|
-
// [LOG FIX] Log what we're about to process
|
|
143
|
-
logger.log('INFO', `[TaskEngine/${taskId}] Queuing UPDATE task:`, {
|
|
144
|
-
taskType: task.type,
|
|
145
|
-
userType: task.userType || 'unknown',
|
|
146
|
-
userId: task.userId,
|
|
147
|
-
taskKeys: Object.keys(task)
|
|
148
|
-
});
|
|
149
|
-
|
|
150
130
|
allTaskPromises.push(
|
|
151
131
|
limit(() =>
|
|
152
132
|
handleUpdate(task, subTaskId, dependencies, config)
|
|
@@ -162,9 +142,7 @@ async function executeTasks(tasksToRun, otherTasks, dependencies, config, taskId
|
|
|
162
142
|
// 3. Wait for ALL tasks to complete
|
|
163
143
|
await Promise.all(allTaskPromises);
|
|
164
144
|
|
|
165
|
-
// 4.
|
|
166
|
-
// This ensures that even if we only processed 200 items (less than the 400 threshold),
|
|
167
|
-
// they still get written to Firestore before the function exits.
|
|
145
|
+
// 4. Flush any remaining data in the buffer
|
|
168
146
|
if (batchManager) {
|
|
169
147
|
logger.log('INFO', `[TaskEngine/${taskId}] Triggering final batch flush...`);
|
|
170
148
|
await batchManager.flushBatches();
|