bulltrackers-module 1.0.342 → 1.0.344
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,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FILENAME: computation-system/helpers/computation_dispatcher.js
|
|
3
3
|
* PURPOSE: Sequential Cursor-Based Dispatcher.
|
|
4
|
+
* UPDATED: Implemented "Fast-Forward" Scanning Loop to skip empty dates efficiently.
|
|
4
5
|
* UPDATED: Enforces Strict One-Shot Policy (Standard -> HighMem -> Dead Letter).
|
|
5
|
-
* UPDATED: Prevents infinite loops by permanently ignoring deterministic failures.
|
|
6
6
|
* UPDATED: Generates Google Cloud Trace Context (traceId/spanId) for end-to-end monitoring.
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -12,9 +12,9 @@ const { PubSubUtils } = require('../../core/utils/pubsub_utils');
|
|
|
12
12
|
const { fetchComputationStatus } = require('../persistence/StatusRepository');
|
|
13
13
|
const { checkRootDataAvailability } = require('../data/AvailabilityChecker');
|
|
14
14
|
const crypto = require('crypto');
|
|
15
|
-
const monConfig = require('../config/monitoring_config');
|
|
15
|
+
const monConfig = require('../config/monitoring_config');
|
|
16
16
|
|
|
17
|
-
const OOM_THRESHOLD_MB = 1500;
|
|
17
|
+
const OOM_THRESHOLD_MB = 1500; // Unused
|
|
18
18
|
const BASE_SECONDS_PER_WEIGHT_UNIT = 3;
|
|
19
19
|
const SESSION_CACHE_DURATION_MS = 1000 * 60 * 30; // 30 Minutes
|
|
20
20
|
const STALE_LOCK_THRESHOLD_MS = 1000 * 60 * 15;
|
|
@@ -281,7 +281,6 @@ async function handleSweepDispatch(config, dependencies, computationManifest, re
|
|
|
281
281
|
const currentDispatchId = crypto.randomUUID();
|
|
282
282
|
|
|
283
283
|
const tasksPayload = validTasks.map(t => {
|
|
284
|
-
// [NEW] Generate Eyeball Trace
|
|
285
284
|
const traceId = crypto.randomBytes(16).toString('hex');
|
|
286
285
|
const spanId = crypto.randomBytes(8).toString('hex');
|
|
287
286
|
|
|
@@ -294,7 +293,6 @@ async function handleSweepDispatch(config, dependencies, computationManifest, re
|
|
|
294
293
|
dispatchId: currentDispatchId,
|
|
295
294
|
triggerReason: 'SWEEP_RECOVERY',
|
|
296
295
|
resources: 'high-mem', // FORCE
|
|
297
|
-
// [NEW] Attach the eyeball
|
|
298
296
|
traceContext: {
|
|
299
297
|
traceId: traceId,
|
|
300
298
|
spanId: spanId,
|
|
@@ -315,7 +313,7 @@ async function handleSweepDispatch(config, dependencies, computationManifest, re
|
|
|
315
313
|
}
|
|
316
314
|
|
|
317
315
|
// =============================================================================
|
|
318
|
-
// LOGIC: Standard Dispatch (
|
|
316
|
+
// LOGIC: Standard Dispatch (Fast-Forward Enabled)
|
|
319
317
|
// =============================================================================
|
|
320
318
|
async function handleStandardDispatch(config, dependencies, computationManifest, reqBody) {
|
|
321
319
|
const { logger, db } = dependencies;
|
|
@@ -336,16 +334,29 @@ async function handleStandardDispatch(config, dependencies, computationManifest,
|
|
|
336
334
|
const sessionDates = await getStableDateSession(config, dependencies, passToRun, dateLimitStr, forceRebuild);
|
|
337
335
|
if (!sessionDates || sessionDates.length === 0) return { status: 'MOVE_TO_NEXT_PASS', dispatched: 0 };
|
|
338
336
|
|
|
339
|
-
|
|
337
|
+
// --- Fast-Forward Loop Configuration ---
|
|
338
|
+
// Scans up to 50 dates or 40 seconds to find work, avoiding empty "wait loops"
|
|
339
|
+
const MAX_SCAN_DEPTH = 50;
|
|
340
|
+
const TIME_LIMIT_MS = 40000;
|
|
341
|
+
const startTime = Date.now();
|
|
342
|
+
|
|
343
|
+
let currentCursor = targetCursorN;
|
|
340
344
|
let selectedTasks = [];
|
|
345
|
+
let selectedDate = null;
|
|
346
|
+
let datesScanned = 0;
|
|
341
347
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
348
|
+
// Loop until work is found, end is reached, or safety limits hit
|
|
349
|
+
while (currentCursor <= sessionDates.length) {
|
|
350
|
+
datesScanned++;
|
|
351
|
+
selectedDate = sessionDates[currentCursor - 1]; // 0-indexed array
|
|
352
|
+
|
|
353
|
+
// 1. Safety Break (Prevent Timeout)
|
|
354
|
+
if ((Date.now() - startTime) > TIME_LIMIT_MS || datesScanned > MAX_SCAN_DEPTH) {
|
|
355
|
+
logger.log('INFO', `[Dispatcher] ⏩ Fast-forward paused at ${selectedDate} after scanning ${datesScanned} dates.`);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
347
358
|
|
|
348
|
-
|
|
359
|
+
// 2. Analyze Date
|
|
349
360
|
const earliestDates = await getEarliestDataDates(config, dependencies);
|
|
350
361
|
let prevDailyStatusPromise = Promise.resolve(null);
|
|
351
362
|
if (calcsInThisPass.some(c => c.isHistorical)) {
|
|
@@ -362,48 +373,70 @@ async function handleStandardDispatch(config, dependencies, computationManifest,
|
|
|
362
373
|
checkRootDataAvailability(selectedDate, config, dependencies, DEFINITIVE_EARLIEST_DATES)
|
|
363
374
|
]);
|
|
364
375
|
|
|
365
|
-
if (availability && availability.status) {
|
|
376
|
+
if (availability && availability.status) {
|
|
366
377
|
const report = analyzeDateExecution(selectedDate, calcsInThisPass, availability.status, dailyStatus, manifestMap, prevDailyStatus);
|
|
367
378
|
let rawTasks = [...report.runnable, ...report.reRuns];
|
|
368
379
|
|
|
369
380
|
if (rawTasks.length > 0) {
|
|
370
381
|
rawTasks = await attemptSimHashResolution(dependencies, selectedDate, rawTasks, dailyStatus, manifestMap);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
382
|
+
const activeTasks = await filterActiveTasks(db, selectedDate, passToRun, rawTasks, logger);
|
|
383
|
+
|
|
384
|
+
if (activeTasks.length > 0) {
|
|
385
|
+
const { standard, highMem } = await splitRoutes(db, selectedDate, passToRun, activeTasks, logger);
|
|
386
|
+
selectedTasks = [...standard, ...highMem];
|
|
387
|
+
|
|
388
|
+
if (selectedTasks.length > 0) {
|
|
389
|
+
// Found work! Break loop to dispatch.
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
378
393
|
}
|
|
379
394
|
}
|
|
395
|
+
|
|
396
|
+
// No work found for this date. Fast-forward to next.
|
|
397
|
+
currentCursor++;
|
|
380
398
|
}
|
|
381
399
|
|
|
400
|
+
// --- Result Handling ---
|
|
401
|
+
|
|
402
|
+
// Case 1: Satiated (Scanned to end of session with no work)
|
|
403
|
+
if (currentCursor > sessionDates.length && selectedTasks.length === 0) {
|
|
404
|
+
return {
|
|
405
|
+
status: 'CONTINUE_PASS',
|
|
406
|
+
dateProcessed: selectedDate,
|
|
407
|
+
dispatched: 0,
|
|
408
|
+
n_cursor_ignored: false,
|
|
409
|
+
remainingDates: 0,
|
|
410
|
+
nextCursor: currentCursor // Matches length + 1
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Case 2: Paused by Limit (No work found yet, but more dates remain)
|
|
382
415
|
if (selectedTasks.length === 0) {
|
|
383
416
|
return {
|
|
384
417
|
status: 'CONTINUE_PASS',
|
|
385
418
|
dateProcessed: selectedDate,
|
|
386
419
|
dispatched: 0,
|
|
387
420
|
n_cursor_ignored: false,
|
|
388
|
-
|
|
389
|
-
|
|
421
|
+
remainingDates: sessionDates.length - currentCursor + 1,
|
|
422
|
+
nextCursor: currentCursor // Resume from here
|
|
390
423
|
};
|
|
391
424
|
}
|
|
392
425
|
|
|
426
|
+
// Case 3: Work Found (Dispatching)
|
|
393
427
|
const totalweight = selectedTasks.reduce((sum, t) => sum + (manifestWeightMap.get(normalizeName(t.name)) || 1.0), 0);
|
|
394
428
|
const currentDispatchId = crypto.randomUUID();
|
|
395
429
|
const etaSeconds = Math.max(20, Math.ceil(totalweight * BASE_SECONDS_PER_WEIGHT_UNIT));
|
|
396
430
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
431
|
+
if (datesScanned > 1) {
|
|
432
|
+
logger.log('INFO', `[Dispatcher] ⏩ Fast-forwarded ${datesScanned - 1} empty dates. Dispatching ${selectedTasks.length} tasks for ${selectedDate}.`);
|
|
433
|
+
} else {
|
|
434
|
+
logger.log('INFO', `[Dispatcher] ✅ Dispatching ${selectedTasks.length} tasks for ${selectedDate}.`);
|
|
435
|
+
}
|
|
401
436
|
|
|
402
437
|
const mapToTaskPayload = (t) => {
|
|
403
|
-
// [NEW] Generate Eyeball Trace
|
|
404
438
|
const traceId = crypto.randomBytes(16).toString('hex');
|
|
405
439
|
const spanId = crypto.randomBytes(8).toString('hex');
|
|
406
|
-
|
|
407
440
|
return {
|
|
408
441
|
...t,
|
|
409
442
|
action: 'RUN_COMPUTATION_DATE',
|
|
@@ -413,7 +446,6 @@ async function handleStandardDispatch(config, dependencies, computationManifest,
|
|
|
413
446
|
dispatchId: currentDispatchId,
|
|
414
447
|
triggerReason: t.reason,
|
|
415
448
|
resources: t.resources || 'standard',
|
|
416
|
-
// [NEW] Attach the eyeball
|
|
417
449
|
traceContext: {
|
|
418
450
|
traceId: traceId,
|
|
419
451
|
spanId: spanId,
|
|
@@ -448,13 +480,15 @@ async function handleStandardDispatch(config, dependencies, computationManifest,
|
|
|
448
480
|
dispatched: selectedTasks.length,
|
|
449
481
|
n_cursor_ignored: false,
|
|
450
482
|
etaSeconds: etaSeconds,
|
|
451
|
-
remainingDates: sessionDates.length - targetCursorN
|
|
483
|
+
remainingDates: sessionDates.length - targetCursorN,
|
|
484
|
+
nextCursor: currentCursor + 1 // Start next scan AFTER this date
|
|
452
485
|
};
|
|
453
486
|
}
|
|
454
487
|
|
|
455
488
|
// =============================================================================
|
|
456
489
|
// HELPER: Route Splitting (One-Shot Enforcement)
|
|
457
490
|
// =============================================================================
|
|
491
|
+
// [UPDATED] Route Splitting with Version-Aware Dead Lettering
|
|
458
492
|
async function splitRoutes(db, date, pass, tasks, logger) {
|
|
459
493
|
const standard = [];
|
|
460
494
|
const highMem = [];
|
|
@@ -465,32 +499,51 @@ async function splitRoutes(db, date, pass, tasks, logger) {
|
|
|
465
499
|
const doc = await db.doc(ledgerPath).get();
|
|
466
500
|
|
|
467
501
|
if (!doc.exists) {
|
|
468
|
-
// New task -> Standard
|
|
469
502
|
standard.push(task);
|
|
470
503
|
continue;
|
|
471
504
|
}
|
|
472
505
|
|
|
473
506
|
const data = doc.data();
|
|
474
507
|
|
|
475
|
-
//
|
|
508
|
+
// CHECK: Is the task currently running or pending?
|
|
509
|
+
if (['PENDING', 'IN_PROGRESS'].includes(data.status)) {
|
|
510
|
+
// It's active, don't dispatch again
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
476
514
|
if (data.status === 'FAILED') {
|
|
477
515
|
const stage = data.error?.stage;
|
|
478
516
|
|
|
479
|
-
// 1.
|
|
480
|
-
if (['QUALITY_CIRCUIT_BREAKER', 'SEMANTIC_GATE'].includes(stage)) {
|
|
481
|
-
|
|
482
|
-
|
|
517
|
+
// 1. DETERMINISTIC FAILURES (Never Retry)
|
|
518
|
+
if (['QUALITY_CIRCUIT_BREAKER', 'SEMANTIC_GATE', 'SHARDING_LIMIT_EXCEEDED'].includes(stage)) {
|
|
519
|
+
logger.log('WARN', `[Dispatcher] 🛑 Dropping ${name} - Deterministic Failure (${stage}).`);
|
|
520
|
+
continue;
|
|
483
521
|
}
|
|
484
522
|
|
|
485
|
-
// 2.
|
|
523
|
+
// 2. HIGH MEMORY FAILURE HANDLING (The New Logic)
|
|
486
524
|
if (data.resourceTier === 'high-mem') {
|
|
487
|
-
|
|
488
|
-
|
|
525
|
+
const failedHash = data.hash || data.composition?.code; // Support legacy or new structure
|
|
526
|
+
const currentHash = task.hash;
|
|
527
|
+
|
|
528
|
+
// A. EXACT CODE MATCH: It failed High-Mem with THIS code.
|
|
529
|
+
if (failedHash === currentHash) {
|
|
530
|
+
logger.log('WARN', `[Dispatcher] 💀 Dead End: ${name} failed High-Mem on this version (${currentHash.slice(0,6)}). Waiting for code fix.`);
|
|
531
|
+
continue; // STOP. Do not retry.
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// B. CODE MISMATCH: The code has changed since the High-Mem failure.
|
|
535
|
+
// We reset it to 'standard' to see if the fix optimized memory usage.
|
|
536
|
+
else {
|
|
537
|
+
logger.log('INFO', `[Dispatcher] 🔄 Code Updated for ${name}. Resetting High-Mem failure to Standard retry.`);
|
|
538
|
+
standard.push({
|
|
539
|
+
...task,
|
|
540
|
+
reason: 'Retry: Code Version Changed'
|
|
541
|
+
});
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
489
544
|
}
|
|
490
545
|
|
|
491
|
-
// 3. STANDARD
|
|
492
|
-
// If it failed standard, we give it ONE shot on high-mem.
|
|
493
|
-
// Note: Even if it was an "Unknown" error, we promote to High-Mem to cover OOMs that looked like crashes.
|
|
546
|
+
// 3. STANDARD FAILURE -> PROMOTE TO HIGH MEM
|
|
494
547
|
highMem.push({
|
|
495
548
|
...task,
|
|
496
549
|
resources: 'high-mem',
|
|
@@ -498,7 +551,8 @@ async function splitRoutes(db, date, pass, tasks, logger) {
|
|
|
498
551
|
});
|
|
499
552
|
|
|
500
553
|
} else {
|
|
501
|
-
//
|
|
554
|
+
// Status is likely COMPLETED (but filterActiveTasks should have caught this)
|
|
555
|
+
// or some other state. Default to standard if we are here.
|
|
502
556
|
standard.push(task);
|
|
503
557
|
}
|
|
504
558
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* FILENAME: computation-system/helpers/computation_worker.js
|
|
3
3
|
* UPDATED: Implements Google Cloud Monitoring Heartbeats.
|
|
4
4
|
* UPDATED: Implements Structured Logging with Trace Context.
|
|
5
|
+
* FIXED: 'manifest' scope issue (declared outside try/catch).
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
const { executeDispatchTask } = require('../WorkflowOrchestrator.js');
|
|
@@ -143,8 +144,6 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
143
144
|
const workerId = process.env.K_REVISION || os.hostname();
|
|
144
145
|
|
|
145
146
|
// 2. Initialize Trace-Aware Logger (The "Eyeball")
|
|
146
|
-
// If traceContext is present (from dispatcher), we inject it into globalMetadata
|
|
147
|
-
// so ALL logs from this execution line up in Google Trace.
|
|
148
147
|
const globalMetadata = {};
|
|
149
148
|
if (traceContext && monConfig.enabled) {
|
|
150
149
|
globalMetadata['logging.googleapis.com/trace'] = `projects/${monConfig.project}/traces/${traceContext.traceId}`;
|
|
@@ -155,7 +154,7 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
155
154
|
const logger = new StructuredLogger({
|
|
156
155
|
minLevel: config.minLevel || 'INFO',
|
|
157
156
|
enableStructured: true,
|
|
158
|
-
globalMetadata,
|
|
157
|
+
globalMetadata,
|
|
159
158
|
...config
|
|
160
159
|
});
|
|
161
160
|
|
|
@@ -172,11 +171,13 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
172
171
|
logger.log('INFO', `[Worker] 📥 Task: ${computation} (${date}) [Tier: ${resourceTier}] [ID: ${dispatchId}]`);
|
|
173
172
|
|
|
174
173
|
// --- STEP 2: START DUAL HEARTBEATS ---
|
|
175
|
-
// Starts both Firestore writes (legacy) AND Google Metrics (new)
|
|
176
174
|
const heartbeats = startMemoryHeartbeat(db, ledgerPath, workerId, computation, traceContext?.traceId);
|
|
177
175
|
|
|
176
|
+
// [FIX] Declare manifest here so it is accessible in both try and catch blocks
|
|
177
|
+
let manifest;
|
|
178
|
+
|
|
178
179
|
try {
|
|
179
|
-
|
|
180
|
+
manifest = getManifest(config.activeProductLines || [], calculations, runDeps);
|
|
180
181
|
const startTime = Date.now();
|
|
181
182
|
|
|
182
183
|
const result = await executeDispatchTask(
|
|
@@ -207,7 +208,6 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
207
208
|
};
|
|
208
209
|
|
|
209
210
|
await db.doc(ledgerPath).update({ status: 'COMPLETED', completedAt: new Date() });
|
|
210
|
-
// Use the new recorder which also prints JSON for logs
|
|
211
211
|
await recordRunAttempt(db, { date, computation, pass }, 'SUCCESS', null, metrics, triggerReason, resourceTier);
|
|
212
212
|
|
|
213
213
|
} catch (err) {
|
|
@@ -215,10 +215,25 @@ async function handleComputationTask(message, config, dependencies) {
|
|
|
215
215
|
|
|
216
216
|
const isDeterministic = ['SHARDING_LIMIT_EXCEEDED', 'QUALITY_CIRCUIT_BREAKER', 'SEMANTIC_GATE'].includes(err.stage);
|
|
217
217
|
|
|
218
|
+
// [FIX] Now we can safely access 'manifest' to find the current hash
|
|
219
|
+
let currentHash = 'unknown';
|
|
220
|
+
if (typeof manifest !== 'undefined' && Array.isArray(manifest)) {
|
|
221
|
+
const item = manifest.find(c => normalizeName(c.name) === normalizeName(computation));
|
|
222
|
+
if (item) currentHash = item.hash;
|
|
223
|
+
}
|
|
224
|
+
|
|
218
225
|
if (isDeterministic || (message.deliveryAttempt || 1) >= MAX_RETRIES) {
|
|
219
226
|
const errorPayload = { message: err.message, stage: err.stage || 'FATAL' };
|
|
220
227
|
|
|
221
|
-
|
|
228
|
+
// [UPDATED] We now save 'hash' and 'resourceTier' to the ledger on failure
|
|
229
|
+
await db.doc(ledgerPath).set({
|
|
230
|
+
status: 'FAILED',
|
|
231
|
+
error: errorPayload,
|
|
232
|
+
failedAt: new Date(),
|
|
233
|
+
hash: currentHash, // <--- CRITICAL FOR DISPATCHER DEAD-LETTER CHECK
|
|
234
|
+
resourceTier: resourceTier
|
|
235
|
+
}, { merge: true });
|
|
236
|
+
|
|
222
237
|
await recordRunAttempt(db, { date, computation, pass }, 'FAILURE', { message: err.message, stage: err.stage || 'FATAL' }, { peakMemoryMB: heartbeats.getPeak() }, triggerReason, resourceTier);
|
|
223
238
|
return;
|
|
224
239
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Cloud Workflows: Precision Cursor-Based Orchestrator
|
|
2
|
-
#
|
|
3
|
-
# UPGRADED:
|
|
2
|
+
# FAST-FORWARD MODE: Dispatch -> (Internal Loop) -> Wait ETA -> Jump to Next Valid Cursor
|
|
3
|
+
# UPGRADED: Supports 'nextCursor' to skip empty date ranges instantly.
|
|
4
4
|
|
|
5
5
|
main:
|
|
6
6
|
params: [input]
|
|
@@ -39,12 +39,12 @@ main:
|
|
|
39
39
|
|
|
40
40
|
- evaluate_dispatch:
|
|
41
41
|
switch:
|
|
42
|
-
# 1. End of Session (Dispatcher reached end of date list)
|
|
42
|
+
# 1. End of Session (Dispatcher reached absolute end of date list)
|
|
43
43
|
- condition: '${dispatch_res.body.status == "MOVE_TO_NEXT_PASS"}'
|
|
44
44
|
assign:
|
|
45
45
|
- pass_complete: true
|
|
46
46
|
|
|
47
|
-
# 2. Satiation Check (
|
|
47
|
+
# 2. Satiation Check (Dispatcher scanned to end, found nothing)
|
|
48
48
|
- condition: '${dispatch_res.body.status == "CONTINUE_PASS" and dispatch_res.body.remainingDates == 0 and dispatch_res.body.dispatched == 0}'
|
|
49
49
|
steps:
|
|
50
50
|
- log_satiation:
|
|
@@ -55,7 +55,7 @@ main:
|
|
|
55
55
|
assign:
|
|
56
56
|
- pass_complete: true
|
|
57
57
|
|
|
58
|
-
# 3. Work Dispatched: Wait ETA ->
|
|
58
|
+
# 3. Work Dispatched: Wait ETA -> Jump to 'nextCursor'
|
|
59
59
|
- condition: '${dispatch_res.body.dispatched > 0}'
|
|
60
60
|
steps:
|
|
61
61
|
- wait_for_completion:
|
|
@@ -64,25 +64,26 @@ main:
|
|
|
64
64
|
seconds: '${int(dispatch_res.body.etaSeconds)}'
|
|
65
65
|
- update_cursor:
|
|
66
66
|
assign:
|
|
67
|
-
|
|
67
|
+
# CRITICAL CHANGE: Use dispatcher's specific jump target
|
|
68
|
+
- n_cursor: '${default(dispatch_res.body.nextCursor, n_cursor + 1)}'
|
|
68
69
|
- next_loop_work:
|
|
69
70
|
next: sequential_date_loop
|
|
70
71
|
|
|
71
|
-
# 4. No Work (
|
|
72
|
+
# 4. No Work (Fast-Forward limit reached or Single Check): Jump to 'nextCursor'
|
|
72
73
|
- condition: '${dispatch_res.body.dispatched == 0}'
|
|
73
74
|
steps:
|
|
74
75
|
- wait_short:
|
|
75
76
|
call: sys.sleep
|
|
76
77
|
args:
|
|
77
|
-
seconds: 2 # Tiny debounce
|
|
78
|
+
seconds: 2 # Tiny debounce for API limits
|
|
78
79
|
- update_cursor_retry:
|
|
79
80
|
assign:
|
|
80
|
-
#
|
|
81
|
-
- n_cursor: '${
|
|
81
|
+
# CRITICAL CHANGE: Resume exactly where the Fast-Forward loop stopped
|
|
82
|
+
- n_cursor: '${default(dispatch_res.body.nextCursor, n_cursor + 1)}'
|
|
82
83
|
- next_loop_retry:
|
|
83
84
|
next: sequential_date_loop
|
|
84
85
|
|
|
85
|
-
# ---
|
|
86
|
+
# --- VERIFICATION & SWEEP (No changes needed here) ---
|
|
86
87
|
- verify_pass_completion:
|
|
87
88
|
call: http.post
|
|
88
89
|
args:
|