@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.
- package/package.json +1 -1
- package/src/orchestration/scene-callbacks.js +57 -5
- 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 +63 -9
- package/src/runtime/runtime-scheduler.js +18 -1
- package/src/runtime/scene-window.js +28 -0
- package/test/artifact-root-binding.test.js +174 -0
- package/test/capacity-deferral.test.js +265 -0
|
@@ -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
|
+
});
|