@animastor/orchestration 0.1.7 → 0.3.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 +156 -0
- package/package.json +3 -2
- package/src/index.js +1 -0
- package/src/orchestration/orchestrator.js +18 -8
- package/src/orchestration/scene-callbacks.js +55 -3
- package/src/orchestration/scene-orchestrator.js +91 -14
- package/src/runtime/active-scenes-index.js +18 -2
- package/src/runtime/command-gate.js +270 -0
- package/src/runtime/dispatch-engine.js +322 -6
- 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/progress-events-port.js +6 -0
- package/src/runtime/reconciliation-engine.js +206 -172
- package/src/runtime/runtime-scheduler.js +88 -61
- package/src/runtime/scene-window.js +51 -2
- package/test/capacity-deferral.test.js +265 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// COMMAND GATE — «генерация только по явной команде»
|
|
3
|
+
// ======================================================
|
|
4
|
+
// Правило владельца (2026-10-31, разбор reports/2026-10-31/
|
|
5
|
+
// auto-image-task-ba-incident.md §6/§8): GPU-генерация запускается ТОЛЬКО
|
|
6
|
+
// кнопками «аудио» / «картинки» / «всё». Никаких автозапусков следующих
|
|
7
|
+
// стадий, никаких фоновых запусков, задачи только на включённые слои.
|
|
8
|
+
//
|
|
9
|
+
// ИНВАРИАНТ. Сцена×стадия попадает в active index / dispatch ТОЛЬКО когда
|
|
10
|
+
// одновременно:
|
|
11
|
+
// 1. есть АКТИВНАЯ задача реестра (generation-progress, создаётся явной
|
|
12
|
+
// командой POST /regenerate), в targets которой есть эта сцена и чей
|
|
13
|
+
// type — эта стадия;
|
|
14
|
+
// 2. слой стадии включён в layer-config книги;
|
|
15
|
+
// 3. у книги нет отмены (cancel flag в Redis / tombstone в PG).
|
|
16
|
+
//
|
|
17
|
+
// Этот модуль — единственное место, где инвариант вычисляется. Им пользуются:
|
|
18
|
+
// • active-scenes-index.addActiveScene — запись в active index;
|
|
19
|
+
// • runtime-scheduler.shouldScheduleAssets — выбор стадий для dispatch;
|
|
20
|
+
// • reconciliation-engine — ремонтные фиксы, которые раньше ставили работу;
|
|
21
|
+
// • scene-window.startScene — оконный старт сцены.
|
|
22
|
+
//
|
|
23
|
+
// Реестр задач читается ТОЛЬКО через ProgressEventsPort (O-5), отмена — через
|
|
24
|
+
// PersistencePort (O-2) и cancel flag scene-window; Redis-ключи хост-сервисов
|
|
25
|
+
// здесь не появляются. Ошибка чтения реестра — fail-closed (стадия НЕ
|
|
26
|
+
// авторизована): без доказанной команды работы нет.
|
|
27
|
+
//
|
|
28
|
+
// Docs: animastor-docs/architecture/COMMAND_ONLY_GENERATION.md
|
|
29
|
+
// ======================================================
|
|
30
|
+
|
|
31
|
+
const mediaRegistry = require('@animastor/generation').mediaRegistry;
|
|
32
|
+
const { lazyHostBinding } = require('../host/host-bindings');
|
|
33
|
+
const state = lazyHostBinding('state');
|
|
34
|
+
const progressEventsOp = require('./progress-events-port').progressEventsOp;
|
|
35
|
+
const persist = require('./persistence-port').persist;
|
|
36
|
+
|
|
37
|
+
const logPrefix = '[COMMAND-GATE]';
|
|
38
|
+
|
|
39
|
+
function log(msg) {
|
|
40
|
+
console.log(`${logPrefix} ${msg}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function warn(msg) {
|
|
44
|
+
console.warn(`${logPrefix} ⚠️ ${msg}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ======================================================
|
|
48
|
+
// LAYER CONFIG
|
|
49
|
+
// ======================================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the book layer config (Redis `animastor:layer-config:<book>`).
|
|
53
|
+
* Missing/broken config → every registered layer enabled (registry default).
|
|
54
|
+
*/
|
|
55
|
+
async function getLayerConfig(redis, bookId) {
|
|
56
|
+
const layerRaw = await redis.get(`animastor:layer-config:${bookId}`);
|
|
57
|
+
if (layerRaw) {
|
|
58
|
+
try { return JSON.parse(layerRaw); } catch (_) { /* fall through to defaults */ }
|
|
59
|
+
}
|
|
60
|
+
const defaults = {};
|
|
61
|
+
for (const t of mediaRegistry.listMediaTypes()) { defaults[`${t}_enabled`] = true; }
|
|
62
|
+
return defaults;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isLayerEnabled(layerCfg, stage) {
|
|
66
|
+
return !layerCfg || layerCfg[`${stage}_enabled`] !== false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ======================================================
|
|
70
|
+
// CANCELLATION
|
|
71
|
+
// ======================================================
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True if the user explicitly cancelled generation for this book (Redis
|
|
75
|
+
* cancel flag and/or PG tombstone). A PG error reads as "not cancelled"
|
|
76
|
+
* (the Redis flag is still honoured) — the same semantics the reconcile
|
|
77
|
+
* resurrection guard always had.
|
|
78
|
+
*/
|
|
79
|
+
async function isBookCancelled(redis, bookId) {
|
|
80
|
+
try {
|
|
81
|
+
// Lazy: scene-window requires active-scenes-index, which requires
|
|
82
|
+
// this module — resolve at call time to keep the module graph acyclic.
|
|
83
|
+
if (await require('./scene-window').isCancelled(redis, bookId)) return true;
|
|
84
|
+
} catch (_) { /* flag unreadable — fall through to tombstone */ }
|
|
85
|
+
try {
|
|
86
|
+
return !!(await persist('cancel.isCancelled')(bookId));
|
|
87
|
+
} catch (_) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ======================================================
|
|
93
|
+
// AUTHORIZATION
|
|
94
|
+
// ======================================================
|
|
95
|
+
|
|
96
|
+
/** Per-pass memo (one scheduler tick / one reconcile pass). */
|
|
97
|
+
function createGateCache() {
|
|
98
|
+
return { layer: new Map(), cancelled: new Map() };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function memo(cache, bucket, bookId, fn) {
|
|
102
|
+
if (!cache) return fn();
|
|
103
|
+
if (cache[bucket].has(bookId)) return cache[bucket].get(bookId);
|
|
104
|
+
const value = await fn();
|
|
105
|
+
cache[bucket].set(bookId, value);
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const EMPTY_TASK_STATE = Object.freeze({ managed: false, activeTypes: new Set() });
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Which stages of one scene are authorized by an explicit command right now.
|
|
113
|
+
*
|
|
114
|
+
* @returns {Promise<{stages:Set<string>, denied:Object<string,string>,
|
|
115
|
+
* cancelled:boolean, layerCfg:object, taskState:{managed:boolean, activeTypes:Set<string>}}>}
|
|
116
|
+
* `denied[stage]` ∈ 'cancelled' | 'no_active_task' | 'layer_disabled'.
|
|
117
|
+
*/
|
|
118
|
+
async function getAuthorizedStages(redis, bookId, chapterId, sceneId, cache = null) {
|
|
119
|
+
const cancelled = await memo(cache, 'cancelled', bookId, () => isBookCancelled(redis, bookId));
|
|
120
|
+
const layerCfg = await memo(cache, 'layer', bookId, () => getLayerConfig(redis, bookId));
|
|
121
|
+
|
|
122
|
+
let taskState = EMPTY_TASK_STATE;
|
|
123
|
+
try {
|
|
124
|
+
const ts = await progressEventsOp('getSceneTaskState')(redis, bookId, chapterId, sceneId);
|
|
125
|
+
if (ts && ts.activeTypes instanceof Set) taskState = ts;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// Fail-closed: an unreadable registry proves no command.
|
|
128
|
+
warn(`task registry unreadable for ${bookId}/${chapterId}/${sceneId}: ${err.message} — no stage authorized`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const stages = new Set();
|
|
132
|
+
const denied = {};
|
|
133
|
+
for (const stage of mediaRegistry.listMediaTypes()) {
|
|
134
|
+
if (cancelled) { denied[stage] = 'cancelled'; continue; }
|
|
135
|
+
if (!taskState.activeTypes.has(stage)) { denied[stage] = 'no_active_task'; continue; }
|
|
136
|
+
if (!isLayerEnabled(layerCfg, stage)) { denied[stage] = 'layer_disabled'; continue; }
|
|
137
|
+
stages.add(stage);
|
|
138
|
+
}
|
|
139
|
+
return { stages, denied, cancelled, layerCfg, taskState };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Is ONE stage of a scene authorized? */
|
|
143
|
+
async function isStageAuthorized(redis, bookId, chapterId, sceneId, stage, cache = null) {
|
|
144
|
+
const auth = await getAuthorizedStages(redis, bookId, chapterId, sceneId, cache);
|
|
145
|
+
return auth.stages.has(stage);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function describeDenied(denied) {
|
|
149
|
+
return Object.entries(denied || {}).map(([s, r]) => `${s}:${r}`).join(',') || 'none';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ======================================================
|
|
153
|
+
// STATE HYGIENE WITHOUT WORK
|
|
154
|
+
// ======================================================
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* GENERATING → DIRTY (FSM-valid edge). Used where a dead/unsent generation
|
|
158
|
+
* must be released WITHOUT re-queueing it: without a command a stage may not
|
|
159
|
+
* sit in PENDING (PENDING is "queued work" — the incident's mine).
|
|
160
|
+
* Only GENERATING is touched; every other state is left as-is.
|
|
161
|
+
*/
|
|
162
|
+
async function rollbackGeneratingToDirty(redis, bookId, chapterId, sceneId, stage, reason) {
|
|
163
|
+
const states = await state.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
164
|
+
const current = states?.[stage];
|
|
165
|
+
if (current !== state.AssetState.GENERATING) {
|
|
166
|
+
return { changed: false, reason: `state_${current || 'new'}` };
|
|
167
|
+
}
|
|
168
|
+
const check = state.validateAssetTransition(current, state.AssetState.DIRTY);
|
|
169
|
+
if (!check.valid) {
|
|
170
|
+
return { changed: false, reason: `invalid_transition:${current}->dirty` };
|
|
171
|
+
}
|
|
172
|
+
await state.unsafeRestoreAssetState(redis, bookId, chapterId, sceneId, stage, state.AssetState.DIRTY);
|
|
173
|
+
log(`${bookId}/${chapterId}/${sceneId} ${stage}: generating → dirty (reason=${reason}) — not re-queued`);
|
|
174
|
+
return { changed: true, from: current, to: state.AssetState.DIRTY };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ======================================================
|
|
178
|
+
// NO WORKER → BLOCKED (no auto-retry)
|
|
179
|
+
// ======================================================
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A stage could not be enqueued because no live worker of its type exists.
|
|
183
|
+
* The command that asked for it is BLOCKED (task registry + PG) with the
|
|
184
|
+
* reason «нет воркера <type>»; the stage loses its authorization, so the
|
|
185
|
+
* scheduler never retries it. Only a NEW command retries.
|
|
186
|
+
* Storage writes happen host-side behind ProgressEventsPort.blockSceneStage.
|
|
187
|
+
*/
|
|
188
|
+
async function blockStageNoWorker(redis, bookId, chapterId, sceneId, stage) {
|
|
189
|
+
const reason = `no_worker:${stage}`;
|
|
190
|
+
let result = { tasks: [] };
|
|
191
|
+
try {
|
|
192
|
+
result = (await progressEventsOp('blockSceneStage')(redis, bookId, chapterId, sceneId, stage, reason)) || result;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
warn(`blockSceneStage failed for ${bookId}/${chapterId}/${sceneId}:${stage}: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
const ids = (result.tasks || []).map(t => t.task_id || t).join(',') || 'none';
|
|
197
|
+
log(`BLOCKED ${bookId}/${chapterId}/${sceneId}:${stage} — нет воркера ${stage}; tasks=[${ids}] — no auto-retry, a new command is required`);
|
|
198
|
+
return { reason, ...result };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ======================================================
|
|
202
|
+
// FORCE — only for the scene×stage pairs of the command
|
|
203
|
+
// ======================================================
|
|
204
|
+
// The former `animastor:force-dispatch:<book>` flag bypassed the duplicate/
|
|
205
|
+
// lease guards for the WHOLE book for 120 s (incident §6 item 7). The force
|
|
206
|
+
// set below holds exactly the scene×stage pairs reset by one regeneration.
|
|
207
|
+
|
|
208
|
+
const FORCE_STAGES_KEY_PREFIX = 'animastor:force-dispatch-stages:';
|
|
209
|
+
|
|
210
|
+
function forceStagesKey(bookId) {
|
|
211
|
+
return `${FORCE_STAGES_KEY_PREFIX}${bookId}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function forceMember(chapterId, sceneId, stage) {
|
|
215
|
+
return JSON.stringify([String(chapterId), String(sceneId), String(stage)]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function addForceStages(redis, bookId, pairs, ttlSeconds) {
|
|
219
|
+
const members = [];
|
|
220
|
+
for (const p of pairs || []) {
|
|
221
|
+
for (const stage of p.stages || []) members.push(forceMember(p.chapter_id, p.scene_id, stage));
|
|
222
|
+
}
|
|
223
|
+
if (members.length === 0) return 0;
|
|
224
|
+
const key = forceStagesKey(bookId);
|
|
225
|
+
await redis.sadd(key, ...members);
|
|
226
|
+
await redis.expire(key, ttlSeconds);
|
|
227
|
+
return members.length;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @returns {Promise<Set<string>>} force members of the book (see forceMember). */
|
|
231
|
+
async function getForceStages(redis, bookId) {
|
|
232
|
+
try {
|
|
233
|
+
return new Set(await redis.smembers(forceStagesKey(bookId)) || []);
|
|
234
|
+
} catch (_) {
|
|
235
|
+
return new Set();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Stages of one scene present in a book force set. */
|
|
240
|
+
function forcedStagesForScene(forceSet, chapterId, sceneId) {
|
|
241
|
+
const out = new Set();
|
|
242
|
+
if (!forceSet || forceSet.size === 0) return out;
|
|
243
|
+
for (const stage of mediaRegistry.listMediaTypes()) {
|
|
244
|
+
if (forceSet.has(forceMember(chapterId, sceneId, stage))) out.add(stage);
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function clearForceStages(redis, bookId) {
|
|
250
|
+
try { await redis.del(forceStagesKey(bookId)); } catch (_) { /* best effort */ }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
getLayerConfig,
|
|
255
|
+
isLayerEnabled,
|
|
256
|
+
isBookCancelled,
|
|
257
|
+
createGateCache,
|
|
258
|
+
getAuthorizedStages,
|
|
259
|
+
isStageAuthorized,
|
|
260
|
+
describeDenied,
|
|
261
|
+
rollbackGeneratingToDirty,
|
|
262
|
+
blockStageNoWorker,
|
|
263
|
+
FORCE_STAGES_KEY_PREFIX,
|
|
264
|
+
forceStagesKey,
|
|
265
|
+
forceMember,
|
|
266
|
+
addForceStages,
|
|
267
|
+
getForceStages,
|
|
268
|
+
forcedStagesForScene,
|
|
269
|
+
clearForceStages,
|
|
270
|
+
};
|
|
@@ -498,7 +498,8 @@ async function getDispatchEvidence(redis, bookId, chapterId, sceneId, stage) {
|
|
|
498
498
|
*
|
|
499
499
|
* @param {Object} redis
|
|
500
500
|
* @param {string} bookId
|
|
501
|
-
* @param {{reason?: string}} [opts]
|
|
501
|
+
* @param {{reason?: string, target?: 'pending'|'dirty'}} [opts] — target 'dirty'
|
|
502
|
+
* (command-only generation, Stop All) releases to DIRTY without re-queueing.
|
|
502
503
|
* @returns {Promise<{repaired: Array<{chapterId, sceneId, stage, path}>}>}
|
|
503
504
|
*/
|
|
504
505
|
async function repairOrphanGeneratingStates(redis, bookId, opts = {}) {
|
|
@@ -530,6 +531,16 @@ async function repairOrphanGeneratingStates(redis, bookId, opts = {}) {
|
|
|
530
531
|
if (states[stage] !== stateModule.AssetState.GENERATING) continue;
|
|
531
532
|
const evidence = await getDispatchEvidence(redis, bookId, chapterId, sceneId, stage);
|
|
532
533
|
if (evidence.alive) continue;
|
|
534
|
+
if (opts.target === 'dirty') {
|
|
535
|
+
// Command-only generation (Stop All): release the ghost
|
|
536
|
+
// to DIRTY without re-queueing it (no PENDING).
|
|
537
|
+
const released = await require('./command-gate').rollbackGeneratingToDirty(
|
|
538
|
+
redis, bookId, chapterId, sceneId, stage, reason);
|
|
539
|
+
if (released.changed) {
|
|
540
|
+
repaired.push({ chapterId, sceneId, stage, path: [stateModule.AssetState.DIRTY] });
|
|
541
|
+
}
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
533
544
|
const rollback = await rollbackStageToPending(
|
|
534
545
|
redis, bookId, chapterId, sceneId, stage, null, reason
|
|
535
546
|
);
|
|
@@ -730,10 +741,194 @@ async function shouldSkipDispatch(redis, bookId, chapterId, sceneId, stage) {
|
|
|
730
741
|
* - { dispatched: true, dispatchId, stage, leaseKey }
|
|
731
742
|
* - { dispatched: false, reason, skip: true/false }
|
|
732
743
|
*/
|
|
744
|
+
// ======================================================
|
|
745
|
+
// STAGE 116 (D3) — BOUNDED DEFERRAL (capacity backoff)
|
|
746
|
+
// STAGE 118 (audit 117 · M2/M4) — taxonomy + force handling
|
|
747
|
+
// ======================================================
|
|
748
|
+
// The scheduler ticks every SCHEDULER_TICK_MS (5 s). A stage whose jobs could
|
|
749
|
+
// not be enqueued (no live compatible worker, or every job already pending in
|
|
750
|
+
// the lane) used to be re-dispatched on EVERY tick: lease created → executor
|
|
751
|
+
// sent nothing → 'no_jobs_sent' → dispatch cancelled → FSM rolled back →
|
|
752
|
+
// next tick repeats. That is an unbounded dispatch/cancel loop with FSM
|
|
753
|
+
// churn, lease churn and unbounded event-journal growth (Stage 115 D3).
|
|
754
|
+
//
|
|
755
|
+
// The deferral is a bounded, per-stage backoff:
|
|
756
|
+
// • checked BEFORE any side effect (no lease, no metadata, no FSM write,
|
|
757
|
+
// no journal event) ⇒ a deferred stage costs one Redis GET per tick;
|
|
758
|
+
// • the stage stays PENDING and stays in the active index ⇒ recoverable;
|
|
759
|
+
// • an explicit cancel clears it (the user asked for the work to run).
|
|
760
|
+
//
|
|
761
|
+
// Stage 118 — three corrections of the Stage 116 implementation:
|
|
762
|
+
//
|
|
763
|
+
// 1. The reason taxonomy now distinguishes CAPACITY from IDEMPOTENT
|
|
764
|
+
// suppression ('already_completed' is NOT a capacity gap — audit 117 · M4).
|
|
765
|
+
// The idempotent bucket gets its own, much longer window: the stage is not
|
|
766
|
+
// "waiting for capacity", it is "already done" — retrying it every minute
|
|
767
|
+
// for a week is not a fix.
|
|
768
|
+
// 2. `force=true` NO LONGER BYPASSES the deferral (audit 117 · M2). The
|
|
769
|
+
// force-dispatch flag lives for FORCE_DISPATCH_TTL_S (120 s) and is set for
|
|
770
|
+
// the WHOLE book by /regenerate, so bypassing the backoff there restored
|
|
771
|
+
// the very dispatch/cancel churn D3 was built to remove (≈24 full attempts
|
|
772
|
+
// per scene per regeneration). The explicit intent (a regeneration, a
|
|
773
|
+
// cancel) clears the deferral at ITS call site — the backoff then applies
|
|
774
|
+
// again after the next capacity failure.
|
|
775
|
+
// 3. The deferral key encodes its components (audit 117 · M1): a book_id may
|
|
776
|
+
// contain ':' (the bundle book_id is client-controlled), so a raw
|
|
777
|
+
// ':'-joined key is ambiguous — `a:b` + chapter `c` collides with book
|
|
778
|
+
// `a` + chapter `b`.
|
|
779
|
+
const DEFERRED_STAGE_KEY_PREFIX = 'animastor:runtime:deferred:';
|
|
780
|
+
const DEFERRED_STAGE_TTL_S = 60; // capacity backoff window (bounded retry)
|
|
781
|
+
const DEFERRED_IDEMPOTENT_TTL_S = 900; // idempotent-suppression window (15 min)
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Executor outcomes that mean "nothing could be enqueued — retry later".
|
|
785
|
+
*
|
|
786
|
+
* The taxonomy lives in ./dispatch-outcomes (shared with the stage
|
|
787
|
+
* executors). The generic 'no_jobs_sent' / 'no_jobs_generated' /
|
|
788
|
+
* 'send_failed' outcomes keep their historic contract (retry on the next
|
|
789
|
+
* tick — the audit-6929ba5 regression tests rely on it): deferring them
|
|
790
|
+
* would change the meaning of an executor result that does not necessarily
|
|
791
|
+
* mean "no capacity".
|
|
792
|
+
*/
|
|
793
|
+
const {
|
|
794
|
+
CAPACITY_OUTCOMES,
|
|
795
|
+
IDEMPOTENT_OUTCOMES,
|
|
796
|
+
FENCE_REJECTED_OUTCOMES,
|
|
797
|
+
isCapacityOutcome,
|
|
798
|
+
isIdempotentOutcome,
|
|
799
|
+
isFenceRejectedOutcome,
|
|
800
|
+
isDeferrableOutcome,
|
|
801
|
+
} = require('./dispatch-outcomes');
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Encode one key component so that ':' (and the glob metacharacters) can
|
|
805
|
+
* never be mistaken for the separator. encodeURIComponent escapes ':' as
|
|
806
|
+
* '%3A' and leaves '_' readable.
|
|
807
|
+
*/
|
|
808
|
+
function encodeKeyComponent(value) {
|
|
809
|
+
return encodeURIComponent(String(value == null ? '' : value));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function deferredStageKey(bookId, chapterId, sceneId, stage) {
|
|
813
|
+
return `${DEFERRED_STAGE_KEY_PREFIX}`
|
|
814
|
+
+ `${encodeKeyComponent(bookId)}:${encodeKeyComponent(chapterId)}`
|
|
815
|
+
+ `:${encodeKeyComponent(sceneId)}:${encodeKeyComponent(stage)}`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Is this stage currently deferred (backoff active)? */
|
|
819
|
+
async function isStageDeferred(redis, bookId, chapterId, sceneId, stage) {
|
|
820
|
+
try {
|
|
821
|
+
const value = await redis.get(deferredStageKey(bookId, chapterId, sceneId, stage));
|
|
822
|
+
return value != null;
|
|
823
|
+
} catch (_) {
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Defer a stage for the bounded backoff window.
|
|
830
|
+
* @param {number} [ttlSeconds] — the capacity window by default; the
|
|
831
|
+
* idempotent window for an 'already_completed' suppression.
|
|
832
|
+
*/
|
|
833
|
+
async function deferStage(redis, bookId, chapterId, sceneId, stage, ttlSeconds = DEFERRED_STAGE_TTL_S) {
|
|
834
|
+
try {
|
|
835
|
+
await redis.set(deferredStageKey(bookId, chapterId, sceneId, stage), '1', 'EX', ttlSeconds);
|
|
836
|
+
} catch (_) { /* best effort — a missed deferral only costs one extra attempt */ }
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** Clear the deferral (the stage dispatched, completed or was cancelled). */
|
|
840
|
+
async function clearStageDeferral(redis, bookId, chapterId, sceneId, stage) {
|
|
841
|
+
try {
|
|
842
|
+
await redis.del(deferredStageKey(bookId, chapterId, sceneId, stage));
|
|
843
|
+
} catch (_) { /* best effort */ }
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Clear the capacity backoff of EVERY stage of a book.
|
|
848
|
+
*
|
|
849
|
+
* Stage 118 (audit 117 · M2/B4): the explicit intents — a cancel, a cache
|
|
850
|
+
* teardown, a deletion — ask for the work to run again, so the backoff must
|
|
851
|
+
* not survive them. The deferral key is owned by this package, so the
|
|
852
|
+
* package clears it (the host only injects the port).
|
|
853
|
+
*
|
|
854
|
+
* @returns {Promise<number>} the number of cleared deferrals.
|
|
855
|
+
*/
|
|
856
|
+
async function clearDeferralsForBook(redis, bookId) {
|
|
857
|
+
if (!redis || !bookId) return 0;
|
|
858
|
+
return clearDeferralsByPrefix(redis, `${DEFERRED_STAGE_KEY_PREFIX}${encodeKeyComponent(bookId)}:`);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Clear the capacity backoff of every stage of one scene (an explicit
|
|
863
|
+
* regeneration of that scene).
|
|
864
|
+
*/
|
|
865
|
+
async function clearDeferralsForScene(redis, bookId, chapterId, sceneId) {
|
|
866
|
+
if (!redis || !bookId || !chapterId || !sceneId) return 0;
|
|
867
|
+
return clearDeferralsByPrefix(redis, `${DEFERRED_STAGE_KEY_PREFIX}`
|
|
868
|
+
+ `${encodeKeyComponent(bookId)}:${encodeKeyComponent(chapterId)}:${encodeKeyComponent(sceneId)}:`);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function clearDeferralsByPrefix(redis, prefix) {
|
|
872
|
+
let cleared = 0;
|
|
873
|
+
if (!redis || !prefix) return cleared;
|
|
874
|
+
let cursor = '0';
|
|
875
|
+
try {
|
|
876
|
+
do {
|
|
877
|
+
const [next, keys] = await redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 200);
|
|
878
|
+
cursor = next;
|
|
879
|
+
for (const key of keys || []) {
|
|
880
|
+
if (!key.startsWith(prefix)) continue;
|
|
881
|
+
await redis.del(key);
|
|
882
|
+
cleared++;
|
|
883
|
+
}
|
|
884
|
+
} while (cursor !== '0');
|
|
885
|
+
} catch (_) { /* best effort */ }
|
|
886
|
+
return cleared;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* The backoff window that fits the outcome: a capacity gap retries in
|
|
891
|
+
* minutes, an idempotent suppression (already complete) in quarters of an
|
|
892
|
+
* hour — it is not waiting for capacity, it is simply done.
|
|
893
|
+
*/
|
|
894
|
+
function deferralWindowFor(reason) {
|
|
895
|
+
// Stage 129 (audit 128 · F5): a fence rejection is terminal for THIS
|
|
896
|
+
// generation — retrying it immediately would only recreate the
|
|
897
|
+
// dispatch → cancel → rollback churn. It shares the long window with the
|
|
898
|
+
// idempotent suppressions and stays visible through the log/metric.
|
|
899
|
+
return (isIdempotentOutcome(reason) || isFenceRejectedOutcome(reason))
|
|
900
|
+
? DEFERRED_IDEMPOTENT_TTL_S
|
|
901
|
+
: DEFERRED_STAGE_TTL_S;
|
|
902
|
+
}
|
|
903
|
+
|
|
733
904
|
async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBook, buildId, options = {}) {
|
|
734
905
|
const { force = false } = options;
|
|
735
906
|
const dispatchId = generateDispatchToken();
|
|
736
907
|
|
|
908
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
909
|
+
// Stage 116 (D3) / Stage 118 (M2) — BOUNDED DEFERRAL (retry/backoff)
|
|
910
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
911
|
+
// A stage that could not enqueue anything (no live compatible worker, or
|
|
912
|
+
// every job already pending in the lane) must NOT be retried on every
|
|
913
|
+
// scheduler tick: the tick is 5 s, so the un-deferred path produced an
|
|
914
|
+
// endless dispatch → cancel → FSM-rollback cycle (lease churn, FSM churn
|
|
915
|
+
// and UNBOUNDED event-journal growth).
|
|
916
|
+
//
|
|
917
|
+
// The deferral is checked BEFORE any side effect (no lease, no dispatch
|
|
918
|
+
// metadata, no FSM write, no journal event) ⇒ a deferred stage costs one
|
|
919
|
+
// Redis GET per tick. The stage stays PENDING and stays in the active
|
|
920
|
+
// index ⇒ it is recoverable: as soon as the deferral window expires (or a
|
|
921
|
+
// worker appears) the next tick dispatches it again.
|
|
922
|
+
//
|
|
923
|
+
// Stage 118 (M2): the check applies in FORCE mode as well. The
|
|
924
|
+
// force-dispatch flag is per BOOK and lives for 120 s, so bypassing the
|
|
925
|
+
// backoff there reproduced the very churn D3 removes (≈24 full attempts
|
|
926
|
+
// per scene per /regenerate). An EXPLICIT intent (a regeneration, a
|
|
927
|
+
// cancel) clears the deferral at its own call site.
|
|
928
|
+
if (await isStageDeferred(redis, bookId, chapterId, sceneId, stage)) {
|
|
929
|
+
return { dispatched: false, reason: 'deferred_capacity', retryable: true, deferred: true, dispatchId };
|
|
930
|
+
}
|
|
931
|
+
|
|
737
932
|
log(`DISPATCH_REQUEST: ${bookId}/${chapterId}/${sceneId}:${stage}${force ? ' (force=true)' : ''}`);
|
|
738
933
|
|
|
739
934
|
// Force mode pre-clear: cancel existing dispatch, release quota ТОЛЬКО при наличии lease
|
|
@@ -908,6 +1103,18 @@ async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBoo
|
|
|
908
1103
|
if (!result.dispatched) {
|
|
909
1104
|
log(`DISPATCH_SKIPPED: ${bookId}/${chapterId}/${sceneId}:${stage} - ${result.reason || 'already_done'}`);
|
|
910
1105
|
if (!result.completed) {
|
|
1106
|
+
// Stage 116 (D3): a stage that enqueued NOTHING is not a
|
|
1107
|
+
// lost task and not a fatal cancellation — it is a bounded
|
|
1108
|
+
// deferral. Remember it for a bounded window so the
|
|
1109
|
+
// scheduler's 5 s tick does not spin.
|
|
1110
|
+
//
|
|
1111
|
+
// Stage 118 (M4): the window depends on the bucket — a
|
|
1112
|
+
// capacity gap retries within a minute, an idempotent
|
|
1113
|
+
// suppression ('already_completed') is NOT waiting for
|
|
1114
|
+
// capacity and gets the long window.
|
|
1115
|
+
if (isDeferrableOutcome(result.reason)) {
|
|
1116
|
+
await deferStage(redis, bookId, chapterId, sceneId, stage, deferralWindowFor(result.reason));
|
|
1117
|
+
}
|
|
911
1118
|
await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
|
|
912
1119
|
outcome: 'cancelled',
|
|
913
1120
|
dispatchId,
|
|
@@ -918,13 +1125,29 @@ async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBoo
|
|
|
918
1125
|
if (result.rollbackFailed) {
|
|
919
1126
|
error(`STATE_ROLLBACK_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${result.rollbackReason || 'unknown'} — stage may be stuck in GENERATING`);
|
|
920
1127
|
}
|
|
1128
|
+
} else {
|
|
1129
|
+
// The stage completed (cache hit / already done) — it is no
|
|
1130
|
+
// longer waiting for capacity.
|
|
1131
|
+
await clearStageDeferral(redis, bookId, chapterId, sceneId, stage);
|
|
921
1132
|
}
|
|
922
1133
|
// Cancelled finalize does not touch the circuit breaker — release
|
|
923
1134
|
// the test permit here so a half-open circuit can keep recovering.
|
|
924
1135
|
await releaseTestPermit();
|
|
925
|
-
return {
|
|
1136
|
+
return {
|
|
1137
|
+
dispatched: false,
|
|
1138
|
+
dispatchId,
|
|
1139
|
+
reason: result.reason || 'already_done',
|
|
1140
|
+
retryable: isDeferrableOutcome(result.reason),
|
|
1141
|
+
// Stage 118 (M9): the caller (scheduler) must be able to
|
|
1142
|
+
// tell "nothing happened, try later" from a real error.
|
|
1143
|
+
deferred: isDeferrableOutcome(result.reason),
|
|
1144
|
+
result,
|
|
1145
|
+
};
|
|
926
1146
|
}
|
|
927
1147
|
|
|
1148
|
+
// The stage really dispatched — capacity deferral is over.
|
|
1149
|
+
await clearStageDeferral(redis, bookId, chapterId, sceneId, stage);
|
|
1150
|
+
|
|
928
1151
|
// T6: Start lease renewal for this actively dispatched job
|
|
929
1152
|
// Only started after confirming dispatcher returned dispatched:true
|
|
930
1153
|
// (not for cache hits, backpressure, or empty executor results)
|
|
@@ -1296,6 +1519,9 @@ async function markDispatchFailed(redis, bookId, chapterId, sceneId, stage, erro
|
|
|
1296
1519
|
* T6: Останавливает in-memory renewal timer, чтобы избежать утечки.
|
|
1297
1520
|
*/
|
|
1298
1521
|
async function cancelActiveDispatch(redis, bookId, chapterId, sceneId, stage, reason = 'cancelled') {
|
|
1522
|
+
// Stage 116 (D3): an explicit cancel means the work is wanted again — a
|
|
1523
|
+
// capacity deferral must not survive it.
|
|
1524
|
+
await clearStageDeferral(redis, bookId, chapterId, sceneId, stage);
|
|
1299
1525
|
const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
|
|
1300
1526
|
if (metadata?.dispatch_id) {
|
|
1301
1527
|
const result = await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
|
|
@@ -1383,9 +1609,33 @@ async function clearLeasesForScenes(redis, bookId, scenes) {
|
|
|
1383
1609
|
}
|
|
1384
1610
|
|
|
1385
1611
|
/**
|
|
1386
|
-
* Remove
|
|
1612
|
+
* Remove cancelled dispatches from GPU Hub.
|
|
1387
1613
|
* Kept here with dispatch cancellation so every caller uses the same auth and
|
|
1388
1614
|
* error-handling contract.
|
|
1615
|
+
*
|
|
1616
|
+
* Stage 122 (N1): the hub's `DELETE /queue/clear` accepts EITHER a
|
|
1617
|
+
* `dispatch_id` (per-job) OR a `book_id` (whole-book lane sweep). Before this
|
|
1618
|
+
* stage the function only honoured the per-id form; a caller that passed an
|
|
1619
|
+
* EMPTY id list together with `book_id` (the regenerate post-release sweep,
|
|
1620
|
+
* audit 121 · N1) fell straight through the id loop and issued ZERO requests —
|
|
1621
|
+
* a silent NO-OP that left orphan lane entries behind while looking like a
|
|
1622
|
+
* successful sweep.
|
|
1623
|
+
*
|
|
1624
|
+
* The contract now is:
|
|
1625
|
+
* - ids present → one `?dispatch_id=` DELETE per id (unchanged);
|
|
1626
|
+
* - no ids + book_id → exactly ONE `?book_id=` DELETE (the whole-book sweep
|
|
1627
|
+
* the hub already supports and cache/delete paths use);
|
|
1628
|
+
* - neither → NO request at all (never a fake HTTP call).
|
|
1629
|
+
*
|
|
1630
|
+
* A book-scoped sweep is checked against the hub's echoed `scope.book_id`:
|
|
1631
|
+
* if the hub answers `ok` but echoes a DIFFERENT scope (or none at all while
|
|
1632
|
+
* reporting a full wipe) it did not honour our ownership and other books may
|
|
1633
|
+
* have been touched — that is counted as `failed` and warned, never as a
|
|
1634
|
+
* success. Failures never throw (best-effort contract, callers keep claims).
|
|
1635
|
+
*
|
|
1636
|
+
* @returns {Promise<{requested:number, cleared:number, failed:number}>}
|
|
1637
|
+
* `requested` = number of hub requests actually attempted (1 for a
|
|
1638
|
+
* book-scoped sweep, ids.length for per-id, 0 when there is nothing to do).
|
|
1389
1639
|
*/
|
|
1390
1640
|
async function clearHubDispatches(dispatchIds, options = {}) {
|
|
1391
1641
|
const ids = [...new Set((dispatchIds || []).filter(Boolean))];
|
|
@@ -1396,16 +1646,59 @@ async function clearHubDispatches(dispatchIds, options = {}) {
|
|
|
1396
1646
|
const context = options.context || 'DISPATCH';
|
|
1397
1647
|
const reportWarning = typeof options.warn === 'function' ? options.warn : warn;
|
|
1398
1648
|
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
1649
|
+
const bookId = options.book_id || options.bookId || null;
|
|
1399
1650
|
let cleared = 0;
|
|
1400
1651
|
let failed = 0;
|
|
1401
1652
|
|
|
1653
|
+
const authHeaders = () => {
|
|
1654
|
+
const headers = {};
|
|
1655
|
+
if (apiKey) headers['x-api-key'] = apiKey;
|
|
1656
|
+
return headers;
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
// ── Book-scoped sweep (no explicit dispatch ids) ──────────────────────
|
|
1660
|
+
// Exactly one request; the hub filters by record.book_id so only this
|
|
1661
|
+
// book's queue/running/result entries are removed.
|
|
1662
|
+
if (ids.length === 0 && bookId) {
|
|
1663
|
+
try {
|
|
1664
|
+
const response = await fetchImpl(
|
|
1665
|
+
`${hubUrl}/queue/clear?book_id=${encodeURIComponent(bookId)}`,
|
|
1666
|
+
{ method: 'DELETE', headers: authHeaders() }
|
|
1667
|
+
);
|
|
1668
|
+
if (!response.ok) {
|
|
1669
|
+
failed++;
|
|
1670
|
+
reportWarning(`${context}: GPU hub book cleanup returned ${response.status} for ${bookId}`);
|
|
1671
|
+
} else {
|
|
1672
|
+
// Verify the hub honoured OUR scope. A mismatched echo means it
|
|
1673
|
+
// may have swept a different book (or everything) — that is a
|
|
1674
|
+
// failure, not a success we can report.
|
|
1675
|
+
let enforcedScope = true;
|
|
1676
|
+
try {
|
|
1677
|
+
const body = typeof response.json === 'function' ? await response.json() : null;
|
|
1678
|
+
if (body && body.scope && body.scope.book_id != null && body.scope.book_id !== bookId) {
|
|
1679
|
+
enforcedScope = false;
|
|
1680
|
+
}
|
|
1681
|
+
} catch (_) { /* unparseable body — trust the ok status */ }
|
|
1682
|
+
if (!enforcedScope) {
|
|
1683
|
+
failed++;
|
|
1684
|
+
reportWarning(`${context}: GPU hub book cleanup echoed a foreign scope for ${bookId} — treated as failed`);
|
|
1685
|
+
} else {
|
|
1686
|
+
cleared++;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
} catch (err) {
|
|
1690
|
+
failed++;
|
|
1691
|
+
reportWarning(`${context}: GPU hub book cleanup failed for ${bookId}: ${err.message}`);
|
|
1692
|
+
}
|
|
1693
|
+
return { requested: 1, cleared, failed };
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// ── Per-dispatch cleanup (unchanged) ─────────────────────────────────
|
|
1402
1697
|
for (const dispatchId of ids) {
|
|
1403
1698
|
try {
|
|
1404
|
-
const headers = {};
|
|
1405
|
-
if (apiKey) headers['x-api-key'] = apiKey;
|
|
1406
1699
|
const response = await fetchImpl(
|
|
1407
1700
|
`${hubUrl}/queue/clear?dispatch_id=${encodeURIComponent(dispatchId)}`,
|
|
1408
|
-
{ method: 'DELETE', headers }
|
|
1701
|
+
{ method: 'DELETE', headers: authHeaders() }
|
|
1409
1702
|
);
|
|
1410
1703
|
if (!response.ok) {
|
|
1411
1704
|
failed++;
|
|
@@ -1781,6 +2074,29 @@ module.exports = {
|
|
|
1781
2074
|
LEASE_TTLS,
|
|
1782
2075
|
QUOTAS,
|
|
1783
2076
|
|
|
2077
|
+
// Stage 116 (D3) / Stage 118 (M2/M4): bounded deferral (retry/backoff) —
|
|
2078
|
+
// the stage is not retried on every 5 s scheduler tick while it cannot
|
|
2079
|
+
// enqueue. Stage 118: the capacity and the idempotent bucket have their
|
|
2080
|
+
// own windows, and the taxonomy is shared with the stage executors.
|
|
2081
|
+
DEFERRED_STAGE_KEY_PREFIX,
|
|
2082
|
+
DEFERRED_STAGE_TTL_S,
|
|
2083
|
+
DEFERRED_IDEMPOTENT_TTL_S,
|
|
2084
|
+
deferredStageKey,
|
|
2085
|
+
isStageDeferred,
|
|
2086
|
+
deferStage,
|
|
2087
|
+
clearStageDeferral,
|
|
2088
|
+
clearDeferralsForBook,
|
|
2089
|
+
clearDeferralsForScene,
|
|
2090
|
+
deferralWindowFor,
|
|
2091
|
+
encodeKeyComponent,
|
|
2092
|
+
isDeferrableOutcome,
|
|
2093
|
+
isCapacityOutcome,
|
|
2094
|
+
isIdempotentOutcome,
|
|
2095
|
+
isFenceRejectedOutcome,
|
|
2096
|
+
CAPACITY_OUTCOMES,
|
|
2097
|
+
IDEMPOTENT_OUTCOMES,
|
|
2098
|
+
FENCE_REJECTED_OUTCOMES,
|
|
2099
|
+
|
|
1784
2100
|
// PW-2: dispatch identity generation (crypto-hardened)
|
|
1785
2101
|
generateDispatchToken
|
|
1786
2102
|
};
|