bulltrackers-module 1.0.300 → 1.0.302
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/computation-system/executors/StandardExecutor.js +1 -1
- package/functions/computation-system/helpers/computation_dispatcher.js +32 -33
- package/functions/computation-system/helpers/computation_worker.js +47 -16
- package/functions/computation-system/utils/data_loader.js +3 -3
- package/functions/computation-system/workflows/bulltrackers_pipeline.yaml +4 -3
- package/package.json +1 -1
|
@@ -114,7 +114,7 @@ class StandardExecutor {
|
|
|
114
114
|
|
|
115
115
|
usersSinceLastFlush += chunkSize;
|
|
116
116
|
const heapStats = v8.getHeapStatistics();
|
|
117
|
-
if (usersSinceLastFlush >=
|
|
117
|
+
if (usersSinceLastFlush >= 500 || (heapStats.used_heap_size / heapStats.heap_size_limit) > 0.70) {
|
|
118
118
|
const flushResult = await StandardExecutor.flushBuffer(state, dateStr, passName, config, deps, shardIndexMap, executionStats, 'INTERMEDIATE', true, !hasFlushed);
|
|
119
119
|
hasFlushed = true;
|
|
120
120
|
StandardExecutor.mergeReports(aggregatedSuccess, aggregatedFailures, flushResult);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FILENAME: computation-system/helpers/computation_dispatcher.js
|
|
3
3
|
* PURPOSE: "Smart Dispatcher" - Analyzes state, initializes Run Counters, and dispatches tasks.
|
|
4
|
-
* UPDATED:
|
|
4
|
+
* UPDATED: Optimized Forensics - Only runs on Retries (Attempt > 1) or Single-Day runs.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { getExpectedDateStrings, getEarliestDataDates, normalizeName, DEFINITIVE_EARLIEST_DATES } = require('../utils/utils.js');
|
|
@@ -59,7 +59,7 @@ async function checkCrashForensics(db, date, pass, computationName) {
|
|
|
59
59
|
* @param {Object} config - System config (Injected with topics)
|
|
60
60
|
* @param {Object} dependencies - { db, logger, ... }
|
|
61
61
|
* @param {Array} computationManifest - List of calculations
|
|
62
|
-
* @param {Object} reqBody - (Optional) HTTP Body containing 'callbackUrl' and '
|
|
62
|
+
* @param {Object} reqBody - (Optional) HTTP Body containing 'callbackUrl', 'date', and 'attempt'
|
|
63
63
|
*/
|
|
64
64
|
async function dispatchComputationPass(config, dependencies, computationManifest, reqBody = {}) {
|
|
65
65
|
const { logger, db } = dependencies;
|
|
@@ -67,9 +67,10 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
67
67
|
const passToRun = String(config.COMPUTATION_PASS_TO_RUN);
|
|
68
68
|
|
|
69
69
|
// Extract Date and Callback from request body (pushed by Workflow)
|
|
70
|
-
// NOTE: 'dateStr' acts as the "Target Date" (Ceiling), usually T-1.
|
|
71
70
|
const dateStr = reqBody.date || config.date;
|
|
72
71
|
const callbackUrl = reqBody.callbackUrl || null;
|
|
72
|
+
// [NEW] Get Attempt Count (Default to 1 if missing)
|
|
73
|
+
const attemptCount = reqBody.attempt ? parseInt(reqBody.attempt) : 1;
|
|
73
74
|
|
|
74
75
|
if (!passToRun) { return logger.log('ERROR', '[Dispatcher] No pass defined (COMPUTATION_PASS_TO_RUN). Aborting.'); }
|
|
75
76
|
if (!dateStr) { return logger.log('ERROR', '[Dispatcher] No date defined. Aborting.'); }
|
|
@@ -81,7 +82,7 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
81
82
|
|
|
82
83
|
if (!calcsInThisPass.length) { return logger.log('WARN', `[Dispatcher] No calcs for Pass ${passToRun}. Exiting.`); }
|
|
83
84
|
|
|
84
|
-
logger.log('INFO', `🚀 [Dispatcher] Smart-Dispatching PASS ${passToRun} (Target: ${dateStr})`);
|
|
85
|
+
logger.log('INFO', `🚀 [Dispatcher] Smart-Dispatching PASS ${passToRun} (Target: ${dateStr}) [Attempt ${attemptCount}]`);
|
|
85
86
|
|
|
86
87
|
// -- DATE ANALYSIS LOGIC (FIXED: RANGE SCAN) --
|
|
87
88
|
|
|
@@ -93,7 +94,7 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
93
94
|
// 2. Generate the full range of dates to check
|
|
94
95
|
let allExpectedDates = getExpectedDateStrings(startDate, endDate);
|
|
95
96
|
|
|
96
|
-
// Safety fallback
|
|
97
|
+
// Safety fallback
|
|
97
98
|
if (!allExpectedDates || allExpectedDates.length === 0) {
|
|
98
99
|
logger.log('WARN', `[Dispatcher] Date range calculation returned empty (Start: ${startDate.toISOString()} -> End: ${endDate.toISOString()}). Defaulting to single target date.`);
|
|
99
100
|
allExpectedDates = [dateStr];
|
|
@@ -104,9 +105,23 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
104
105
|
const manifestMap = new Map(computationManifest.map(c => [normalizeName(c.name), c]));
|
|
105
106
|
const tasksToDispatch = [];
|
|
106
107
|
|
|
107
|
-
// [FIX] Separate concurrency limits
|
|
108
|
-
const dateLimit = pLimit(20);
|
|
109
|
-
const forensicsLimit = pLimit(50);
|
|
108
|
+
// [FIX] Separate concurrency limits
|
|
109
|
+
const dateLimit = pLimit(20);
|
|
110
|
+
const forensicsLimit = pLimit(50);
|
|
111
|
+
|
|
112
|
+
// [NEW] SMART FORENSICS TRIGGER
|
|
113
|
+
// 1. If scanning > 5 days (Backfill), SKIP (Too expensive).
|
|
114
|
+
// 2. If attempt == 1 (First Run), SKIP (Assume Standard).
|
|
115
|
+
// 3. Only run if Attempt > 1 AND Small Batch.
|
|
116
|
+
const isBulkBackfill = allExpectedDates.length > 5;
|
|
117
|
+
const shouldRunForensics = (attemptCount > 1) && !isBulkBackfill;
|
|
118
|
+
|
|
119
|
+
if (!shouldRunForensics) {
|
|
120
|
+
if (isBulkBackfill) logger.log('INFO', `[Dispatcher] ⏩ Bulk Backfill (${allExpectedDates.length} days). Skipping Forensics.`);
|
|
121
|
+
else logger.log('INFO', `[Dispatcher] ⏩ First Attempt. Skipping Forensics (Defaulting to Standard).`);
|
|
122
|
+
} else {
|
|
123
|
+
logger.log('WARN', `[Dispatcher] 🕵️♀️ Retry Detected (Attempt ${attemptCount}). Enabling Forensic Crash Analysis.`);
|
|
124
|
+
}
|
|
110
125
|
|
|
111
126
|
const analysisPromises = allExpectedDates.map(d => dateLimit(async () => {
|
|
112
127
|
try {
|
|
@@ -120,8 +135,6 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
120
135
|
const prevDate = new Date(d + 'T00:00:00Z');
|
|
121
136
|
prevDate.setUTCDate(prevDate.getUTCDate() - 1);
|
|
122
137
|
prevDateStr = prevDate.toISOString().slice(0, 10);
|
|
123
|
-
|
|
124
|
-
// Only fetch previous status if it's within valid range
|
|
125
138
|
if (prevDate >= DEFINITIVE_EARLIEST_DATES.absoluteEarliest) {
|
|
126
139
|
fetchPromises.push(fetchComputationStatus(prevDateStr, config, dependencies));
|
|
127
140
|
}
|
|
@@ -138,7 +151,6 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
138
151
|
|
|
139
152
|
const report = analyzeDateExecution(d, calcsInThisPass, rootDataStatus, dailyStatus, manifestMap, prevDailyStatus);
|
|
140
153
|
|
|
141
|
-
// Handle Status Updates (Impossible / Blocked)
|
|
142
154
|
const statusUpdates = {};
|
|
143
155
|
report.impossible.forEach(item => {
|
|
144
156
|
if (dailyStatus[item.name]?.hash !== STATUS_IMPOSSIBLE) {
|
|
@@ -159,12 +171,14 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
159
171
|
|
|
160
172
|
const validToRun = [...report.runnable, ...report.reRuns];
|
|
161
173
|
|
|
162
|
-
// [FIX] Use separate 'forensicsLimit' here to avoid deadlock with 'dateLimit'
|
|
163
174
|
await Promise.all(validToRun.map(item => forensicsLimit(async () => {
|
|
164
175
|
const compName = normalizeName(item.name);
|
|
165
176
|
|
|
166
|
-
//
|
|
167
|
-
|
|
177
|
+
// [UPDATED] Conditional Forensics
|
|
178
|
+
let requiredResource = 'standard';
|
|
179
|
+
if (shouldRunForensics) {
|
|
180
|
+
requiredResource = await checkCrashForensics(db, d, passToRun, compName);
|
|
181
|
+
}
|
|
168
182
|
|
|
169
183
|
const uniqueDispatchId = crypto.randomUUID();
|
|
170
184
|
tasksToDispatch.push({
|
|
@@ -178,11 +192,10 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
178
192
|
triggerReason: item.reason || "Unknown",
|
|
179
193
|
dependencyResultHashes: item.dependencyResultHashes || {},
|
|
180
194
|
timestamp: Date.now(),
|
|
181
|
-
resources: requiredResource
|
|
195
|
+
resources: requiredResource
|
|
182
196
|
});
|
|
183
197
|
})));
|
|
184
198
|
|
|
185
|
-
// [PROGRESS LOG] This should now fire correctly
|
|
186
199
|
logger.log('INFO', `[Dispatcher] Analyzed ${d}: ${validToRun.length} tasks (Cumulative: ${tasksToDispatch.length})`);
|
|
187
200
|
|
|
188
201
|
} catch (e) {
|
|
@@ -196,14 +209,13 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
196
209
|
|
|
197
210
|
if (tasksToDispatch.length > 0) {
|
|
198
211
|
|
|
199
|
-
// 1. Initialize Shared State Document (The Counter)
|
|
200
212
|
const runId = crypto.randomUUID();
|
|
201
213
|
const metaStatePath = `computation_runs/${runId}`;
|
|
202
214
|
|
|
203
215
|
if (callbackUrl) {
|
|
204
216
|
await db.doc(metaStatePath).set({
|
|
205
217
|
createdAt: new Date(),
|
|
206
|
-
date: dateStr,
|
|
218
|
+
date: dateStr,
|
|
207
219
|
pass: passToRun,
|
|
208
220
|
totalTasks: tasksToDispatch.length,
|
|
209
221
|
remainingTasks: tasksToDispatch.length,
|
|
@@ -213,29 +225,23 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
213
225
|
logger.log('INFO', `[Dispatcher] 🏁 Run State Initialized: ${runId}. Tasks: ${tasksToDispatch.length}`);
|
|
214
226
|
}
|
|
215
227
|
|
|
216
|
-
// 2. Attach Run Metadata
|
|
217
228
|
tasksToDispatch.forEach(task => {
|
|
218
229
|
task.runId = runId;
|
|
219
230
|
task.metaStatePath = callbackUrl ? metaStatePath : null;
|
|
220
231
|
});
|
|
221
232
|
|
|
222
|
-
// 3. Create Audit Ledger Entries
|
|
223
233
|
const finalDispatched = [];
|
|
224
|
-
const txnLimit = pLimit(50);
|
|
234
|
+
const txnLimit = pLimit(50);
|
|
225
235
|
|
|
226
236
|
const txnPromises = tasksToDispatch.map(task => txnLimit(async () => {
|
|
227
237
|
const ledgerRef = db.collection(`computation_audit_ledger/${task.date}/passes/${task.pass}/tasks`).doc(task.computation);
|
|
228
|
-
|
|
229
238
|
try {
|
|
230
239
|
await db.runTransaction(async (t) => {
|
|
231
240
|
const doc = await t.get(ledgerRef);
|
|
232
|
-
|
|
233
241
|
if (doc.exists) {
|
|
234
242
|
const data = doc.data();
|
|
235
|
-
// Strict Idempotency: If completed, don't run again.
|
|
236
243
|
if (data.status === 'COMPLETED') return false;
|
|
237
244
|
}
|
|
238
|
-
|
|
239
245
|
t.set(ledgerRef, {
|
|
240
246
|
status: 'PENDING',
|
|
241
247
|
dispatchId: task.dispatchId,
|
|
@@ -245,15 +251,12 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
245
251
|
createdAt: new Date(),
|
|
246
252
|
dispatcherHash: currentManifestHash,
|
|
247
253
|
triggerReason: task.triggerReason,
|
|
248
|
-
resources: task.resources,
|
|
254
|
+
resources: task.resources,
|
|
249
255
|
retries: 0
|
|
250
256
|
}, { merge: true });
|
|
251
|
-
|
|
252
257
|
return true;
|
|
253
258
|
});
|
|
254
|
-
|
|
255
259
|
finalDispatched.push(task);
|
|
256
|
-
|
|
257
260
|
} catch (txnErr) {
|
|
258
261
|
logger.log('WARN', `[Dispatcher] Transaction failed for ${task.computation}: ${txnErr.message}`);
|
|
259
262
|
}
|
|
@@ -261,13 +264,10 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
261
264
|
|
|
262
265
|
await Promise.all(txnPromises);
|
|
263
266
|
|
|
264
|
-
// 4. Publish to Pub/Sub (Segregated by Resources)
|
|
265
267
|
if (finalDispatched.length > 0) {
|
|
266
|
-
|
|
267
268
|
const standardTasks = finalDispatched.filter(t => t.resources !== 'high-mem');
|
|
268
269
|
const highMemTasks = finalDispatched.filter(t => t.resources === 'high-mem');
|
|
269
270
|
|
|
270
|
-
// Publish Standard
|
|
271
271
|
if (standardTasks.length > 0) {
|
|
272
272
|
logger.log('INFO', `[Dispatcher] ✅ Publishing ${standardTasks.length} Standard tasks...`);
|
|
273
273
|
await pubsubUtils.batchPublishTasks(dependencies, {
|
|
@@ -278,7 +278,6 @@ async function dispatchComputationPass(config, dependencies, computationManifest
|
|
|
278
278
|
});
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
// Publish High-Mem
|
|
282
281
|
if (highMemTasks.length > 0) {
|
|
283
282
|
logger.log('INFO', `[Dispatcher] 🏋️♀️ Publishing ${highMemTasks.length} tasks to HIGH-MEM infrastructure.`);
|
|
284
283
|
await pubsubUtils.batchPublishTasks(dependencies, {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FILENAME: computation-system/helpers/computation_worker.js
|
|
3
3
|
* PURPOSE: Consumes tasks, executes logic, and signals Workflow upon Batch Completion.
|
|
4
|
-
* UPDATED:
|
|
5
|
-
* UPDATED: Implements Peak Memory Heartbeat and Resource Tier tracking.
|
|
4
|
+
* UPDATED: Added "Contention-Aware Retry" for the Batch Counter to fix ABORTED errors.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
const { executeDispatchTask } = require('../WorkflowOrchestrator.js');
|
|
@@ -99,24 +98,56 @@ async function triggerWorkflowCallback(url, status, logger) {
|
|
|
99
98
|
}
|
|
100
99
|
|
|
101
100
|
/**
|
|
102
|
-
* Helper: Decrements 'remainingTasks' in Firestore.
|
|
101
|
+
* [UPDATED] Helper: Decrements 'remainingTasks' in Firestore.
|
|
102
|
+
* NOW INCLUDES CONTENTION RETRY LOGIC (The "Sentinel" Fix)
|
|
103
103
|
*/
|
|
104
104
|
async function decrementAndCheck(db, metaStatePath, logger) {
|
|
105
105
|
if (!metaStatePath) return null;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
106
|
+
|
|
107
|
+
const MAX_CONTENTION_RETRIES = 10;
|
|
108
|
+
let attempt = 0;
|
|
109
|
+
|
|
110
|
+
while (attempt < MAX_CONTENTION_RETRIES) {
|
|
111
|
+
try {
|
|
112
|
+
const result = await db.runTransaction(async (t) => {
|
|
113
|
+
const ref = db.doc(metaStatePath);
|
|
114
|
+
const doc = await t.get(ref);
|
|
115
|
+
if (!doc.exists) return null;
|
|
116
|
+
|
|
117
|
+
const data = doc.data();
|
|
118
|
+
// Safety: Don't decrement below zero
|
|
119
|
+
const currentRemaining = data.remainingTasks || 0;
|
|
120
|
+
if (currentRemaining <= 0) return { remaining: 0, callbackUrl: data.callbackUrl };
|
|
121
|
+
|
|
122
|
+
const newRemaining = currentRemaining - 1;
|
|
123
|
+
t.update(ref, { remainingTasks: newRemaining, lastUpdated: new Date() });
|
|
124
|
+
|
|
125
|
+
return { remaining: newRemaining, callbackUrl: data.callbackUrl };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Success! Check if we are the "Sentinel" (the last one)
|
|
129
|
+
if (result && result.remaining <= 0) return result.callbackUrl;
|
|
130
|
+
return null; // We decremented successfully, but weren't the last one.
|
|
131
|
+
|
|
132
|
+
} catch (e) {
|
|
133
|
+
// Check if it's a contention error (ABORTED/10 or DEADLINE_EXCEEDED/4)
|
|
134
|
+
const isContention = e.code === 10 || e.code === 4 || (e.message && e.message.includes('contention'));
|
|
135
|
+
|
|
136
|
+
if (isContention) {
|
|
137
|
+
attempt++;
|
|
138
|
+
// JITTER: Random delay between 50ms and 500ms to desynchronize the herd
|
|
139
|
+
const delay = Math.floor(Math.random() * 450) + 50;
|
|
140
|
+
logger.log('WARN', `[Worker] Batch counter contention (Attempt ${attempt}/${MAX_CONTENTION_RETRIES}). Retrying in ${delay}ms...`);
|
|
141
|
+
await new Promise(r => setTimeout(r, delay));
|
|
142
|
+
} else {
|
|
143
|
+
// Fatal error (permission, etc)
|
|
144
|
+
logger.log('ERROR', `[Worker] Fatal error decrementing batch counter: ${e.message}`);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
119
148
|
}
|
|
149
|
+
|
|
150
|
+
logger.log('ERROR', `[Worker] Failed to decrement batch counter after ${MAX_CONTENTION_RETRIES} attempts. The count will be inaccurate.`);
|
|
120
151
|
return null;
|
|
121
152
|
}
|
|
122
153
|
|
|
@@ -48,7 +48,7 @@ async function loadDataByRefs(config, deps, refs) {
|
|
|
48
48
|
const { withRetry } = calculationUtils;
|
|
49
49
|
if (!refs || !refs.length) return {};
|
|
50
50
|
const mergedPortfolios = {};
|
|
51
|
-
const batchSize = config.partRefBatchSize ||
|
|
51
|
+
const batchSize = config.partRefBatchSize || 10;
|
|
52
52
|
for (let i = 0; i < refs.length; i += batchSize) {
|
|
53
53
|
const batchRefs = refs.slice(i, i + batchSize);
|
|
54
54
|
const snapshots = await withRetry(() => db.getAll(...batchRefs), `getAll(batch ${Math.floor(i / batchSize)})`);
|
|
@@ -145,7 +145,7 @@ async function* streamPortfolioData(config, deps, dateString, providedRefs = nul
|
|
|
145
145
|
const { logger } = deps;
|
|
146
146
|
const refs = providedRefs || (await getPortfolioPartRefs(config, deps, dateString));
|
|
147
147
|
if (refs.length === 0) { logger.log('WARN', `[streamPortfolioData] No portfolio refs found for ${dateString}. Stream is empty.`); return; }
|
|
148
|
-
const batchSize = config.partRefBatchSize ||
|
|
148
|
+
const batchSize = config.partRefBatchSize || 10;
|
|
149
149
|
logger.log('INFO', `[streamPortfolioData] Streaming ${refs.length} portfolio parts in chunks of ${batchSize}...`);
|
|
150
150
|
for (let i = 0; i < refs.length; i += batchSize) {
|
|
151
151
|
const batchRefs = refs.slice(i, i + batchSize);
|
|
@@ -160,7 +160,7 @@ async function* streamHistoryData(config, deps, dateString, providedRefs = null)
|
|
|
160
160
|
const { logger } = deps;
|
|
161
161
|
const refs = providedRefs || (await getHistoryPartRefs(config, deps, dateString));
|
|
162
162
|
if (refs.length === 0) { logger.log('WARN', `[streamHistoryData] No history refs found for ${dateString}. Stream is empty.`); return; }
|
|
163
|
-
const batchSize = config.partRefBatchSize ||
|
|
163
|
+
const batchSize = config.partRefBatchSize || 10;
|
|
164
164
|
logger.log('INFO', `[streamHistoryData] Streaming ${refs.length} history parts in chunks of ${batchSize}...`);
|
|
165
165
|
for (let i = 0; i < refs.length; i += batchSize) {
|
|
166
166
|
const batchRefs = refs.slice(i, i + batchSize);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Cloud Workflows Definition for BullTrackers Computation Pipeline
|
|
2
2
|
# Orchestrates 5 sequential passes using Event-Driven Callbacks (Zero Polling).
|
|
3
|
-
#
|
|
3
|
+
# UPDATED: Passes 'attempt' count to Dispatcher to trigger Smart Forensics on retries.
|
|
4
4
|
|
|
5
5
|
main:
|
|
6
6
|
params: [input]
|
|
@@ -16,7 +16,7 @@ main:
|
|
|
16
16
|
- yesterday_str: ${text.substring(time.format(yesterday_timestamp), 0, 10)}
|
|
17
17
|
- date_to_run: ${default(map.get(input, "date"), yesterday_str)}
|
|
18
18
|
|
|
19
|
-
# Configuration Variables
|
|
19
|
+
# Configuration Variables
|
|
20
20
|
- passes: ["1", "2", "3", "4", "5"]
|
|
21
21
|
- max_retries: 3
|
|
22
22
|
|
|
@@ -71,6 +71,7 @@ main:
|
|
|
71
71
|
body:
|
|
72
72
|
date: ${date_to_run}
|
|
73
73
|
callbackUrl: ${callback_url}
|
|
74
|
+
attempt: ${attempt_count} # [UPDATED] Critical for Smart Forensics logic
|
|
74
75
|
auth:
|
|
75
76
|
type: OIDC
|
|
76
77
|
timeout: 1800 # 30 mins max for dispatch analysis
|
|
@@ -96,7 +97,7 @@ main:
|
|
|
96
97
|
call: events.await_callback
|
|
97
98
|
args:
|
|
98
99
|
callback: ${callback_details}
|
|
99
|
-
timeout: 10800 #
|
|
100
|
+
timeout: 10800 # Reduced to 3h to fail faster if stalled
|
|
100
101
|
result: callback_request
|
|
101
102
|
|
|
102
103
|
# 5. PROCESS SIGNAL
|