@animastor/orchestration 0.1.7 → 0.2.0

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.
@@ -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
+ });