@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.
- package/CHANGELOG.md +107 -0
- package/package.json +3 -2
- 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
|
@@ -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
|
+
});
|