@animastor/orchestration 0.1.3 → 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.
@@ -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
+ });