bulltrackers-module 1.0.892 → 1.0.894
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.
- package/functions/api-v3/testing/routes.integration.test.js +7 -1
- package/functions/api-v3/testing/test-app-factory.js +7 -0
- package/functions/computation-system-v3/verification/runVerification.js +13 -2
- package/functions/core/utils/intelligent_proxy_manager.js +28 -8
- package/functions/data-fetcher-system-v1/services/taskEngine/taskEngineService.js +57 -34
- package/functions/data-fetcher-system-v1/shared/appscriptClient.js +14 -13
- package/package.json +1 -1
|
@@ -16,6 +16,11 @@ process.env.COMPUTATION_VERIFICATION_URL = '';
|
|
|
16
16
|
// Mock Redis so sync route uses Firestore rate-limit path (path relative to Core where Jest runs)
|
|
17
17
|
jest.mock('bulltrackers-module/functions/shared/upstashRedis', () => ({ getRedis: () => null }));
|
|
18
18
|
|
|
19
|
+
// Force in-process verification in tests (no sandbox HTTP). Use same path as route require() so Jest applies the mock.
|
|
20
|
+
jest.mock('../services/verificationSandboxClient', () => ({
|
|
21
|
+
callVerificationSandbox: () => Promise.resolve(null),
|
|
22
|
+
}));
|
|
23
|
+
|
|
19
24
|
const request = require('supertest');
|
|
20
25
|
const { createTestApp } = require('./test-app-factory');
|
|
21
26
|
|
|
@@ -983,9 +988,10 @@ describe('Cross-Cutting', () => {
|
|
|
983
988
|
});
|
|
984
989
|
|
|
985
990
|
it('returns 200 and writes to GCS when code passes verification', async () => {
|
|
991
|
+
// Use process: (ctx) => ... so extracted process source re-parses when wrapped in parens (method shorthand process(ctx){ } is invalid as a standalone expression).
|
|
986
992
|
const validCode = `module.exports = {
|
|
987
993
|
config: { name: 'SafeComp', skills: ['lib'], requires: {} },
|
|
988
|
-
process(ctx)
|
|
994
|
+
process: (ctx) => ({ ok: true, date: ctx.date })
|
|
989
995
|
};`;
|
|
990
996
|
const res = await request(app)
|
|
991
997
|
.post('/computations/upload')
|
|
@@ -169,6 +169,13 @@ function createMockFirestore() {
|
|
|
169
169
|
commit: async () => { for (const op of ops) await op(); },
|
|
170
170
|
};
|
|
171
171
|
},
|
|
172
|
+
runTransaction(callback) {
|
|
173
|
+
const t = {
|
|
174
|
+
get: async (ref) => ref.get(),
|
|
175
|
+
set: async (ref, data, opts) => ref.set(data, opts),
|
|
176
|
+
};
|
|
177
|
+
return callback(t);
|
|
178
|
+
},
|
|
172
179
|
// ─── Test helper ─────────────────────────────────────────
|
|
173
180
|
// Seed a document at a Firestore-like path
|
|
174
181
|
seed(path, data) {
|
|
@@ -116,6 +116,8 @@ async function runVerification(code, name, userId, options = {}) {
|
|
|
116
116
|
addStage(STAGE_GCS_RULES, 'passed', 'GCS rules (no select-all) satisfied');
|
|
117
117
|
|
|
118
118
|
// Stage 3: Dry run with mock BQ and short timeout
|
|
119
|
+
// In test env, use in-process run (no WASM) so integration tests don't require quickjs-emscripten
|
|
120
|
+
const isTestEnv = process.env.NODE_ENV === 'test';
|
|
119
121
|
const sandboxExecutionConfig = {
|
|
120
122
|
...config.execution,
|
|
121
123
|
timeoutMs: SANDBOX_TIMEOUT_MS,
|
|
@@ -124,8 +126,8 @@ async function runVerification(code, name, userId, options = {}) {
|
|
|
124
126
|
memoryLimitBytes: config.execution?.memoryLimitBytes ?? 64 * 1024 * 1024,
|
|
125
127
|
maxStackSizeBytes: config.execution?.maxStackSizeBytes ?? 327680,
|
|
126
128
|
runtime: config.execution?.runtime ?? 'default',
|
|
127
|
-
sandbox:
|
|
128
|
-
useQuickJS: config.execution?.useQuickJS !== false
|
|
129
|
+
sandbox: !isTestEnv,
|
|
130
|
+
useQuickJS: isTestEnv ? false : (config.execution?.useQuickJS !== false)
|
|
129
131
|
};
|
|
130
132
|
|
|
131
133
|
const mockBq = new MockBQAdapter(options.fixtureData || {}, config);
|
|
@@ -154,6 +156,15 @@ async function runVerification(code, name, userId, options = {}) {
|
|
|
154
156
|
usageCollector: null
|
|
155
157
|
});
|
|
156
158
|
|
|
159
|
+
// In test, dynamic recipe only has processSource; provide a callable process so in-process run works without WASM
|
|
160
|
+
if (isTestEnv && entry.recipe.processSource && entry.recipe.process === null) {
|
|
161
|
+
const processSource = entry.recipe.processSource;
|
|
162
|
+
entry.recipe.process = function (context) {
|
|
163
|
+
const fn = new Function('ctx', `return (${processSource})(ctx);`);
|
|
164
|
+
return Promise.resolve(fn(context));
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
157
168
|
try {
|
|
158
169
|
const result = await runner.run(entry.recipe, ctx);
|
|
159
170
|
if (result && result.status === 'completed') {
|
|
@@ -62,14 +62,15 @@ class IntelligentProxyManager {
|
|
|
62
62
|
/**
|
|
63
63
|
* Direct fetch to target URL (no proxy). Used when circuit is open or as fallback after proxy exhaustion.
|
|
64
64
|
* @private
|
|
65
|
+
* @param {object} fetchMeta - { usedDirect: true, attemptCount: number } to attach to response.
|
|
65
66
|
*/
|
|
66
|
-
async _directFetch(targetUrl, options = {}) {
|
|
67
|
+
async _directFetch(targetUrl, options = {}, fetchMeta = { usedDirect: true, attemptCount: 1 }) {
|
|
67
68
|
const method = options.method || 'GET';
|
|
68
69
|
const headers = options.headers || {};
|
|
69
70
|
const fetchOptions = { method, headers };
|
|
70
71
|
if (options.signal) fetchOptions.signal = options.signal;
|
|
71
72
|
const res = await fetch(targetUrl, fetchOptions);
|
|
72
|
-
|
|
73
|
+
const out = {
|
|
73
74
|
ok: res.ok,
|
|
74
75
|
status: res.status,
|
|
75
76
|
headers: res.headers,
|
|
@@ -78,6 +79,8 @@ class IntelligentProxyManager {
|
|
|
78
79
|
isUrlFetchError: false,
|
|
79
80
|
isRateLimitError: false
|
|
80
81
|
};
|
|
82
|
+
out._fetchMeta = fetchMeta;
|
|
83
|
+
return out;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
/**
|
|
@@ -177,11 +180,17 @@ class IntelligentProxyManager {
|
|
|
177
180
|
} catch (_) { /* use in-memory */ }
|
|
178
181
|
}
|
|
179
182
|
if (circuitOpenUntil && Date.now() < circuitOpenUntil) {
|
|
180
|
-
this.logger.log('INFO', '[ProxyManager] Circuit open
|
|
181
|
-
|
|
183
|
+
this.logger.log('INFO', '[ProxyManager] Circuit breaker open: using direct fetch only (1 hour TTL). 3 consecutive request failures.', {
|
|
184
|
+
targetUrl,
|
|
185
|
+
cooldownRemainingMs: circuitOpenUntil - Date.now(),
|
|
186
|
+
ttlMinutes: Math.ceil(this.CIRCUIT_COOLDOWN_MS / 60000),
|
|
187
|
+
shared: !!redis
|
|
188
|
+
});
|
|
189
|
+
return this._directFetch(targetUrl, options, { usedDirect: true, attemptCount: 1 });
|
|
182
190
|
}
|
|
183
191
|
|
|
184
192
|
let lastResponse = null;
|
|
193
|
+
let proxyAttemptCount = 0;
|
|
185
194
|
|
|
186
195
|
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
|
|
187
196
|
let proxy;
|
|
@@ -189,15 +198,20 @@ class IntelligentProxyManager {
|
|
|
189
198
|
proxy = await this._selectProxy();
|
|
190
199
|
} catch (error) {
|
|
191
200
|
this.logger.log('ERROR', '[ProxyManager] fetch failed: All proxies are locked.', { url: targetUrl });
|
|
192
|
-
|
|
201
|
+
const errResp = { ok: false, status: 503, error: { message: error.message }, headers: new Headers() };
|
|
202
|
+
errResp._fetchMeta = { usedDirect: false, attemptCount: 0 };
|
|
203
|
+
return errResp;
|
|
193
204
|
}
|
|
194
205
|
|
|
206
|
+
proxyAttemptCount = attempt;
|
|
195
207
|
const response = await this._fetchViaAppsScript(proxy.url, targetUrl, options);
|
|
196
208
|
lastResponse = response;
|
|
197
209
|
|
|
198
210
|
if (response.ok) {
|
|
199
211
|
this.consecutiveFailures = 0;
|
|
200
212
|
if (redis) { try { await redis.set(CIRCUIT_FAILURES_KEY, '0', { ex: CIRCUIT_FAILURES_TTL_SEC }); } catch (_) {} }
|
|
213
|
+
if (!response._fetchMeta) response._fetchMeta = { usedDirect: false, attemptCount: proxyAttemptCount };
|
|
214
|
+
else response._fetchMeta.attemptCount = proxyAttemptCount;
|
|
201
215
|
return response;
|
|
202
216
|
}
|
|
203
217
|
|
|
@@ -216,7 +230,8 @@ class IntelligentProxyManager {
|
|
|
216
230
|
|
|
217
231
|
this.consecutiveFailures = 0;
|
|
218
232
|
if (redis) { try { await redis.set(CIRCUIT_FAILURES_KEY, '0', { ex: CIRCUIT_FAILURES_TTL_SEC }); } catch (_) {} }
|
|
219
|
-
|
|
233
|
+
if (!lastResponse._fetchMeta) lastResponse._fetchMeta = { usedDirect: false, attemptCount: proxyAttemptCount };
|
|
234
|
+
return lastResponse;
|
|
220
235
|
}
|
|
221
236
|
|
|
222
237
|
let newCount = this.consecutiveFailures + 1;
|
|
@@ -236,10 +251,15 @@ class IntelligentProxyManager {
|
|
|
236
251
|
await redis.del(CIRCUIT_FAILURES_KEY);
|
|
237
252
|
} catch (_) {}
|
|
238
253
|
}
|
|
239
|
-
this.logger.log('WARN', '[ProxyManager] Circuit breaker open: 3 consecutive
|
|
240
|
-
|
|
254
|
+
this.logger.log('WARN', '[ProxyManager] Circuit breaker open: 3 consecutive request failures overall. Using direct fetch only for 1 hour TTL.', {
|
|
255
|
+
cooldownMs: this.CIRCUIT_COOLDOWN_MS,
|
|
256
|
+
ttlMinutes: Math.ceil(this.CIRCUIT_COOLDOWN_MS / 60000),
|
|
257
|
+
shared: !!redis
|
|
258
|
+
});
|
|
259
|
+
return this._directFetch(targetUrl, options, { usedDirect: true, attemptCount: proxyAttemptCount + 1 });
|
|
241
260
|
}
|
|
242
261
|
this.logger.log('ERROR', `[ProxyManager] Request failed after ${this.MAX_RETRIES} proxy attempts.`, { url: targetUrl, lastStatus: lastResponse?.status });
|
|
262
|
+
if (lastResponse && !lastResponse._fetchMeta) lastResponse._fetchMeta = { usedDirect: false, attemptCount: proxyAttemptCount };
|
|
243
263
|
return lastResponse;
|
|
244
264
|
}
|
|
245
265
|
|
|
@@ -9,6 +9,10 @@ function parseIncomingPayload(message) {
|
|
|
9
9
|
return (typeof rawData === 'string') ? JSON.parse(rawData) : rawData;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
function defaultFetchMeta() {
|
|
13
|
+
return { usedDirect: true, attemptCount: 1 };
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
async function fetchJsonWithHeader({ url, dependencies }) {
|
|
13
17
|
const { headerManager, proxyManager, adapters, logger } = dependencies;
|
|
14
18
|
const selectedHeader = await headerManager.selectHeader();
|
|
@@ -20,8 +24,12 @@ async function fetchJsonWithHeader({ url, dependencies }) {
|
|
|
20
24
|
logger
|
|
21
25
|
});
|
|
22
26
|
|
|
27
|
+
const fetchMeta = response?._fetchMeta || defaultFetchMeta();
|
|
28
|
+
|
|
23
29
|
if (!response?.ok) {
|
|
24
|
-
|
|
30
|
+
const err = new Error(`[TaskEngineV1] Fetch failed: ${response?.status || 'unknown'} for ${url}`);
|
|
31
|
+
err.fetchMeta = fetchMeta;
|
|
32
|
+
throw err;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
const contentType = (response.headers?.get?.('content-type') || '').toLowerCase();
|
|
@@ -29,20 +37,15 @@ async function fetchJsonWithHeader({ url, dependencies }) {
|
|
|
29
37
|
const looksLikeHtml = contentType.includes('text/html') || /^\s*</.test(rawBody);
|
|
30
38
|
|
|
31
39
|
if (looksLikeHtml) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
contentType,
|
|
36
|
-
bodyLength: rawBody.length,
|
|
37
|
-
bodySnippet: snippet
|
|
38
|
-
});
|
|
39
|
-
throw new Error(`[TaskEngineV1] Expected JSON but received HTML (${rawBody.length} chars) for ${url}`);
|
|
40
|
+
const err = new Error(`[TaskEngineV1] Expected JSON but received HTML (${rawBody.length} chars) for ${url}`);
|
|
41
|
+
err.fetchMeta = fetchMeta;
|
|
42
|
+
throw err; // End-result failure will be logged by caller; no per-attempt log here.
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
const data = JSON.parse(rawBody);
|
|
43
46
|
if (url.includes('/portfolios')) dependencies.contracts?.assertPortfolioResponse(data);
|
|
44
47
|
if (url.includes('/history/public/credit/flat')) dependencies.contracts?.assertHistoryResponse(data);
|
|
45
|
-
return data;
|
|
48
|
+
return { data, fetchMeta };
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
async function storeUserSnapshot({ dependencies, collection, cid, date, payload, type }) {
|
|
@@ -74,38 +77,55 @@ async function handleUserUpdate(taskData, configObj, dependencies, isPopularInve
|
|
|
74
77
|
const historyUrl = `${historyBase}?StartTime=${encodeURIComponent(startTime)}&PageNumber=1&ItemsPerPage=30000&PublicHistoryPortfolioFilter=&CID=${cid}&client_request_id=${clientRequestId}`;
|
|
75
78
|
|
|
76
79
|
const userType = isPopularInvestor ? 'popular_investor' : 'signed_in_user';
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
} catch (err) {
|
|
86
|
-
logger.log('WARN', '[TaskEngineV1] Portfolio fetch failed', { cid, error: err.message, url: portfolioUrl });
|
|
80
|
+
const collections = isPopularInvestor
|
|
81
|
+
? { portfolio: config.FIRESTORE_COLLECTION_PI_PORTFOLIOS_OVERALL || 'pi_portfolios_overall', history: config.FIRESTORE_COLLECTION_PI_HISTORY || 'pi_trade_history' }
|
|
82
|
+
: { portfolio: config.FIRESTORE_COLLECTION_SIGNED_IN_USER_PORTFOLIOS || 'signed_in_users', history: config.FIRESTORE_COLLECTION_SIGNED_IN_HISTORY || 'signed_in_user_history' };
|
|
83
|
+
|
|
84
|
+
function formatFetchMeta(meta) {
|
|
85
|
+
const n = (meta && meta.attemptCount) != null ? meta.attemptCount : 1;
|
|
86
|
+
const mode = (meta && meta.usedDirect) ? 'direct' : 'proxy';
|
|
87
|
+
return { n, mode };
|
|
87
88
|
}
|
|
89
|
+
|
|
90
|
+
// Portfolio: one end-result log per task type / user type / user ID
|
|
91
|
+
let portfolioData = null;
|
|
88
92
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
+
const result = await fetchJsonWithHeader({ url: portfolioUrl, dependencies });
|
|
94
|
+
portfolioData = result.data;
|
|
95
|
+
const { n, mode } = formatFetchMeta(result.fetchMeta);
|
|
96
|
+
logger.log('INFO', `[TaskEngineV1] Success to fetch portfolio for ${userType} for ID ${cid} after ${n} attempts with ${mode}`, {
|
|
97
|
+
taskType: 'portfolio', userType, userId: cid, attemptCount: n, mode
|
|
98
|
+
});
|
|
93
99
|
} catch (err) {
|
|
94
|
-
|
|
100
|
+
const { n, mode } = formatFetchMeta(err.fetchMeta);
|
|
101
|
+
logger.log('WARN', `[TaskEngineV1] Failure to fetch portfolio for ${userType} for ID ${cid} after ${n} attempts with ${mode}`, {
|
|
102
|
+
taskType: 'portfolio', userType, userId: cid, attemptCount: n, mode, error: err.message
|
|
103
|
+
});
|
|
95
104
|
}
|
|
96
|
-
|
|
97
|
-
const collections = isPopularInvestor
|
|
98
|
-
? { portfolio: config.FIRESTORE_COLLECTION_PI_PORTFOLIOS_OVERALL || 'pi_portfolios_overall', history: config.FIRESTORE_COLLECTION_PI_HISTORY || 'pi_trade_history' }
|
|
99
|
-
: { portfolio: config.FIRESTORE_COLLECTION_SIGNED_IN_USER_PORTFOLIOS || 'signed_in_users', history: config.FIRESTORE_COLLECTION_SIGNED_IN_HISTORY || 'signed_in_user_history' };
|
|
100
105
|
if (portfolioData) {
|
|
101
106
|
await storeUserSnapshot({ dependencies, collection: collections.portfolio, cid, date, payload: portfolioData, type: 'portfolio' });
|
|
102
|
-
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// History: one end-result log per task type / user type / user ID
|
|
110
|
+
let historyData = null;
|
|
111
|
+
try {
|
|
112
|
+
const result = await fetchJsonWithHeader({ url: historyUrl, dependencies });
|
|
113
|
+
historyData = result.data;
|
|
114
|
+
const { n, mode } = formatFetchMeta(result.fetchMeta);
|
|
115
|
+
logger.log('INFO', `[TaskEngineV1] Success to fetch history for ${userType} for ID ${cid} after ${n} attempts with ${mode}`, {
|
|
116
|
+
taskType: 'history', userType, userId: cid, attemptCount: n, mode
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const { n, mode } = formatFetchMeta(err.fetchMeta);
|
|
120
|
+
logger.log('WARN', `[TaskEngineV1] Failure to fetch history for ${userType} for ID ${cid} after ${n} attempts with ${mode}`, {
|
|
121
|
+
taskType: 'history', userType, userId: cid, attemptCount: n, mode, error: err.message
|
|
122
|
+
});
|
|
103
123
|
}
|
|
104
124
|
if (historyData) {
|
|
105
125
|
await storeUserSnapshot({ dependencies, collection: collections.history, cid, date, payload: historyData, type: 'history' });
|
|
106
|
-
logger.log('INFO', '[TaskEngineV1] Stored trade history', { cid, username, userType, dataType: 'history', collection: collections.history, date });
|
|
107
126
|
}
|
|
108
127
|
|
|
128
|
+
// Social: one end-result log per task type / user type / user ID (no fetchMeta from inner call; use defaults)
|
|
109
129
|
if (taskData.data?.includeSocial !== false) {
|
|
110
130
|
try {
|
|
111
131
|
await handleSocialTaskPayload({
|
|
@@ -113,10 +133,13 @@ async function handleUserUpdate(taskData, configObj, dependencies, isPopularInve
|
|
|
113
133
|
id: String(cid),
|
|
114
134
|
username
|
|
115
135
|
}, configObj.social || configObj.taskEngine?.social || {}, dependencies);
|
|
116
|
-
logger.log('INFO',
|
|
136
|
+
logger.log('INFO', `[TaskEngineV1] Success to fetch social for ${userType} for ID ${cid} after 1 attempts with proxy`, {
|
|
137
|
+
taskType: 'social', userType, userId: cid, attemptCount: 1, mode: 'proxy'
|
|
138
|
+
});
|
|
117
139
|
} catch (socialErr) {
|
|
118
|
-
|
|
119
|
-
|
|
140
|
+
logger.log('WARN', `[TaskEngineV1] Failure to fetch social for ${userType} for ID ${cid} after 1 attempts with proxy`, {
|
|
141
|
+
taskType: 'social', userType, userId: cid, attemptCount: 1, mode: 'proxy', error: socialErr.message
|
|
142
|
+
});
|
|
120
143
|
}
|
|
121
144
|
}
|
|
122
145
|
}
|
|
@@ -29,6 +29,7 @@ async function fetchWithProxyFallback({
|
|
|
29
29
|
throw new Error('[AppscriptClient] No fetch implementation available (pass fetchImpl or use Node 18+)');
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
let proxyMeta = null;
|
|
32
33
|
if (proxyManager && typeof proxyManager.fetch === 'function') {
|
|
33
34
|
const timeoutMs = options.timeout ?? DEFAULT_PROXY_TIMEOUT_MS;
|
|
34
35
|
try {
|
|
@@ -39,23 +40,23 @@ async function fetchWithProxyFallback({
|
|
|
39
40
|
if (response?.ok) {
|
|
40
41
|
return response;
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
status: response?.status,
|
|
45
|
-
url
|
|
46
|
-
});
|
|
47
|
-
}
|
|
43
|
+
proxyMeta = response?._fetchMeta || null;
|
|
44
|
+
// No per-attempt log: caller logs end-result only (e.g. task engine).
|
|
48
45
|
} catch (proxyErr) {
|
|
49
|
-
|
|
50
|
-
logger.log('WARN', '[AppscriptClient] Proxy fetch failed, using direct fetch fallback', {
|
|
51
|
-
error: proxyErr?.message,
|
|
52
|
-
url
|
|
53
|
-
});
|
|
54
|
-
}
|
|
46
|
+
// No per-attempt log: caller logs end-result only (e.g. task engine).
|
|
55
47
|
}
|
|
56
48
|
}
|
|
57
49
|
|
|
58
|
-
|
|
50
|
+
const directResponse = await impl(url, options);
|
|
51
|
+
if (directResponse && typeof directResponse === 'object' && !directResponse._fetchMeta) {
|
|
52
|
+
// If we had proxy attempts then fell back to direct, proxyMeta may have attemptCount; add 1 for the direct attempt.
|
|
53
|
+
const usedDirect = true;
|
|
54
|
+
const attemptCount = proxyMeta
|
|
55
|
+
? (proxyMeta.attemptCount || 0) + 1
|
|
56
|
+
: 1;
|
|
57
|
+
directResponse._fetchMeta = { usedDirect, attemptCount };
|
|
58
|
+
}
|
|
59
|
+
return directResponse;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
async function fetchJsonWithProxyFallback(params) {
|