@animastor/orchestration 0.1.4 → 0.1.5
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/package.json +1 -1
- package/src/orchestration/scene-callbacks.js +55 -3
- package/src/orchestration/scene-orchestrator.js +70 -7
- package/src/runtime/dispatch-engine.js +310 -5
- package/src/runtime/dispatch-outcomes.js +124 -0
- package/src/runtime/hub-cancel-port.js +6 -0
- package/src/runtime/persistence-port.js +6 -0
- package/src/runtime/reconciliation-engine.js +57 -6
- package/src/runtime/runtime-scheduler.js +18 -1
- package/src/runtime/scene-window.js +28 -0
- package/test/capacity-deferral.test.js +265 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@animastor/orchestration",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Animastor Orchestration — the generation orchestration/runtime contour of the Animastor backend, physically extracted from the host (§32.30): the unified generation lifecycle owner (orchestrator facade, stage executor, scene callbacks/restoration), the dispatch engine and its lease/quota/retry managers, the reconciliation engine and counter reconciliation, the scene window/scheduler/worker-health, the runtime-result emitter/consumer, and the eight O-2..O-10 port contracts (Persistence, SceneData, PlaceholderAudio, ProgressEvents, AudioFsm, VideoFsm, HubCancel, LayerConfig). The package reaches the host (runtime config slices, Redis-domain state layer, media facades, the S-5 seam registry, the artifact root, the PW-2 workspace resolver) ONLY through the composition-root host-bindings seam — no backend/src require, no storage/book/routes/services, no Redis, no PG, no Express inside.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"animastor",
|
|
@@ -277,9 +277,17 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
|
|
|
277
277
|
// T1: возвращает { ok, retryable, reason, artifact }
|
|
278
278
|
log(`IMAGE_CALLBACK: ${bookId}/${chapterId}/${sceneId}`);
|
|
279
279
|
|
|
280
|
-
|
|
280
|
+
// Stage 143a: legacy canonical scan first (unchanged behavior, also the
|
|
281
|
+
// path every host stub knows), then — flag on and scan empty — the
|
|
282
|
+
// published pointer of this scene build as the completion proof.
|
|
283
|
+
let sceneImage = image.resolveCanonicalSceneImage(
|
|
281
284
|
artifactRoot(), buildId, bookId, chapterId, sceneId
|
|
282
285
|
);
|
|
286
|
+
if (!sceneImage && typeof image.resolveVersionedSceneProof === 'function') {
|
|
287
|
+
sceneImage = await image.resolveVersionedSceneProof(
|
|
288
|
+
artifactRoot(), buildId, bookId, chapterId, sceneId
|
|
289
|
+
);
|
|
290
|
+
}
|
|
283
291
|
|
|
284
292
|
// Per-asset check
|
|
285
293
|
const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
@@ -334,7 +342,28 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
|
|
|
334
342
|
const stillPending = [];
|
|
335
343
|
for (const uid of dirtyIds) {
|
|
336
344
|
const pngPath = path.join(buildDir, artifactNaming.sceneImageName(bookId, chapterId, sceneId, uid));
|
|
337
|
-
if (
|
|
345
|
+
if (fs.existsSync(pngPath)) continue;
|
|
346
|
+
// ── Stage 143a (phase 2a · N1) ─────────────────────
|
|
347
|
+
// A versioned-only build never writes the canonical file, so
|
|
348
|
+
// the published pointer is the completion proof for this
|
|
349
|
+
// unit. Without it the dirty marker is never cleared and the
|
|
350
|
+
// unit is force-regenerated on EVERY reconcile pass (infinite
|
|
351
|
+
// regeneration loop with the flag on).
|
|
352
|
+
// Flag OFF ⇒ the facade answers null with zero PG calls → the
|
|
353
|
+
// legacy check above decides, exactly as before.
|
|
354
|
+
// A pointer-store ERROR keeps the unit pending (never cleared
|
|
355
|
+
// on an unknown verdict) and is logged.
|
|
356
|
+
let provenByPointer = false;
|
|
357
|
+
try {
|
|
358
|
+
if (image && typeof image.resolveIuImagePath === 'function') {
|
|
359
|
+
const published = await image.resolveIuImagePath(
|
|
360
|
+
artifactRoot(), buildId, bookId, chapterId, sceneId, uid);
|
|
361
|
+
provenByPointer = !!(published && published.status === 'ok');
|
|
362
|
+
}
|
|
363
|
+
} catch (pgErr) {
|
|
364
|
+
warn(`[DIRTY-UNITS] pointer proof unavailable for ${uid}: ${pgErr.message} — keeping it pending`);
|
|
365
|
+
}
|
|
366
|
+
if (!provenByPointer) {
|
|
338
367
|
stillPending.push(uid);
|
|
339
368
|
}
|
|
340
369
|
}
|
|
@@ -343,8 +372,31 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
|
|
|
343
372
|
log(`[DIRTY-UNITS-CLEARED] ${bookId}/${chapterId}/${sceneId}: all ${dirtyIds.length} dirty unit(s) completed, cleared`);
|
|
344
373
|
try {
|
|
345
374
|
const iuPrefix = artifactNaming.iuImagePrefix(bookId, chapterId, sceneId);
|
|
375
|
+
const scenePrefix = artifactNaming.scenePrefix(bookId, chapterId, sceneId);
|
|
346
376
|
const allFiles = fs.existsSync(buildDir) ? fs.readdirSync(buildDir) : [];
|
|
347
|
-
|
|
377
|
+
// ── Stage 143a (phase 2a · N2) ─────────────────
|
|
378
|
+
// Ready units = canonical file names ∪ published pointer
|
|
379
|
+
// ids (versioned files are invisible to this scan). The
|
|
380
|
+
// counter is a DISPLAY source (consumers clamp it), so a
|
|
381
|
+
// pointer-store error here only logs and leaves the
|
|
382
|
+
// legacy count — never a readiness decision.
|
|
383
|
+
const readyIds = new Set();
|
|
384
|
+
for (const f of allFiles) {
|
|
385
|
+
if (f.startsWith(iuPrefix) && f.endsWith('.png')) {
|
|
386
|
+
readyIds.add(f.startsWith(`${scenePrefix}_`)
|
|
387
|
+
? f.slice(scenePrefix.length + 1, -4)
|
|
388
|
+
: f);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (image && typeof image.listPublishedIuIdsForScene === 'function') {
|
|
392
|
+
try {
|
|
393
|
+
const publishedIds = await image.listPublishedIuIdsForScene(buildId, bookId, chapterId, sceneId);
|
|
394
|
+
for (const id of publishedIds) readyIds.add(String(id));
|
|
395
|
+
} catch (pgErr) {
|
|
396
|
+
warn(`[IU-COUNTER-RESET] published ids unavailable for ${bookId}/${chapterId}/${sceneId}: ${pgErr.message} — counting canonical files only`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const iuFileCount = readyIds.size;
|
|
348
400
|
if (iuFileCount > 0) {
|
|
349
401
|
const progKey = `animastor:iu-progress:${bookId}:${chapterId}:${sceneId}:image`;
|
|
350
402
|
await redis.set(progKey, String(iuFileCount), 'EX', 14400);
|
|
@@ -13,10 +13,15 @@ const chunks = lazyHostBinding('media.audio.chunks');
|
|
|
13
13
|
// orchestrator never requires gpu-dispatcher or the workflow connector.
|
|
14
14
|
const provider = require('@animastor/generation').comfyuiProvider;
|
|
15
15
|
const runtimeScheduler = require('../runtime/runtime-scheduler');
|
|
16
|
+
// Stage 118 (M3): the shared outcome taxonomy — the dispatch engine (runtime
|
|
17
|
+
// tier) and the stage executors (orchestration tier) must classify a
|
|
18
|
+
// "nothing was enqueued" outcome IDENTICALLY.
|
|
19
|
+
const { classifyNotSentReasons } = require('../runtime/dispatch-outcomes');
|
|
16
20
|
// O-2: persistence arrives ONLY through the PersistencePort (host adapter:
|
|
17
21
|
// storage/runtime-persistence-adapter) — the scene-assets repository left
|
|
18
22
|
// this file. SQL lives host-side.
|
|
19
23
|
const persist = require('../runtime/persistence-port').persist;
|
|
24
|
+
const isPersistencePortWired = require('../runtime/persistence-port').isPersistencePortWired;
|
|
20
25
|
// O-3: scene content arrives ONLY through the SceneDataPort (host adapter:
|
|
21
26
|
// storage/scene-data-adapter) — the Book Model facade (../book →
|
|
22
27
|
// @animastor/vbook-runtime) left this file. Aliased `sceneDataPort` because
|
|
@@ -300,8 +305,22 @@ async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchI
|
|
|
300
305
|
const completion = await completeStage(redis, bookId, chapterId, sceneId, 'audio', buildId, dispatchId);
|
|
301
306
|
return { dispatched: false, jobs: 0, completed: completion.completed, reason: 'no_segments' };
|
|
302
307
|
} else {
|
|
303
|
-
|
|
304
|
-
|
|
308
|
+
// Stage 116 (D3) / Stage 118 (B3, M3): nothing was enqueued — carry
|
|
309
|
+
// the precise reason (no live worker / everything already queued /
|
|
310
|
+
// ambiguous POST) so the dispatch engine can apply a BOUNDED deferral
|
|
311
|
+
// instead of a fatal cancel.
|
|
312
|
+
//
|
|
313
|
+
// Stage 118 (B3): the merged (pure-dialogue) path and the per-segment
|
|
314
|
+
// path must produce the SAME classification — the merged branch used
|
|
315
|
+
// to leave the reason empty, so a pure-dialogue scene kept the
|
|
316
|
+
// 5-second retry loop. `deferReason` is computed by the host (audio
|
|
317
|
+
// generation) for EVERY path; the fallback below keeps the historic
|
|
318
|
+
// 'no_jobs_generated' semantics when the host could not classify it.
|
|
319
|
+
const deferReason = (result && result.reason === 'no_jobs_accepted' && result.deferReason)
|
|
320
|
+
? result.deferReason
|
|
321
|
+
: 'no_jobs_generated';
|
|
322
|
+
warn(`AUDIO_DISPATCH: generateSceneAudio did not send jobs for ${bookId}/${chapterId}/${sceneId} (${deferReason})`);
|
|
323
|
+
return { dispatched: false, jobs: 0, reason: deferReason, retryable: true };
|
|
305
324
|
}
|
|
306
325
|
}
|
|
307
326
|
|
|
@@ -352,8 +371,11 @@ async function executeImageDispatch(redis, scene, loadedBook, buildId, dispatchI
|
|
|
352
371
|
|
|
353
372
|
// T3: generateSceneIUImages возвращает количество отправленных jobs
|
|
354
373
|
let jobsSent = 0;
|
|
374
|
+
// Stage 116 (D3): the result is needed AFTER the try/catch (the deferral
|
|
375
|
+
// reason) — it must be visible outside the block.
|
|
376
|
+
let genResult = null;
|
|
355
377
|
try {
|
|
356
|
-
|
|
378
|
+
genResult = await image.generateSceneIUImages(
|
|
357
379
|
redis,
|
|
358
380
|
sceneData,
|
|
359
381
|
bookData,
|
|
@@ -377,6 +399,16 @@ async function executeImageDispatch(redis, scene, loadedBook, buildId, dispatchI
|
|
|
377
399
|
return { dispatched: true, jobs: jobsSent, reason: null };
|
|
378
400
|
}
|
|
379
401
|
|
|
402
|
+
// Stage 116 (D3) / Stage 118 (M3): the executor sent NOTHING. That is
|
|
403
|
+
// not a lost task and not a fatal cancellation — it is a bounded
|
|
404
|
+
// deferral. The precise reason (no live worker / everything already
|
|
405
|
+
// queued / ambiguous POST) is carried through so the deferral can be
|
|
406
|
+
// classified by the shared taxonomy (runtime/dispatch-outcomes).
|
|
407
|
+
if (genResult && genResult.deferReason) {
|
|
408
|
+
warn(`IMAGE_DISPATCH: ${bookId}/${chapterId}/${sceneId} — nothing enqueued (${genResult.deferReason}); deferring`);
|
|
409
|
+
return { dispatched: false, jobs: 0, reason: genResult.deferReason, retryable: true };
|
|
410
|
+
}
|
|
411
|
+
|
|
380
412
|
log(`IMAGE_DISPATCH: ${bookId}/${chapterId}/${sceneId} — no jobs sent (cache hit or empty units)`);
|
|
381
413
|
return { dispatched: false, jobs: 0, reason: 'no_jobs_sent' };
|
|
382
414
|
}
|
|
@@ -551,15 +583,41 @@ async function executeVideoDispatch(redis, scene, loadedBook, buildId, dispatchI
|
|
|
551
583
|
await videoOrch.setWaitingChunks(redis, bookId, chapterId, sceneId);
|
|
552
584
|
|
|
553
585
|
let sentCount = 0;
|
|
586
|
+
// Stage 116 (D3) / Stage 118 (M3): collect WHY a job was not sent — the
|
|
587
|
+
// dominant reason becomes the deferral reason (bounded backoff) instead
|
|
588
|
+
// of the generic 'send_failed'.
|
|
589
|
+
const notSentReasons = new Map();
|
|
554
590
|
for (const jobSpec of jobsToSend) {
|
|
555
591
|
try {
|
|
556
|
-
|
|
592
|
+
let sendResult = await provider.generate(jobSpec);
|
|
593
|
+
// Stage 122 (N2): the group file was probed absent above (a cached
|
|
594
|
+
// group never reaches this loop), so an `already_completed` verdict
|
|
595
|
+
// is by construction an ORPHANED completion marker. Free it and
|
|
596
|
+
// retry ONCE so the video regenerates instead of looping on a
|
|
597
|
+
// 900 s deferral with the scene stuck in the active index (audit
|
|
598
|
+
// 121 · N2). The release rides the persistence port — the
|
|
599
|
+
// orchestration tier cannot require the backend helper directly.
|
|
600
|
+
// Skipped when the port is unwired (harnesses that don't exercise
|
|
601
|
+
// orphan cleanup); production always wires it in backend.cjs.
|
|
602
|
+
if (isPersistencePortWired()) {
|
|
603
|
+
let released = false;
|
|
604
|
+
try {
|
|
605
|
+
released = await persist('idem.releaseTaskDoneIfOrphan')(redis, sendResult);
|
|
606
|
+
} catch (_) { released = false; }
|
|
607
|
+
if (released) {
|
|
608
|
+
log(`VIDEO_DISPATCH: orphaned completion marker released for ${jobSpec.job_id} — regenerating`);
|
|
609
|
+
sendResult = await provider.generate(jobSpec);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
557
612
|
if (sendResult.sent) {
|
|
558
613
|
sentCount++;
|
|
559
614
|
} else {
|
|
560
|
-
|
|
615
|
+
const reason = sendResult.reason || 'send_failed';
|
|
616
|
+
notSentReasons.set(reason, (notSentReasons.get(reason) || 0) + 1);
|
|
617
|
+
warn(`VIDEO_DISPATCH: Hub rejected job ${jobSpec.job_id}: ${sendResult.error || reason}`);
|
|
561
618
|
}
|
|
562
619
|
} catch (sendErr) {
|
|
620
|
+
notSentReasons.set('send_failed', (notSentReasons.get('send_failed') || 0) + 1);
|
|
563
621
|
warn(`VIDEO_DISPATCH: provider generate failed for job ${jobSpec.job_id}: ${sendErr.message}`);
|
|
564
622
|
}
|
|
565
623
|
}
|
|
@@ -569,8 +627,13 @@ async function executeVideoDispatch(redis, scene, loadedBook, buildId, dispatchI
|
|
|
569
627
|
return { dispatched: true, jobs: sentCount, reason: null };
|
|
570
628
|
}
|
|
571
629
|
|
|
572
|
-
|
|
573
|
-
|
|
630
|
+
// Stage 116 (D3) / Stage 118 (M3): the shared taxonomy decides whether
|
|
631
|
+
// the outcome is a bounded deferral. EVERY reason must be deferrable —
|
|
632
|
+
// a single real failure (send_failed) keeps the historic 'send_failed'
|
|
633
|
+
// semantics (an immediate retry on the next tick).
|
|
634
|
+
const deferReason = classifyNotSentReasons(notSentReasons) || 'send_failed';
|
|
635
|
+
warn(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId}: nothing enqueued (${deferReason})`);
|
|
636
|
+
return { dispatched: false, jobs: 0, reason: deferReason, retryable: true };
|
|
574
637
|
}
|
|
575
638
|
|
|
576
639
|
// T4: dispatchStage принимает dispatchId (из dispatch-engine) и передаёт executors
|
|
@@ -730,10 +730,194 @@ async function shouldSkipDispatch(redis, bookId, chapterId, sceneId, stage) {
|
|
|
730
730
|
* - { dispatched: true, dispatchId, stage, leaseKey }
|
|
731
731
|
* - { dispatched: false, reason, skip: true/false }
|
|
732
732
|
*/
|
|
733
|
+
// ======================================================
|
|
734
|
+
// STAGE 116 (D3) — BOUNDED DEFERRAL (capacity backoff)
|
|
735
|
+
// STAGE 118 (audit 117 · M2/M4) — taxonomy + force handling
|
|
736
|
+
// ======================================================
|
|
737
|
+
// The scheduler ticks every SCHEDULER_TICK_MS (5 s). A stage whose jobs could
|
|
738
|
+
// not be enqueued (no live compatible worker, or every job already pending in
|
|
739
|
+
// the lane) used to be re-dispatched on EVERY tick: lease created → executor
|
|
740
|
+
// sent nothing → 'no_jobs_sent' → dispatch cancelled → FSM rolled back →
|
|
741
|
+
// next tick repeats. That is an unbounded dispatch/cancel loop with FSM
|
|
742
|
+
// churn, lease churn and unbounded event-journal growth (Stage 115 D3).
|
|
743
|
+
//
|
|
744
|
+
// The deferral is a bounded, per-stage backoff:
|
|
745
|
+
// • checked BEFORE any side effect (no lease, no metadata, no FSM write,
|
|
746
|
+
// no journal event) ⇒ a deferred stage costs one Redis GET per tick;
|
|
747
|
+
// • the stage stays PENDING and stays in the active index ⇒ recoverable;
|
|
748
|
+
// • an explicit cancel clears it (the user asked for the work to run).
|
|
749
|
+
//
|
|
750
|
+
// Stage 118 — three corrections of the Stage 116 implementation:
|
|
751
|
+
//
|
|
752
|
+
// 1. The reason taxonomy now distinguishes CAPACITY from IDEMPOTENT
|
|
753
|
+
// suppression ('already_completed' is NOT a capacity gap — audit 117 · M4).
|
|
754
|
+
// The idempotent bucket gets its own, much longer window: the stage is not
|
|
755
|
+
// "waiting for capacity", it is "already done" — retrying it every minute
|
|
756
|
+
// for a week is not a fix.
|
|
757
|
+
// 2. `force=true` NO LONGER BYPASSES the deferral (audit 117 · M2). The
|
|
758
|
+
// force-dispatch flag lives for FORCE_DISPATCH_TTL_S (120 s) and is set for
|
|
759
|
+
// the WHOLE book by /regenerate, so bypassing the backoff there restored
|
|
760
|
+
// the very dispatch/cancel churn D3 was built to remove (≈24 full attempts
|
|
761
|
+
// per scene per regeneration). The explicit intent (a regeneration, a
|
|
762
|
+
// cancel) clears the deferral at ITS call site — the backoff then applies
|
|
763
|
+
// again after the next capacity failure.
|
|
764
|
+
// 3. The deferral key encodes its components (audit 117 · M1): a book_id may
|
|
765
|
+
// contain ':' (the bundle book_id is client-controlled), so a raw
|
|
766
|
+
// ':'-joined key is ambiguous — `a:b` + chapter `c` collides with book
|
|
767
|
+
// `a` + chapter `b`.
|
|
768
|
+
const DEFERRED_STAGE_KEY_PREFIX = 'animastor:runtime:deferred:';
|
|
769
|
+
const DEFERRED_STAGE_TTL_S = 60; // capacity backoff window (bounded retry)
|
|
770
|
+
const DEFERRED_IDEMPOTENT_TTL_S = 900; // idempotent-suppression window (15 min)
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Executor outcomes that mean "nothing could be enqueued — retry later".
|
|
774
|
+
*
|
|
775
|
+
* The taxonomy lives in ./dispatch-outcomes (shared with the stage
|
|
776
|
+
* executors). The generic 'no_jobs_sent' / 'no_jobs_generated' /
|
|
777
|
+
* 'send_failed' outcomes keep their historic contract (retry on the next
|
|
778
|
+
* tick — the audit-6929ba5 regression tests rely on it): deferring them
|
|
779
|
+
* would change the meaning of an executor result that does not necessarily
|
|
780
|
+
* mean "no capacity".
|
|
781
|
+
*/
|
|
782
|
+
const {
|
|
783
|
+
CAPACITY_OUTCOMES,
|
|
784
|
+
IDEMPOTENT_OUTCOMES,
|
|
785
|
+
FENCE_REJECTED_OUTCOMES,
|
|
786
|
+
isCapacityOutcome,
|
|
787
|
+
isIdempotentOutcome,
|
|
788
|
+
isFenceRejectedOutcome,
|
|
789
|
+
isDeferrableOutcome,
|
|
790
|
+
} = require('./dispatch-outcomes');
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Encode one key component so that ':' (and the glob metacharacters) can
|
|
794
|
+
* never be mistaken for the separator. encodeURIComponent escapes ':' as
|
|
795
|
+
* '%3A' and leaves '_' readable.
|
|
796
|
+
*/
|
|
797
|
+
function encodeKeyComponent(value) {
|
|
798
|
+
return encodeURIComponent(String(value == null ? '' : value));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function deferredStageKey(bookId, chapterId, sceneId, stage) {
|
|
802
|
+
return `${DEFERRED_STAGE_KEY_PREFIX}`
|
|
803
|
+
+ `${encodeKeyComponent(bookId)}:${encodeKeyComponent(chapterId)}`
|
|
804
|
+
+ `:${encodeKeyComponent(sceneId)}:${encodeKeyComponent(stage)}`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Is this stage currently deferred (backoff active)? */
|
|
808
|
+
async function isStageDeferred(redis, bookId, chapterId, sceneId, stage) {
|
|
809
|
+
try {
|
|
810
|
+
const value = await redis.get(deferredStageKey(bookId, chapterId, sceneId, stage));
|
|
811
|
+
return value != null;
|
|
812
|
+
} catch (_) {
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Defer a stage for the bounded backoff window.
|
|
819
|
+
* @param {number} [ttlSeconds] — the capacity window by default; the
|
|
820
|
+
* idempotent window for an 'already_completed' suppression.
|
|
821
|
+
*/
|
|
822
|
+
async function deferStage(redis, bookId, chapterId, sceneId, stage, ttlSeconds = DEFERRED_STAGE_TTL_S) {
|
|
823
|
+
try {
|
|
824
|
+
await redis.set(deferredStageKey(bookId, chapterId, sceneId, stage), '1', 'EX', ttlSeconds);
|
|
825
|
+
} catch (_) { /* best effort — a missed deferral only costs one extra attempt */ }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Clear the deferral (the stage dispatched, completed or was cancelled). */
|
|
829
|
+
async function clearStageDeferral(redis, bookId, chapterId, sceneId, stage) {
|
|
830
|
+
try {
|
|
831
|
+
await redis.del(deferredStageKey(bookId, chapterId, sceneId, stage));
|
|
832
|
+
} catch (_) { /* best effort */ }
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Clear the capacity backoff of EVERY stage of a book.
|
|
837
|
+
*
|
|
838
|
+
* Stage 118 (audit 117 · M2/B4): the explicit intents — a cancel, a cache
|
|
839
|
+
* teardown, a deletion — ask for the work to run again, so the backoff must
|
|
840
|
+
* not survive them. The deferral key is owned by this package, so the
|
|
841
|
+
* package clears it (the host only injects the port).
|
|
842
|
+
*
|
|
843
|
+
* @returns {Promise<number>} the number of cleared deferrals.
|
|
844
|
+
*/
|
|
845
|
+
async function clearDeferralsForBook(redis, bookId) {
|
|
846
|
+
if (!redis || !bookId) return 0;
|
|
847
|
+
return clearDeferralsByPrefix(redis, `${DEFERRED_STAGE_KEY_PREFIX}${encodeKeyComponent(bookId)}:`);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Clear the capacity backoff of every stage of one scene (an explicit
|
|
852
|
+
* regeneration of that scene).
|
|
853
|
+
*/
|
|
854
|
+
async function clearDeferralsForScene(redis, bookId, chapterId, sceneId) {
|
|
855
|
+
if (!redis || !bookId || !chapterId || !sceneId) return 0;
|
|
856
|
+
return clearDeferralsByPrefix(redis, `${DEFERRED_STAGE_KEY_PREFIX}`
|
|
857
|
+
+ `${encodeKeyComponent(bookId)}:${encodeKeyComponent(chapterId)}:${encodeKeyComponent(sceneId)}:`);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function clearDeferralsByPrefix(redis, prefix) {
|
|
861
|
+
let cleared = 0;
|
|
862
|
+
if (!redis || !prefix) return cleared;
|
|
863
|
+
let cursor = '0';
|
|
864
|
+
try {
|
|
865
|
+
do {
|
|
866
|
+
const [next, keys] = await redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 200);
|
|
867
|
+
cursor = next;
|
|
868
|
+
for (const key of keys || []) {
|
|
869
|
+
if (!key.startsWith(prefix)) continue;
|
|
870
|
+
await redis.del(key);
|
|
871
|
+
cleared++;
|
|
872
|
+
}
|
|
873
|
+
} while (cursor !== '0');
|
|
874
|
+
} catch (_) { /* best effort */ }
|
|
875
|
+
return cleared;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* The backoff window that fits the outcome: a capacity gap retries in
|
|
880
|
+
* minutes, an idempotent suppression (already complete) in quarters of an
|
|
881
|
+
* hour — it is not waiting for capacity, it is simply done.
|
|
882
|
+
*/
|
|
883
|
+
function deferralWindowFor(reason) {
|
|
884
|
+
// Stage 129 (audit 128 · F5): a fence rejection is terminal for THIS
|
|
885
|
+
// generation — retrying it immediately would only recreate the
|
|
886
|
+
// dispatch → cancel → rollback churn. It shares the long window with the
|
|
887
|
+
// idempotent suppressions and stays visible through the log/metric.
|
|
888
|
+
return (isIdempotentOutcome(reason) || isFenceRejectedOutcome(reason))
|
|
889
|
+
? DEFERRED_IDEMPOTENT_TTL_S
|
|
890
|
+
: DEFERRED_STAGE_TTL_S;
|
|
891
|
+
}
|
|
892
|
+
|
|
733
893
|
async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBook, buildId, options = {}) {
|
|
734
894
|
const { force = false } = options;
|
|
735
895
|
const dispatchId = generateDispatchToken();
|
|
736
896
|
|
|
897
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
898
|
+
// Stage 116 (D3) / Stage 118 (M2) — BOUNDED DEFERRAL (retry/backoff)
|
|
899
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
900
|
+
// A stage that could not enqueue anything (no live compatible worker, or
|
|
901
|
+
// every job already pending in the lane) must NOT be retried on every
|
|
902
|
+
// scheduler tick: the tick is 5 s, so the un-deferred path produced an
|
|
903
|
+
// endless dispatch → cancel → FSM-rollback cycle (lease churn, FSM churn
|
|
904
|
+
// and UNBOUNDED event-journal growth).
|
|
905
|
+
//
|
|
906
|
+
// The deferral is checked BEFORE any side effect (no lease, no dispatch
|
|
907
|
+
// metadata, no FSM write, no journal event) ⇒ a deferred stage costs one
|
|
908
|
+
// Redis GET per tick. The stage stays PENDING and stays in the active
|
|
909
|
+
// index ⇒ it is recoverable: as soon as the deferral window expires (or a
|
|
910
|
+
// worker appears) the next tick dispatches it again.
|
|
911
|
+
//
|
|
912
|
+
// Stage 118 (M2): the check applies in FORCE mode as well. The
|
|
913
|
+
// force-dispatch flag is per BOOK and lives for 120 s, so bypassing the
|
|
914
|
+
// backoff there reproduced the very churn D3 removes (≈24 full attempts
|
|
915
|
+
// per scene per /regenerate). An EXPLICIT intent (a regeneration, a
|
|
916
|
+
// cancel) clears the deferral at its own call site.
|
|
917
|
+
if (await isStageDeferred(redis, bookId, chapterId, sceneId, stage)) {
|
|
918
|
+
return { dispatched: false, reason: 'deferred_capacity', retryable: true, deferred: true, dispatchId };
|
|
919
|
+
}
|
|
920
|
+
|
|
737
921
|
log(`DISPATCH_REQUEST: ${bookId}/${chapterId}/${sceneId}:${stage}${force ? ' (force=true)' : ''}`);
|
|
738
922
|
|
|
739
923
|
// Force mode pre-clear: cancel existing dispatch, release quota ТОЛЬКО при наличии lease
|
|
@@ -908,6 +1092,18 @@ async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBoo
|
|
|
908
1092
|
if (!result.dispatched) {
|
|
909
1093
|
log(`DISPATCH_SKIPPED: ${bookId}/${chapterId}/${sceneId}:${stage} - ${result.reason || 'already_done'}`);
|
|
910
1094
|
if (!result.completed) {
|
|
1095
|
+
// Stage 116 (D3): a stage that enqueued NOTHING is not a
|
|
1096
|
+
// lost task and not a fatal cancellation — it is a bounded
|
|
1097
|
+
// deferral. Remember it for a bounded window so the
|
|
1098
|
+
// scheduler's 5 s tick does not spin.
|
|
1099
|
+
//
|
|
1100
|
+
// Stage 118 (M4): the window depends on the bucket — a
|
|
1101
|
+
// capacity gap retries within a minute, an idempotent
|
|
1102
|
+
// suppression ('already_completed') is NOT waiting for
|
|
1103
|
+
// capacity and gets the long window.
|
|
1104
|
+
if (isDeferrableOutcome(result.reason)) {
|
|
1105
|
+
await deferStage(redis, bookId, chapterId, sceneId, stage, deferralWindowFor(result.reason));
|
|
1106
|
+
}
|
|
911
1107
|
await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
|
|
912
1108
|
outcome: 'cancelled',
|
|
913
1109
|
dispatchId,
|
|
@@ -918,13 +1114,29 @@ async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBoo
|
|
|
918
1114
|
if (result.rollbackFailed) {
|
|
919
1115
|
error(`STATE_ROLLBACK_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${result.rollbackReason || 'unknown'} — stage may be stuck in GENERATING`);
|
|
920
1116
|
}
|
|
1117
|
+
} else {
|
|
1118
|
+
// The stage completed (cache hit / already done) — it is no
|
|
1119
|
+
// longer waiting for capacity.
|
|
1120
|
+
await clearStageDeferral(redis, bookId, chapterId, sceneId, stage);
|
|
921
1121
|
}
|
|
922
1122
|
// Cancelled finalize does not touch the circuit breaker — release
|
|
923
1123
|
// the test permit here so a half-open circuit can keep recovering.
|
|
924
1124
|
await releaseTestPermit();
|
|
925
|
-
return {
|
|
1125
|
+
return {
|
|
1126
|
+
dispatched: false,
|
|
1127
|
+
dispatchId,
|
|
1128
|
+
reason: result.reason || 'already_done',
|
|
1129
|
+
retryable: isDeferrableOutcome(result.reason),
|
|
1130
|
+
// Stage 118 (M9): the caller (scheduler) must be able to
|
|
1131
|
+
// tell "nothing happened, try later" from a real error.
|
|
1132
|
+
deferred: isDeferrableOutcome(result.reason),
|
|
1133
|
+
result,
|
|
1134
|
+
};
|
|
926
1135
|
}
|
|
927
1136
|
|
|
1137
|
+
// The stage really dispatched — capacity deferral is over.
|
|
1138
|
+
await clearStageDeferral(redis, bookId, chapterId, sceneId, stage);
|
|
1139
|
+
|
|
928
1140
|
// T6: Start lease renewal for this actively dispatched job
|
|
929
1141
|
// Only started after confirming dispatcher returned dispatched:true
|
|
930
1142
|
// (not for cache hits, backpressure, or empty executor results)
|
|
@@ -1296,6 +1508,9 @@ async function markDispatchFailed(redis, bookId, chapterId, sceneId, stage, erro
|
|
|
1296
1508
|
* T6: Останавливает in-memory renewal timer, чтобы избежать утечки.
|
|
1297
1509
|
*/
|
|
1298
1510
|
async function cancelActiveDispatch(redis, bookId, chapterId, sceneId, stage, reason = 'cancelled') {
|
|
1511
|
+
// Stage 116 (D3): an explicit cancel means the work is wanted again — a
|
|
1512
|
+
// capacity deferral must not survive it.
|
|
1513
|
+
await clearStageDeferral(redis, bookId, chapterId, sceneId, stage);
|
|
1299
1514
|
const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
|
|
1300
1515
|
if (metadata?.dispatch_id) {
|
|
1301
1516
|
const result = await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
|
|
@@ -1383,9 +1598,33 @@ async function clearLeasesForScenes(redis, bookId, scenes) {
|
|
|
1383
1598
|
}
|
|
1384
1599
|
|
|
1385
1600
|
/**
|
|
1386
|
-
* Remove
|
|
1601
|
+
* Remove cancelled dispatches from GPU Hub.
|
|
1387
1602
|
* Kept here with dispatch cancellation so every caller uses the same auth and
|
|
1388
1603
|
* error-handling contract.
|
|
1604
|
+
*
|
|
1605
|
+
* Stage 122 (N1): the hub's `DELETE /queue/clear` accepts EITHER a
|
|
1606
|
+
* `dispatch_id` (per-job) OR a `book_id` (whole-book lane sweep). Before this
|
|
1607
|
+
* stage the function only honoured the per-id form; a caller that passed an
|
|
1608
|
+
* EMPTY id list together with `book_id` (the regenerate post-release sweep,
|
|
1609
|
+
* audit 121 · N1) fell straight through the id loop and issued ZERO requests —
|
|
1610
|
+
* a silent NO-OP that left orphan lane entries behind while looking like a
|
|
1611
|
+
* successful sweep.
|
|
1612
|
+
*
|
|
1613
|
+
* The contract now is:
|
|
1614
|
+
* - ids present → one `?dispatch_id=` DELETE per id (unchanged);
|
|
1615
|
+
* - no ids + book_id → exactly ONE `?book_id=` DELETE (the whole-book sweep
|
|
1616
|
+
* the hub already supports and cache/delete paths use);
|
|
1617
|
+
* - neither → NO request at all (never a fake HTTP call).
|
|
1618
|
+
*
|
|
1619
|
+
* A book-scoped sweep is checked against the hub's echoed `scope.book_id`:
|
|
1620
|
+
* if the hub answers `ok` but echoes a DIFFERENT scope (or none at all while
|
|
1621
|
+
* reporting a full wipe) it did not honour our ownership and other books may
|
|
1622
|
+
* have been touched — that is counted as `failed` and warned, never as a
|
|
1623
|
+
* success. Failures never throw (best-effort contract, callers keep claims).
|
|
1624
|
+
*
|
|
1625
|
+
* @returns {Promise<{requested:number, cleared:number, failed:number}>}
|
|
1626
|
+
* `requested` = number of hub requests actually attempted (1 for a
|
|
1627
|
+
* book-scoped sweep, ids.length for per-id, 0 when there is nothing to do).
|
|
1389
1628
|
*/
|
|
1390
1629
|
async function clearHubDispatches(dispatchIds, options = {}) {
|
|
1391
1630
|
const ids = [...new Set((dispatchIds || []).filter(Boolean))];
|
|
@@ -1396,16 +1635,59 @@ async function clearHubDispatches(dispatchIds, options = {}) {
|
|
|
1396
1635
|
const context = options.context || 'DISPATCH';
|
|
1397
1636
|
const reportWarning = typeof options.warn === 'function' ? options.warn : warn;
|
|
1398
1637
|
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
1638
|
+
const bookId = options.book_id || options.bookId || null;
|
|
1399
1639
|
let cleared = 0;
|
|
1400
1640
|
let failed = 0;
|
|
1401
1641
|
|
|
1642
|
+
const authHeaders = () => {
|
|
1643
|
+
const headers = {};
|
|
1644
|
+
if (apiKey) headers['x-api-key'] = apiKey;
|
|
1645
|
+
return headers;
|
|
1646
|
+
};
|
|
1647
|
+
|
|
1648
|
+
// ── Book-scoped sweep (no explicit dispatch ids) ──────────────────────
|
|
1649
|
+
// Exactly one request; the hub filters by record.book_id so only this
|
|
1650
|
+
// book's queue/running/result entries are removed.
|
|
1651
|
+
if (ids.length === 0 && bookId) {
|
|
1652
|
+
try {
|
|
1653
|
+
const response = await fetchImpl(
|
|
1654
|
+
`${hubUrl}/queue/clear?book_id=${encodeURIComponent(bookId)}`,
|
|
1655
|
+
{ method: 'DELETE', headers: authHeaders() }
|
|
1656
|
+
);
|
|
1657
|
+
if (!response.ok) {
|
|
1658
|
+
failed++;
|
|
1659
|
+
reportWarning(`${context}: GPU hub book cleanup returned ${response.status} for ${bookId}`);
|
|
1660
|
+
} else {
|
|
1661
|
+
// Verify the hub honoured OUR scope. A mismatched echo means it
|
|
1662
|
+
// may have swept a different book (or everything) — that is a
|
|
1663
|
+
// failure, not a success we can report.
|
|
1664
|
+
let enforcedScope = true;
|
|
1665
|
+
try {
|
|
1666
|
+
const body = typeof response.json === 'function' ? await response.json() : null;
|
|
1667
|
+
if (body && body.scope && body.scope.book_id != null && body.scope.book_id !== bookId) {
|
|
1668
|
+
enforcedScope = false;
|
|
1669
|
+
}
|
|
1670
|
+
} catch (_) { /* unparseable body — trust the ok status */ }
|
|
1671
|
+
if (!enforcedScope) {
|
|
1672
|
+
failed++;
|
|
1673
|
+
reportWarning(`${context}: GPU hub book cleanup echoed a foreign scope for ${bookId} — treated as failed`);
|
|
1674
|
+
} else {
|
|
1675
|
+
cleared++;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
} catch (err) {
|
|
1679
|
+
failed++;
|
|
1680
|
+
reportWarning(`${context}: GPU hub book cleanup failed for ${bookId}: ${err.message}`);
|
|
1681
|
+
}
|
|
1682
|
+
return { requested: 1, cleared, failed };
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// ── Per-dispatch cleanup (unchanged) ─────────────────────────────────
|
|
1402
1686
|
for (const dispatchId of ids) {
|
|
1403
1687
|
try {
|
|
1404
|
-
const headers = {};
|
|
1405
|
-
if (apiKey) headers['x-api-key'] = apiKey;
|
|
1406
1688
|
const response = await fetchImpl(
|
|
1407
1689
|
`${hubUrl}/queue/clear?dispatch_id=${encodeURIComponent(dispatchId)}`,
|
|
1408
|
-
{ method: 'DELETE', headers }
|
|
1690
|
+
{ method: 'DELETE', headers: authHeaders() }
|
|
1409
1691
|
);
|
|
1410
1692
|
if (!response.ok) {
|
|
1411
1693
|
failed++;
|
|
@@ -1781,6 +2063,29 @@ module.exports = {
|
|
|
1781
2063
|
LEASE_TTLS,
|
|
1782
2064
|
QUOTAS,
|
|
1783
2065
|
|
|
2066
|
+
// Stage 116 (D3) / Stage 118 (M2/M4): bounded deferral (retry/backoff) —
|
|
2067
|
+
// the stage is not retried on every 5 s scheduler tick while it cannot
|
|
2068
|
+
// enqueue. Stage 118: the capacity and the idempotent bucket have their
|
|
2069
|
+
// own windows, and the taxonomy is shared with the stage executors.
|
|
2070
|
+
DEFERRED_STAGE_KEY_PREFIX,
|
|
2071
|
+
DEFERRED_STAGE_TTL_S,
|
|
2072
|
+
DEFERRED_IDEMPOTENT_TTL_S,
|
|
2073
|
+
deferredStageKey,
|
|
2074
|
+
isStageDeferred,
|
|
2075
|
+
deferStage,
|
|
2076
|
+
clearStageDeferral,
|
|
2077
|
+
clearDeferralsForBook,
|
|
2078
|
+
clearDeferralsForScene,
|
|
2079
|
+
deferralWindowFor,
|
|
2080
|
+
encodeKeyComponent,
|
|
2081
|
+
isDeferrableOutcome,
|
|
2082
|
+
isCapacityOutcome,
|
|
2083
|
+
isIdempotentOutcome,
|
|
2084
|
+
isFenceRejectedOutcome,
|
|
2085
|
+
CAPACITY_OUTCOMES,
|
|
2086
|
+
IDEMPOTENT_OUTCOMES,
|
|
2087
|
+
FENCE_REJECTED_OUTCOMES,
|
|
2088
|
+
|
|
1784
2089
|
// PW-2: dispatch identity generation (crypto-hardened)
|
|
1785
2090
|
generateDispatchToken
|
|
1786
2091
|
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// DISPATCH OUTCOMES — the shared taxonomy of "nothing was enqueued"
|
|
3
|
+
//
|
|
4
|
+
// Stage 118 (audit 117 · D3/M4/M5): the classification of an executor
|
|
5
|
+
// outcome lives in ONE module so the dispatch engine (runtime tier) and the
|
|
6
|
+
// stage executors (orchestration tier) cannot drift apart.
|
|
7
|
+
//
|
|
8
|
+
// Buckets
|
|
9
|
+
// ───────
|
|
10
|
+
// • capacity — the GPU capacity is not there right now:
|
|
11
|
+
// 'no_worker_available' | 'duplicate_pending' | 'ambiguous_post'
|
|
12
|
+
// ⇒ a BOUNDED retry (deferral window) is the right response.
|
|
13
|
+
//
|
|
14
|
+
// • idempotent — the logical task is ALREADY COMPLETE (the completion marker
|
|
15
|
+
// exists): 'already_completed'.
|
|
16
|
+
// This is NOT a capacity problem and MUST NOT be classified as
|
|
17
|
+
// one (audit 117 · M4): mixing it into the capacity bucket
|
|
18
|
+
// turned "this task will never run" into an endless retry
|
|
19
|
+
// loop. It gets its own, much longer backoff window and its
|
|
20
|
+
// own reporting class.
|
|
21
|
+
//
|
|
22
|
+
// The host side (@animastor/generation → backend/src/helpers/queue-idempotency)
|
|
23
|
+
// mirrors these sets for the transport-level reasons it produces; the two
|
|
24
|
+
// lists must stay in sync (see queue-idempotency.cjs · CAPACITY_REASONS).
|
|
25
|
+
// ======================================================
|
|
26
|
+
|
|
27
|
+
const CAPACITY_OUTCOMES = new Set([
|
|
28
|
+
'no_worker_available', // no live, type-compatible worker
|
|
29
|
+
'duplicate_pending', // the job is already pending in the lane
|
|
30
|
+
'ambiguous_post', // the POST outcome was ambiguous (the hub may have taken the task)
|
|
31
|
+
// Stage 129: the intent-fence step could not run (Redis refused the atomic
|
|
32
|
+
// path / the client cannot run it). Nothing was enqueued and the send IS
|
|
33
|
+
// retryable, so it belongs to the bounded-retry bucket.
|
|
34
|
+
'intent_receipt_unavailable',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const IDEMPOTENT_OUTCOMES = new Set([
|
|
38
|
+
'already_completed', // the logical task is already complete (artifact missing)
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fence rejections (Stage 129, audit 128 · F5): the backend refused this
|
|
43
|
+
* dispatch because it belongs to a generation the intent record already
|
|
44
|
+
* superseded, or because the dispatch/intent pair is inconsistent.
|
|
45
|
+
*
|
|
46
|
+
* Retrying the SAME generation can never succeed, so they are NOT retryable —
|
|
47
|
+
* but they must also not fall into the historic "immediate retry on the next
|
|
48
|
+
* tick" bucket, which would turn a stale send into a 5-second dispatch →
|
|
49
|
+
* cancel → rollback loop. They get the long (idempotent) deferral window and
|
|
50
|
+
* stay visible in the logs/metrics.
|
|
51
|
+
*/
|
|
52
|
+
const FENCE_REJECTED_OUTCOMES = new Set([
|
|
53
|
+
'stale_intent',
|
|
54
|
+
'receipt_conflict',
|
|
55
|
+
'intent_not_active',
|
|
56
|
+
'intent_corrupt',
|
|
57
|
+
'intent_missing',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/** Is this outcome a CAPACITY gap (bounded retry with a short window)? */
|
|
61
|
+
function isCapacityOutcome(reason) {
|
|
62
|
+
return CAPACITY_OUTCOMES.has(String(reason || ''));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Is this outcome an idempotent suppression (the task is already done)? */
|
|
66
|
+
function isIdempotentOutcome(reason) {
|
|
67
|
+
return IDEMPOTENT_OUTCOMES.has(String(reason || ''));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Is this outcome a fence rejection (terminal for this generation)? */
|
|
71
|
+
function isFenceRejectedOutcome(reason) {
|
|
72
|
+
return FENCE_REJECTED_OUTCOMES.has(String(reason || ''));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Is this outcome deferrable at all (capacity, idempotent OR fence-rejected)? */
|
|
76
|
+
function isDeferrableOutcome(reason) {
|
|
77
|
+
return isCapacityOutcome(reason) || isIdempotentOutcome(reason) || isFenceRejectedOutcome(reason);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Classify the reasons why NOTHING was enqueued for a stage.
|
|
82
|
+
*
|
|
83
|
+
* Stage 118 (audit 117 · M3): the Stage 116 implementation required EXACTLY
|
|
84
|
+
* ONE distinct reason (`notSentReasons.size === 1`). A scene whose units
|
|
85
|
+
* failed for two DIFFERENT deferrable reasons (e.g. one unit gated by
|
|
86
|
+
* "no live worker" and another suppressed as "duplicate pending") was
|
|
87
|
+
* therefore NOT deferred and kept the 5-second dispatch/cancel churn.
|
|
88
|
+
*
|
|
89
|
+
* The rule now: EVERY reason must be deferrable; the most frequent one is
|
|
90
|
+
* reported. A single non-deferrable reason (a real failure) keeps the
|
|
91
|
+
* historic semantics (an immediate retry on the next tick) — the
|
|
92
|
+
* audit-6929ba5 contract depends on it.
|
|
93
|
+
*
|
|
94
|
+
* @param {Map<string, number>|Object<string, number>} reasons
|
|
95
|
+
* @returns {string|null} the dominant deferrable reason, or null.
|
|
96
|
+
*/
|
|
97
|
+
function classifyNotSentReasons(reasons) {
|
|
98
|
+
const entries = reasons instanceof Map
|
|
99
|
+
? [...reasons.entries()]
|
|
100
|
+
: Object.entries(reasons || {});
|
|
101
|
+
if (entries.length === 0) return null;
|
|
102
|
+
if (!entries.every(([reason]) => isDeferrableOutcome(reason))) return null;
|
|
103
|
+
let dominant = null;
|
|
104
|
+
let dominantCount = -1;
|
|
105
|
+
for (const [reason, count] of entries) {
|
|
106
|
+
const n = Number(count) || 0;
|
|
107
|
+
if (n > dominantCount) {
|
|
108
|
+
dominant = reason;
|
|
109
|
+
dominantCount = n;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return dominant;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
CAPACITY_OUTCOMES,
|
|
117
|
+
IDEMPOTENT_OUTCOMES,
|
|
118
|
+
FENCE_REJECTED_OUTCOMES,
|
|
119
|
+
isCapacityOutcome,
|
|
120
|
+
isIdempotentOutcome,
|
|
121
|
+
isFenceRejectedOutcome,
|
|
122
|
+
isDeferrableOutcome,
|
|
123
|
+
classifyNotSentReasons,
|
|
124
|
+
};
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
// a cancelled dispatch still owns on the GPU Hub
|
|
31
31
|
// (orchestrator resetScenes cancel leg,
|
|
32
32
|
// reconciliation RELEASE_STALE_LEASE recovery leg).
|
|
33
|
+
// Accepts EITHER an array of dispatch ids (one
|
|
34
|
+
// `?dispatch_id=` DELETE per id) OR an empty list with
|
|
35
|
+
// `options.book_id` (Stage 122 · N1 — a single
|
|
36
|
+
// book-scoped `?book_id=` sweep, used by the
|
|
37
|
+
// regenerate post-release lane sweep; audit 121 found
|
|
38
|
+
// the empty-list + book_id form was a silent no-op).
|
|
33
39
|
// Returns { requested, cleared, failed }; hub
|
|
34
40
|
// unavailability is non-fatal per-id (warn + count),
|
|
35
41
|
// never throws for a single failed id.
|
|
@@ -82,6 +82,12 @@ const OPS = [
|
|
|
82
82
|
'registry.registerSceneImageRedis',
|
|
83
83
|
'registry.registerSceneVideoRedis',
|
|
84
84
|
|
|
85
|
+
// Stage 122 (N2 · audit 121): the video send path frees an ORPHANED
|
|
86
|
+
// completion marker through this op (Redis-domain, `redis`-first like the
|
|
87
|
+
// registry.*Redis ops) — the orchestration tier cannot require the backend
|
|
88
|
+
// queue-idempotency helper directly.
|
|
89
|
+
'idem.releaseTaskDoneIfOrphan',
|
|
90
|
+
|
|
85
91
|
'filesystem.getSceneAudioPath',
|
|
86
92
|
|
|
87
93
|
'listBooks',
|
|
@@ -157,7 +157,7 @@ async function checkOrphanImageState(redis, bookId, chapterId, sceneId) {
|
|
|
157
157
|
// Orphan-check uses the canonical book build identity (audit d9d67a3).
|
|
158
158
|
const buildId = resolveBookBuildId(bookId);
|
|
159
159
|
const imageModule = lazyHostBinding('media.image');
|
|
160
|
-
|
|
160
|
+
let imageInfo = imageModule.resolveCanonicalSceneImage(
|
|
161
161
|
artifactRoot(),
|
|
162
162
|
buildId,
|
|
163
163
|
bookId,
|
|
@@ -165,6 +165,31 @@ async function checkOrphanImageState(redis, bookId, chapterId, sceneId) {
|
|
|
165
165
|
sceneId,
|
|
166
166
|
);
|
|
167
167
|
|
|
168
|
+
// ── Stage 143a (phase 2a · B2) ───────────────────────────────────
|
|
169
|
+
// Legacy scan first (byte-identical legacy behavior). A versioned-only
|
|
170
|
+
// build never writes the canonical file, so with the scan empty we ask
|
|
171
|
+
// the image facade for the published pointer of this scene build — the
|
|
172
|
+
// facade is flag-gated, flag OFF answers null with ZERO PG calls.
|
|
173
|
+
//
|
|
174
|
+
// An UNREADABLE pointer store ABORTS this reconcile pass (logged,
|
|
175
|
+
// `pass_incomplete`) instead of being read as "no publication": that
|
|
176
|
+
// reading would recommend regenerating a scene that is already
|
|
177
|
+
// published. No orphan issue is emitted on the aborted pass, so no
|
|
178
|
+
// regeneration is launched.
|
|
179
|
+
if (!imageInfo) {
|
|
180
|
+
try {
|
|
181
|
+
if (typeof imageModule.resolveVersionedSceneProof === 'function') {
|
|
182
|
+
imageInfo = await imageModule.resolveVersionedSceneProof(
|
|
183
|
+
artifactRoot(), buildId, bookId, chapterId, sceneId);
|
|
184
|
+
}
|
|
185
|
+
} catch (pgErr) {
|
|
186
|
+
error(`versioned image proof unavailable for ${bookId}/${chapterId}/${sceneId}: ${pgErr.message} — pass skipped, no regeneration launched`);
|
|
187
|
+
const err = new Error(`reconcile_pass_incomplete: image proof unavailable for ${bookId}/${chapterId}/${sceneId} (${pgErr.message})`);
|
|
188
|
+
err.code = 'pass_incomplete';
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
168
193
|
if (!imageInfo) {
|
|
169
194
|
return {
|
|
170
195
|
type: 'orphan_image_state',
|
|
@@ -251,11 +276,37 @@ async function checkOrphanAssets(redis, bookId, chapterId, sceneId) {
|
|
|
251
276
|
try {
|
|
252
277
|
await fs.access(assets.image.path);
|
|
253
278
|
} catch (err) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
279
|
+
// ── Stage 143a (phase 2a · B2) ───────────────────────────
|
|
280
|
+
// With the flag ON a published pointer is the authority: a LIVE
|
|
281
|
+
// versioned file for this scene build explains the missing
|
|
282
|
+
// canonical file (versioned-only build) → no issue is emitted.
|
|
283
|
+
// Flag OFF ⇒ the facade answers null with ZERO PG calls and the
|
|
284
|
+
// legacy issue is reported exactly as before.
|
|
285
|
+
// An UNREADABLE pointer store aborts the pass (logged) instead of
|
|
286
|
+
// emitting a regeneration recommendation on a guess.
|
|
287
|
+
let coveredByPointer = false;
|
|
288
|
+
const imageModule = lazyHostBinding('media.image');
|
|
289
|
+
try {
|
|
290
|
+
if (typeof imageModule.resolveVersionedSceneProof === 'function') {
|
|
291
|
+
const buildId = resolveBookBuildId(bookId);
|
|
292
|
+
coveredByPointer = !!(await imageModule.resolveVersionedSceneProof(
|
|
293
|
+
artifactRoot(), buildId, bookId, chapterId, sceneId));
|
|
294
|
+
}
|
|
295
|
+
} catch (pgErr) {
|
|
296
|
+
error(`versioned image proof unavailable for ${bookId}/${chapterId}/${sceneId}: ${pgErr.message} — pass skipped, no recommendation emitted`);
|
|
297
|
+
const e = new Error(`reconcile_pass_incomplete: image proof unavailable for ${bookId}/${chapterId}/${sceneId} (${pgErr.message})`);
|
|
298
|
+
e.code = 'pass_incomplete';
|
|
299
|
+
throw e;
|
|
300
|
+
}
|
|
301
|
+
if (coveredByPointer) {
|
|
302
|
+
log(`missing_image_file suppressed: ${bookId}/${chapterId}/${sceneId} is covered by a published versioned pointer`);
|
|
303
|
+
} else {
|
|
304
|
+
orphanAssets.push({
|
|
305
|
+
type: 'missing_image_file',
|
|
306
|
+
scene: { bookId, chapterId, sceneId },
|
|
307
|
+
path: assets.image.path
|
|
308
|
+
});
|
|
309
|
+
}
|
|
259
310
|
}
|
|
260
311
|
}
|
|
261
312
|
|
|
@@ -388,6 +388,7 @@ async function tick(redis, loadedBooks = {}) {
|
|
|
388
388
|
throttled: 0,
|
|
389
389
|
skipped: 0,
|
|
390
390
|
completed: 0,
|
|
391
|
+
deferred: 0,
|
|
391
392
|
errors: []
|
|
392
393
|
};
|
|
393
394
|
|
|
@@ -466,6 +467,10 @@ async function tick(redis, loadedBooks = {}) {
|
|
|
466
467
|
summary.skipped++;
|
|
467
468
|
} else if (result.throttled > 0) {
|
|
468
469
|
summary.throttled += result.throttled;
|
|
470
|
+
} else if (result.deferred > 0) {
|
|
471
|
+
// Stage 118 (M9): a bounded deferral is a normal, quiet
|
|
472
|
+
// state — it must not pollute the tick error list.
|
|
473
|
+
summary.deferred += result.deferred;
|
|
469
474
|
} else if (result.reason === 'backpressure' || result.reason === 'throttled') {
|
|
470
475
|
summary.throttled++;
|
|
471
476
|
} else if (result.success) {
|
|
@@ -480,7 +485,7 @@ async function tick(redis, loadedBooks = {}) {
|
|
|
480
485
|
}
|
|
481
486
|
|
|
482
487
|
log(`=== TICK COMPLETE ===`);
|
|
483
|
-
log(`Active: ${summary.totalActive}, Dispatched: ${summary.dispatched}, Skipped: ${summary.skipped}, Throttled: ${summary.throttled}, Completed: ${summary.completed}`);
|
|
488
|
+
log(`Active: ${summary.totalActive}, Dispatched: ${summary.dispatched}, Skipped: ${summary.skipped}, Throttled: ${summary.throttled}, Completed: ${summary.completed}, Deferred: ${summary.deferred}`);
|
|
484
489
|
|
|
485
490
|
if (summary.errors.length > 0) {
|
|
486
491
|
log(`Errors: ${summary.errors.length}`);
|
|
@@ -572,6 +577,9 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
|
|
|
572
577
|
let dispatched = 0;
|
|
573
578
|
let throttled = 0;
|
|
574
579
|
let anyCompleted = false;
|
|
580
|
+
// Stage 118 (M9): bounded deferrals are counted separately — they are
|
|
581
|
+
// neither a dispatch nor an error.
|
|
582
|
+
let deferred = 0;
|
|
575
583
|
const failures = [];
|
|
576
584
|
|
|
577
585
|
// Dispatch ALL eligible stages in this tick.
|
|
@@ -595,6 +603,12 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
|
|
|
595
603
|
// Stage finished inside the executor (already_done / cache_hit /
|
|
596
604
|
// no_segments) — not a failure, nothing to dispatch.
|
|
597
605
|
anyCompleted = true;
|
|
606
|
+
} else if (result.deferred) {
|
|
607
|
+
// Stage 118 (M9): a bounded deferral is NOT an error — nothing
|
|
608
|
+
// happened, the stage is waiting for capacity (or is already
|
|
609
|
+
// idempotently complete). Reporting it as a failure filled the
|
|
610
|
+
// tick summary with "errors" 12 times per minute per scene.
|
|
611
|
+
deferred++;
|
|
598
612
|
} else {
|
|
599
613
|
// Stage 50: раньше per-stage отказ (circuit_open, retry budget,
|
|
600
614
|
// lease, generating_transition_failed, …) молча проглатывался и
|
|
@@ -607,6 +621,9 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
|
|
|
607
621
|
if (dispatched > 0) {
|
|
608
622
|
return { dispatched, throttled, stages: stages.length };
|
|
609
623
|
}
|
|
624
|
+
if (deferred > 0 && failures.length === 0) {
|
|
625
|
+
return { dispatched: 0, throttled, deferred, stages: stages.length, success: true, reason: 'deferred' };
|
|
626
|
+
}
|
|
610
627
|
if (failures.length > 0) {
|
|
611
628
|
return { dispatched: 0, throttled, stages: stages.length, success: false, reason: failures.join(', ') };
|
|
612
629
|
}
|
|
@@ -158,6 +158,34 @@ async function getSceneFilesStatus(buildDir, bookId, chapterId, sceneId) {
|
|
|
158
158
|
result.image.exists = files.some(f => f.startsWith(imagePrefix) && f.endsWith('.png'));
|
|
159
159
|
} catch (_) {}
|
|
160
160
|
|
|
161
|
+
// ── Stage 143a (phase 2a · B1) ───────────────────────────────────
|
|
162
|
+
// A versioned-only build keeps its IU images under {build}/.versions/**,
|
|
163
|
+
// which this scan can never see. Only when the LEGACY scan came up empty
|
|
164
|
+
// do we ask the image facade for a published pointer; the facade is
|
|
165
|
+
// flag-gated, so flag OFF answers null with ZERO PostgreSQL calls and
|
|
166
|
+
// this function behaves exactly as before (condition 2).
|
|
167
|
+
//
|
|
168
|
+
// A pointer-store ERROR is logged and treated as "no proof" — image
|
|
169
|
+
// stays NOT existing. It is never read as "the file is there" (that
|
|
170
|
+
// would write a false READY) and never throws: this is an advisory
|
|
171
|
+
// checker whose five callers (checkSceneContentCache,
|
|
172
|
+
// restoreChunkStatusForScene, reconcileWindowStatuses, scene-restoration,
|
|
173
|
+
// rebuildWorkList) must keep running on a PG outage.
|
|
174
|
+
if (!result.image.exists) {
|
|
175
|
+
try {
|
|
176
|
+
const imageModule = lazyHostBinding('media.image');
|
|
177
|
+
if (imageModule && typeof imageModule.resolveVersionedSceneProof === 'function') {
|
|
178
|
+
const buildId = path.basename(path.resolve(buildDir));
|
|
179
|
+
const proof = await imageModule.resolveVersionedSceneProof(
|
|
180
|
+
OUTPUT_DIR, buildId, bookId, chapterId, sceneId);
|
|
181
|
+
if (proof) result.image.exists = true;
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
warn(`image pointer proof unavailable for ${bookId}/${chapterId}/${sceneId} (${err.message}) — image treated as absent`);
|
|
185
|
+
result.image.exists = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
161
189
|
// Check video file
|
|
162
190
|
const videoPath = path.join(buildDir, artifactNaming.sceneVideoName(bookId, chapterId, sceneId));
|
|
163
191
|
result.video.exists = fs.existsSync(videoPath);
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// Stage 116 (D3) — CAPACITY DEFERRAL (bounded retry/backoff)
|
|
3
|
+
// ======================================================
|
|
4
|
+
// Regression net for the Stage 115 defect D3: a stage that could not enqueue
|
|
5
|
+
// anything (no live compatible worker, or every job already pending in the
|
|
6
|
+
// lane) was re-dispatched on EVERY scheduler tick (SCHEDULER_TICK_MS = 5 s).
|
|
7
|
+
// Each attempt created a lease + dispatch metadata, the executor sent
|
|
8
|
+
// nothing, the dispatch was cancelled and the FSM was rolled back — an
|
|
9
|
+
// unbounded dispatch/cancel loop with lease churn, FSM churn and unbounded
|
|
10
|
+
// event-journal growth.
|
|
11
|
+
//
|
|
12
|
+
// The fix: a bounded per-stage deferral. While the deferral window is open
|
|
13
|
+
// the dispatch engine returns WITHOUT any side effect (no lease, no metadata,
|
|
14
|
+
// no FSM write, no journal event), the stage stays PENDING and recoverable,
|
|
15
|
+
// and the deferral is cleared by a successful dispatch or an explicit cancel.
|
|
16
|
+
//
|
|
17
|
+
// Isolated: in-memory Redis mock + stubbed host bindings. No production
|
|
18
|
+
// Redis/PG, no hub, no network.
|
|
19
|
+
|
|
20
|
+
const { expect } = require('chai');
|
|
21
|
+
const { createMockRedis } = require('./mocks/redis-mock');
|
|
22
|
+
const { bindHostModules, clearHostBindings } = require('../src/host/host-bindings');
|
|
23
|
+
|
|
24
|
+
const BOOK = 'test-stage116-book';
|
|
25
|
+
const CHAPTER = 'ch-stage116';
|
|
26
|
+
const SCENE = 'sc-stage116';
|
|
27
|
+
const BUILD = 'build_test-stage116-book';
|
|
28
|
+
|
|
29
|
+
describe('Stage 116 (D3): capacity deferral (bounded retry/backoff)', () => {
|
|
30
|
+
let redis;
|
|
31
|
+
let dispatchEngine;
|
|
32
|
+
let journalEvents;
|
|
33
|
+
let executorCalls;
|
|
34
|
+
let executorResult;
|
|
35
|
+
let rollbacks;
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
redis = createMockRedis();
|
|
39
|
+
journalEvents = [];
|
|
40
|
+
executorCalls = 0;
|
|
41
|
+
rollbacks = [];
|
|
42
|
+
executorResult = { dispatched: false, jobs: 0, reason: 'no_worker_available', retryable: true };
|
|
43
|
+
|
|
44
|
+
clearHostBindings();
|
|
45
|
+
bindHostModules({
|
|
46
|
+
journal: () => ({
|
|
47
|
+
appendSceneEvent: async (r, b, c, s, type, state, details) => {
|
|
48
|
+
journalEvents.push({ type, state, details });
|
|
49
|
+
return true;
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
seams: () => ({
|
|
53
|
+
getOrchestrationSeam: (name) => {
|
|
54
|
+
if (name === 'dispatchStage') {
|
|
55
|
+
return async () => {
|
|
56
|
+
executorCalls++;
|
|
57
|
+
return executorResult;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (name === 'rollbackStageToPending') {
|
|
61
|
+
return async (r, b, c, s, stage, buildId, reason) => {
|
|
62
|
+
rollbacks.push({ stage, reason });
|
|
63
|
+
return { changed: true };
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`[stage116-test] seam not wired: ${name}`);
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
dispatchEngine = require('../src/runtime/dispatch-engine');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
clearHostBindings();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const dispatch = () => dispatchEngine.dispatchStage(
|
|
79
|
+
redis, BOOK, CHAPTER, SCENE, 'image', { book_id: BOOK }, BUILD
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
it('a stage that enqueued nothing is deferred and reported as retryable', async () => {
|
|
83
|
+
const result = await dispatch();
|
|
84
|
+
expect(result.dispatched).to.equal(false);
|
|
85
|
+
expect(result.reason).to.equal('no_worker_available');
|
|
86
|
+
expect(result.retryable).to.equal(true);
|
|
87
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('a deferred stage is NOT re-dispatched on the next tick (no side effects)', async () => {
|
|
91
|
+
await dispatch(); // attempt 1 — really attempted
|
|
92
|
+
const callsAfterFirst = executorCalls;
|
|
93
|
+
const journalAfterFirst = journalEvents.length;
|
|
94
|
+
|
|
95
|
+
// the next scheduler tick (5 s later) must be a no-op
|
|
96
|
+
const second = await dispatch();
|
|
97
|
+
expect(second.dispatched).to.equal(false);
|
|
98
|
+
expect(second.reason).to.equal('deferred_capacity');
|
|
99
|
+
expect(second.retryable).to.equal(true);
|
|
100
|
+
expect(executorCalls).to.equal(callsAfterFirst); // executor NOT called
|
|
101
|
+
expect(journalEvents.length).to.equal(journalAfterFirst); // no journal event
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('100 scheduler ticks inside the deferral window ⇒ ONE attempt (bounded churn)', async () => {
|
|
105
|
+
await dispatch();
|
|
106
|
+
for (let i = 0; i < 99; i++) await dispatch();
|
|
107
|
+
expect(executorCalls).to.equal(1);
|
|
108
|
+
// the journal must not grow with the tick count (bounded, not unbounded)
|
|
109
|
+
const journalAfterLoop = journalEvents.length;
|
|
110
|
+
expect(journalAfterLoop).to.be.lessThan(10);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('the deferral window expires ⇒ the stage is dispatched again', async () => {
|
|
114
|
+
await dispatch();
|
|
115
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
|
|
116
|
+
// simulate the TTL elapsing (the mock keeps keys until told otherwise)
|
|
117
|
+
await redis.del(dispatchEngine.deferredStageKey(BOOK, CHAPTER, SCENE, 'image'));
|
|
118
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
|
|
119
|
+
|
|
120
|
+
executorResult = { dispatched: true, jobs: 2, reason: null };
|
|
121
|
+
const result = await dispatch();
|
|
122
|
+
expect(result.dispatched).to.equal(true);
|
|
123
|
+
expect(executorCalls).to.equal(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('a successful dispatch clears the deferral', async () => {
|
|
127
|
+
await dispatch(); // deferred
|
|
128
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
|
|
129
|
+
await redis.del(dispatchEngine.deferredStageKey(BOOK, CHAPTER, SCENE, 'image'));
|
|
130
|
+
executorResult = { dispatched: true, jobs: 1, reason: null };
|
|
131
|
+
await dispatch();
|
|
132
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('an explicit cancel clears the deferral (the user asked for the work)', async () => {
|
|
136
|
+
await dispatch();
|
|
137
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
|
|
138
|
+
await dispatchEngine.cancelActiveDispatch(redis, BOOK, CHAPTER, SCENE, 'image', 'user_cancel');
|
|
139
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('a deferred attempt does NOT consume the retry budget (it is not a failure)', async () => {
|
|
143
|
+
const budgetKey = `animastor:retry-budget:${BOOK}:${CHAPTER}:${SCENE}:image`;
|
|
144
|
+
await redis.set(budgetKey, '3');
|
|
145
|
+
await dispatch();
|
|
146
|
+
const after = await redis.get(budgetKey);
|
|
147
|
+
expect(after).to.equal('3'); // unchanged — no budget consumed
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('a deferred attempt is not reported as a failure (retryable, not fatal)', async () => {
|
|
151
|
+
const result = await dispatch();
|
|
152
|
+
expect(result.dispatched).to.equal(false);
|
|
153
|
+
expect(result.reason).to.equal('no_worker_available');
|
|
154
|
+
expect(result.retryable).to.equal(true);
|
|
155
|
+
// the dispatch engine must not have marked the stage as failed
|
|
156
|
+
expect(result.result && result.result.retryable).to.equal(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('the deferral is per stage — another stage of the same scene is not blocked', async () => {
|
|
160
|
+
await dispatch(); // image stage deferred
|
|
161
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
|
|
162
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'video')).to.equal(false);
|
|
163
|
+
expect(await dispatchEngine.isStageDeferred(redis, 'other-book', CHAPTER, SCENE, 'image')).to.equal(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('deferrable outcomes are classified explicitly (capacity only)', () => {
|
|
167
|
+
expect(dispatchEngine.isDeferrableOutcome('no_worker_available')).to.equal(true);
|
|
168
|
+
expect(dispatchEngine.isDeferrableOutcome('duplicate_pending')).to.equal(true);
|
|
169
|
+
expect(dispatchEngine.isDeferrableOutcome('ambiguous_post')).to.equal(true);
|
|
170
|
+
// Stage 118 (M4): 'already_completed' is NOT a capacity gap — the
|
|
171
|
+
// logical task is already complete. It stays deferrable (the stage
|
|
172
|
+
// must not spin) but in its OWN bucket with its own window.
|
|
173
|
+
expect(dispatchEngine.isCapacityOutcome('already_completed')).to.equal(false);
|
|
174
|
+
expect(dispatchEngine.isIdempotentOutcome('already_completed')).to.equal(true);
|
|
175
|
+
expect(dispatchEngine.isDeferrableOutcome('already_completed')).to.equal(true);
|
|
176
|
+
// the generic "sent nothing" outcomes keep their historic contract
|
|
177
|
+
// (immediate retry) — the audit-6929ba5 regressions depend on it
|
|
178
|
+
expect(dispatchEngine.isDeferrableOutcome('no_jobs_sent')).to.equal(false);
|
|
179
|
+
expect(dispatchEngine.isDeferrableOutcome('no_jobs_generated')).to.equal(false);
|
|
180
|
+
expect(dispatchEngine.isDeferrableOutcome('send_failed')).to.equal(false);
|
|
181
|
+
expect(dispatchEngine.isDeferrableOutcome('already_ready')).to.equal(false);
|
|
182
|
+
expect(dispatchEngine.isDeferrableOutcome('circuit_open')).to.equal(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('the deferral window is bounded (not a permanent block)', () => {
|
|
186
|
+
expect(dispatchEngine.DEFERRED_STAGE_TTL_S).to.be.a('number');
|
|
187
|
+
expect(dispatchEngine.DEFERRED_STAGE_TTL_S).to.be.greaterThan(0);
|
|
188
|
+
expect(dispatchEngine.DEFERRED_STAGE_TTL_S).to.be.lessThanOrEqual(600);
|
|
189
|
+
// the key lives under the runtime namespace (backend-owned family)
|
|
190
|
+
expect(dispatchEngine.DEFERRED_STAGE_KEY_PREFIX).to.equal('animastor:runtime:deferred:');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── Stage 118 (audit 117) ─────────────────────────────────────────────
|
|
194
|
+
describe('Stage 118: audit 117 corrections', () => {
|
|
195
|
+
it('M2: force mode does NOT bypass the deferral', async () => {
|
|
196
|
+
await dispatch(); // deferred (capacity)
|
|
197
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
|
|
198
|
+
const callsBefore = executorCalls;
|
|
199
|
+
// The force-dispatch flag lives for 120 s and is set for the WHOLE
|
|
200
|
+
// book by /regenerate — bypassing the backoff there reproduced the
|
|
201
|
+
// very churn D3 removes (≈24 full attempts per scene).
|
|
202
|
+
const forced = await dispatchEngine.dispatchStage(
|
|
203
|
+
redis, BOOK, CHAPTER, SCENE, 'image', { book_id: BOOK }, BUILD, { force: true }
|
|
204
|
+
);
|
|
205
|
+
expect(forced.dispatched).to.equal(false);
|
|
206
|
+
expect(forced.reason).to.equal('deferred_capacity');
|
|
207
|
+
expect(forced.deferred).to.equal(true);
|
|
208
|
+
expect(executorCalls).to.equal(callsBefore); // no side effect
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('M4: the idempotent suppression gets its OWN (longer) window', async () => {
|
|
212
|
+
executorResult = { dispatched: false, jobs: 0, reason: 'already_completed', retryable: true };
|
|
213
|
+
await dispatch();
|
|
214
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
|
|
215
|
+
expect(dispatchEngine.deferralWindowFor('already_completed'))
|
|
216
|
+
.to.equal(dispatchEngine.DEFERRED_IDEMPOTENT_TTL_S);
|
|
217
|
+
expect(dispatchEngine.deferralWindowFor('no_worker_available'))
|
|
218
|
+
.to.equal(dispatchEngine.DEFERRED_STAGE_TTL_S);
|
|
219
|
+
expect(dispatchEngine.DEFERRED_IDEMPOTENT_TTL_S)
|
|
220
|
+
.to.be.greaterThan(dispatchEngine.DEFERRED_STAGE_TTL_S);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('M1: a book_id containing ":" cannot reach into another book key space', async () => {
|
|
224
|
+
const bookA = 'a';
|
|
225
|
+
const bookB = 'a:b';
|
|
226
|
+
const chapter = 'ch';
|
|
227
|
+
const scene = 'sc';
|
|
228
|
+
// the deferral key of book 'a' / chapter 'b' must not collide
|
|
229
|
+
// with the key of book 'a:b'
|
|
230
|
+
executorResult = { dispatched: false, jobs: 0, reason: 'no_worker_available', retryable: true };
|
|
231
|
+
await dispatchEngine.dispatchStage(redis, bookA, chapter, scene, 'image', { book_id: bookA }, BUILD);
|
|
232
|
+
expect(await dispatchEngine.isStageDeferred(redis, bookA, chapter, scene, 'image')).to.equal(true);
|
|
233
|
+
expect(await dispatchEngine.isStageDeferred(redis, bookB, chapter, scene, 'image')).to.equal(false);
|
|
234
|
+
expect(dispatchEngine.deferredStageKey(bookA, chapter, scene, 'image'))
|
|
235
|
+
.to.not.equal(dispatchEngine.deferredStageKey(bookB, chapter, scene, 'image'));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('M2: an explicit intent clears the backoff of a book / a scene', async () => {
|
|
239
|
+
await dispatch();
|
|
240
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
|
|
241
|
+
const cleared = await dispatchEngine.clearDeferralsForBook(redis, BOOK);
|
|
242
|
+
expect(cleared).to.be.greaterThan(0);
|
|
243
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
|
|
244
|
+
|
|
245
|
+
await dispatch();
|
|
246
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
|
|
247
|
+
await dispatchEngine.clearDeferralsForScene(redis, BOOK, CHAPTER, SCENE);
|
|
248
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
|
|
249
|
+
// a sibling book is untouched
|
|
250
|
+
await dispatchEngine.dispatchStage(redis, 'other-book', CHAPTER, SCENE, 'image', { book_id: 'other-book' }, BUILD);
|
|
251
|
+
await dispatchEngine.clearDeferralsForBook(redis, 'other-book');
|
|
252
|
+
expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('M3: several deferrable reasons are classified (the dominant one is reported)', () => {
|
|
256
|
+
const { classifyNotSentReasons } = require('../src/runtime/dispatch-outcomes');
|
|
257
|
+
expect(classifyNotSentReasons(new Map([['no_worker_available', 3], ['duplicate_pending', 1]])))
|
|
258
|
+
.to.equal('no_worker_available');
|
|
259
|
+
// a real failure keeps the historic semantics
|
|
260
|
+
expect(classifyNotSentReasons(new Map([['no_worker_available', 3], ['send_failed', 1]])))
|
|
261
|
+
.to.equal(null);
|
|
262
|
+
expect(classifyNotSentReasons(new Map())).to.equal(null);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|