@animastor/orchestration 0.1.1
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/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +51 -0
- package/src/host/host-bindings.js +138 -0
- package/src/index.js +99 -0
- package/src/orchestration/index.js +40 -0
- package/src/orchestration/orchestrator.js +630 -0
- package/src/orchestration/runtime-result-consumer.js +54 -0
- package/src/orchestration/scene-callbacks.js +542 -0
- package/src/orchestration/scene-orchestrator.js +601 -0
- package/src/orchestration/scene-restoration.js +129 -0
- package/src/orchestration/scene-utils.js +36 -0
- package/src/runtime/active-scenes-index.js +164 -0
- package/src/runtime/audio-fsm-port.js +187 -0
- package/src/runtime/circuit-breaker.js +588 -0
- package/src/runtime/counter-reconciliation.js +308 -0
- package/src/runtime/dispatch-engine.js +1768 -0
- package/src/runtime/failure-taxonomy.js +125 -0
- package/src/runtime/hub-cancel-port.js +122 -0
- package/src/runtime/layer-config-port.js +136 -0
- package/src/runtime/lease-manager.js +607 -0
- package/src/runtime/persistence-port.js +162 -0
- package/src/runtime/placeholder-audio-port.js +117 -0
- package/src/runtime/progress-events-port.js +121 -0
- package/src/runtime/reconciliation-engine.js +2405 -0
- package/src/runtime/retry-budget-manager.js +229 -0
- package/src/runtime/runtime-metrics.js +427 -0
- package/src/runtime/runtime-result-emitter.js +123 -0
- package/src/runtime/runtime-scheduler.js +629 -0
- package/src/runtime/scene-data-port.js +114 -0
- package/src/runtime/scene-window.js +827 -0
- package/src/runtime/video-fsm-port.js +189 -0
- package/src/runtime/worker-health.js +270 -0
- package/test/counter-reconciliation.test.js +164 -0
- package/test/mocks/redis-mock.js +221 -0
- package/test/test-bindings.js +60 -0
|
@@ -0,0 +1,1768 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// Dispatch Engine - v1.0.0
|
|
3
|
+
// ======================================================
|
|
4
|
+
// Single authority for dispatching scene stages.
|
|
5
|
+
// Implements leases for determinism, quotas for backpressure.
|
|
6
|
+
// Supports renewable leases and drift detection.
|
|
7
|
+
|
|
8
|
+
const { lazyHostBinding } = require('../host/host-bindings');
|
|
9
|
+
// §32.30: former host requires resolve lazily through the host-bindings seam.
|
|
10
|
+
const state = lazyHostBinding('state');
|
|
11
|
+
// S-5: the event journal is an append-only observability sink (zero requires,
|
|
12
|
+
// no orchestration policy) — the ONLY runtime→Generation dependency allowed
|
|
13
|
+
// without a seam (s5-runtime-orchestration-cycle.test.js S5-A).
|
|
14
|
+
const journal = lazyHostBinding('journal');
|
|
15
|
+
// S-5: orchestration-owned behavior (stage executor, FSM-safe rollbacks)
|
|
16
|
+
// reaches runtime ONLY through the composition-root-injected seam registry.
|
|
17
|
+
// No orchestration module is imported from this file (S-5).
|
|
18
|
+
const orchestrationSeams = lazyHostBinding('seams');
|
|
19
|
+
const leaseManager = require('./lease-manager');
|
|
20
|
+
const counterReconciliation = require('./counter-reconciliation');
|
|
21
|
+
const runtimeMetrics = require('./runtime-metrics');
|
|
22
|
+
// O-2: the former dead `../storage` require is GONE — dispatch-engine holds
|
|
23
|
+
// no persistence dependency at all (leases/quotas/markers are Redis-keyed
|
|
24
|
+
// domain state over the injected redis parameter).
|
|
25
|
+
const circuitBreaker = require('./circuit-breaker');
|
|
26
|
+
const retryBudget = require('./retry-budget-manager');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
// Phase 5: Runtime Result Contract — dispatch finalizations are reported to
|
|
29
|
+
// orchestration through the contracts seam (runtime → contract → injected
|
|
30
|
+
// consumer), NOT through an orchestration import.
|
|
31
|
+
// Docs: docs/architecture/PHASE_5_ORCHESTRATION_RUNTIME.md
|
|
32
|
+
const runtimeResultEmitter = require('./runtime-result-emitter');
|
|
33
|
+
|
|
34
|
+
const logPrefix = '[DISPATCH]';
|
|
35
|
+
|
|
36
|
+
function log(msg) {
|
|
37
|
+
console.log(`${logPrefix} ${msg}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function warn(msg) {
|
|
41
|
+
console.warn(`${logPrefix} ⚠️ ${msg}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function error(msg) {
|
|
45
|
+
console.error(`${logPrefix} ❌ ${msg}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ======================================================
|
|
49
|
+
// CONFIGURATION
|
|
50
|
+
// ======================================================
|
|
51
|
+
|
|
52
|
+
// S-2 COMPLETION: Lease TTLs and quotas resolved through media registry.
|
|
53
|
+
// The registry reads from runtime-config (canonical source) at startup.
|
|
54
|
+
// dispatch-engine no longer maintains its own media-type maps.
|
|
55
|
+
// LEASE_TTLS / QUOTAS stay exported for backward compatibility (tests and
|
|
56
|
+
// metrics read them) — they are thin lazy views over the registry, NOT a
|
|
57
|
+
// second source of truth: property access resolves through the registry on
|
|
58
|
+
// every read, so registry/config changes are reflected immediately.
|
|
59
|
+
const mediaRegistry = require('@animastor/generation').mediaRegistry;
|
|
60
|
+
const runtimeConfig = lazyHostBinding('config');
|
|
61
|
+
|
|
62
|
+
// S-2: resolve all registered stages dynamically
|
|
63
|
+
function _allStages() { return mediaRegistry.listMediaTypes(); }
|
|
64
|
+
function _leaseTtl(stage) { return mediaRegistry.resolveLeaseTtl(stage, 1800); }
|
|
65
|
+
function _maxActive(stage) { return mediaRegistry.resolveMaxActive(stage, 1); }
|
|
66
|
+
|
|
67
|
+
/** Lazy registry view: LEASE_TTLS.audio → resolveLeaseTtl('audio'). */
|
|
68
|
+
const LEASE_TTLS = new Proxy({}, {
|
|
69
|
+
get(_, stage) { return mediaRegistry.resolveLeaseTtl(stage); },
|
|
70
|
+
ownKeys() { return [...mediaRegistry.listMediaTypes()]; },
|
|
71
|
+
getOwnPropertyDescriptor(_, stage) {
|
|
72
|
+
const v = mediaRegistry.resolveLeaseTtl(stage);
|
|
73
|
+
return v === undefined ? undefined : { value: v, enumerable: true, configurable: true };
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Lazy registry view: QUOTAS.maxActiveAudio → resolveMaxActive('audio'). */
|
|
78
|
+
const QUOTAS = new Proxy({}, {
|
|
79
|
+
get(_, key) {
|
|
80
|
+
if (typeof key !== 'string' || !key.startsWith('maxActive')) return undefined;
|
|
81
|
+
return mediaRegistry.resolveMaxActive(key.slice('maxActive'.length).toLowerCase());
|
|
82
|
+
},
|
|
83
|
+
ownKeys() {
|
|
84
|
+
return mediaRegistry.listMediaTypes().map(t => `maxActive${t.charAt(0).toUpperCase() + t.slice(1)}`);
|
|
85
|
+
},
|
|
86
|
+
getOwnPropertyDescriptor(_, key) {
|
|
87
|
+
const v = this.get(key);
|
|
88
|
+
return v === undefined ? undefined : { value: v, enumerable: true, configurable: true };
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ======================================================
|
|
93
|
+
// KEY PATTERNS
|
|
94
|
+
// ======================================================
|
|
95
|
+
|
|
96
|
+
const DISPATCH_LEASE_PREFIX = 'animastor:dispatch-lease';
|
|
97
|
+
const DISPATCH_META_PREFIX = 'animastor:dispatch-meta';
|
|
98
|
+
// Д.1: idempotency marker for completion — guards releaseQuota against a
|
|
99
|
+
// repeated markDispatchCompleted (e.g. a callback retry that slipped past the
|
|
100
|
+
// HTTP-layer dedup). Cleared at dispatch start so each new dispatch completes once.
|
|
101
|
+
const DISPATCH_COMPLETED_PREFIX = 'animastor:dispatch-completed';
|
|
102
|
+
const ACTIVE_STAGE_PREFIX = 'animastor:runtime:active';
|
|
103
|
+
|
|
104
|
+
// IU in-flight markers (image stage): iu-processor создаёт маркер
|
|
105
|
+
// animastor:iu-in-flight:* ДО gpu.send и регистрирует его в per-dispatch
|
|
106
|
+
// индексе. Значение маркера = dispatch_id владельца. Как только GPU job
|
|
107
|
+
// реально отправлен, маркер снимается с индекса (с этого момента job живёт
|
|
108
|
+
// сам по себе, маркер очищает completion callback). Всё, что осталось в
|
|
109
|
+
// индексе на момент финализации dispatch — маркеры НЕотправленных jobs:
|
|
110
|
+
// их безопасно удалять compare-and-delete по dispatch_id (forensic audit
|
|
111
|
+
// 6929ba5: stale маркеры сорванного dispatch блокировали следующий dispatch
|
|
112
|
+
// → no_jobs_sent → ghost GENERATING).
|
|
113
|
+
const IU_IN_FLIGHT_INDEX_PREFIX = 'animastor:iu-in-flight-index';
|
|
114
|
+
// §32.30: module-level config read deferred — the config module arrives
|
|
115
|
+
// through the host-bindings seam and is resolved on first use.
|
|
116
|
+
let IU_IN_FLIGHT_MARKER_TTL_S_ = null;
|
|
117
|
+
function IU_IN_FLIGHT_MARKER_TTL_S() {
|
|
118
|
+
if (IU_IN_FLIGHT_MARKER_TTL_S_ === null) IU_IN_FLIGHT_MARKER_TTL_S_ = runtimeConfig.TIMEOUTS.IU_IN_FLIGHT_TTL_S;
|
|
119
|
+
return IU_IN_FLIGHT_MARKER_TTL_S_;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const SCHEDULER_TICK_LOCK = 'animastor:runtime:scheduler-lock';
|
|
123
|
+
const SCHEDULER_TICK_LOCK_TTL = 30; // 30 seconds
|
|
124
|
+
|
|
125
|
+
// ======================================================
|
|
126
|
+
// LEASE MANAGEMENT
|
|
127
|
+
// ======================================================
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get lease key for scene:stage.
|
|
131
|
+
*/
|
|
132
|
+
function getLeaseKey(bookId, chapterId, sceneId, stage) {
|
|
133
|
+
return `${DISPATCH_LEASE_PREFIX}:${bookId}:${chapterId}:${sceneId}:${stage}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get dispatch metadata key.
|
|
138
|
+
*/
|
|
139
|
+
function getDispatchMetaKey(bookId, chapterId, sceneId, stage) {
|
|
140
|
+
return `${DISPATCH_META_PREFIX}:${bookId}:${chapterId}:${sceneId}:${stage}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get dispatch-completed idempotency key (Д.1).
|
|
145
|
+
*/
|
|
146
|
+
function getDispatchCompletedKey(bookId, chapterId, sceneId, stage, dispatchId = null) {
|
|
147
|
+
const base = `${DISPATCH_COMPLETED_PREFIX}:${bookId}:${chapterId}:${sceneId}:${stage}`;
|
|
148
|
+
return dispatchId ? `${base}:${dispatchId}` : base;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get active stage counter key.
|
|
153
|
+
*/
|
|
154
|
+
function getActiveCounterKey(stage) {
|
|
155
|
+
return `${ACTIVE_STAGE_PREFIX}-${stage}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Generate unique dispatch token.
|
|
160
|
+
* PW-2: cryptographic randomness (128-bit) instead of Math.random — the
|
|
161
|
+
* dispatch_id is an ownership/claim token on the hub hot path; predictable
|
|
162
|
+
* ids would let a caller guess or collide dispatch identities.
|
|
163
|
+
*/
|
|
164
|
+
function generateDispatchToken() {
|
|
165
|
+
return `dispatch-${Date.now()}-${crypto.randomBytes(16).toString('hex')}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Acquire stage lease for scene.
|
|
170
|
+
* When force=true, any existing lease is deleted and a new one is created.
|
|
171
|
+
* Returns { acquired: boolean, token: string | null, leaseKey: string }
|
|
172
|
+
*/
|
|
173
|
+
async function acquireStageLease(redis, bookId, chapterId, sceneId, stage, force = false) {
|
|
174
|
+
const leaseKey = getLeaseKey(bookId, chapterId, sceneId, stage);
|
|
175
|
+
const token = generateDispatchToken();
|
|
176
|
+
const ttl = _leaseTtl(stage);
|
|
177
|
+
|
|
178
|
+
if (force) {
|
|
179
|
+
// Force mode: delete any existing lease, then set new one
|
|
180
|
+
const existing = await redis.get(leaseKey);
|
|
181
|
+
if (existing) {
|
|
182
|
+
log(`LEASE_FORCE_REPLACE: ${bookId}/${chapterId}/${sceneId}:${stage} — replacing existing lease`);
|
|
183
|
+
await redis.del(leaseKey);
|
|
184
|
+
}
|
|
185
|
+
await redis.set(leaseKey, token, 'EX', ttl);
|
|
186
|
+
log(`LEASE_FORCE_ACQUIRED: ${bookId}/${chapterId}/${sceneId}:${stage} (ttl=${ttl}s, token=${token.slice(0, 16)}...)`);
|
|
187
|
+
return { acquired: true, token, leaseKey, forced: true };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const acquired = await redis.set(leaseKey, token, 'NX', 'EX', ttl);
|
|
191
|
+
|
|
192
|
+
if (acquired) {
|
|
193
|
+
log(`LEASE_ACQUIRED: ${bookId}/${chapterId}/${sceneId}:${stage} (ttl=${ttl}s, token=${token.slice(0, 16)}...)`);
|
|
194
|
+
return { acquired: true, token, leaseKey };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check if lease is still valid (not stale)
|
|
198
|
+
const currentToken = await redis.get(leaseKey);
|
|
199
|
+
if (currentToken) {
|
|
200
|
+
warn(`DISPATCH_SKIPPED_DUPLICATE: ${bookId}/${chapterId}/${sceneId}:${stage} (lease still active)`);
|
|
201
|
+
return { acquired: false, token: null, leaseKey, reason: 'lease_active', currentToken };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { acquired: false, token: null, leaseKey, reason: 'lease_missing' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Release stage lease.
|
|
209
|
+
* Only owner can release.
|
|
210
|
+
*/
|
|
211
|
+
async function releaseStageLease(redis, leaseKey, token) {
|
|
212
|
+
const current = await redis.get(leaseKey);
|
|
213
|
+
|
|
214
|
+
if (!current) {
|
|
215
|
+
log(`LEASE_RELEASED: already expired (key=${leaseKey})`);
|
|
216
|
+
return { released: true, reason: 'already_expired' };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (current !== token) {
|
|
220
|
+
warn(`LEASE_RELEASE_FAILED: token mismatch (key=${leaseKey}, current=${current.slice(0, 16)}..., expected=${token.slice(0, 16)}...)`);
|
|
221
|
+
return { released: false, reason: 'token_mismatch' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await redis.del(leaseKey);
|
|
225
|
+
log(`LEASE_RELEASED: ${leaseKey}`);
|
|
226
|
+
return { released: true, reason: 'success' };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if lease is still valid.
|
|
231
|
+
*/
|
|
232
|
+
async function isLeaseValid(redis, leaseKey, token) {
|
|
233
|
+
const current = await redis.get(leaseKey);
|
|
234
|
+
return current === token;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get lease data.
|
|
239
|
+
*/
|
|
240
|
+
async function getLeaseData(redis, bookId, chapterId, sceneId, stage) {
|
|
241
|
+
const leaseKey = getLeaseKey(bookId, chapterId, sceneId, stage);
|
|
242
|
+
const token = await redis.get(leaseKey);
|
|
243
|
+
return { leaseKey, token };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ======================================================
|
|
247
|
+
// DISPATCH METADATA
|
|
248
|
+
// ======================================================
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Create dispatch metadata.
|
|
252
|
+
*/
|
|
253
|
+
function createDispatchMetadata(dispatchId, stage, worker = 'scheduler', ownership = {}) {
|
|
254
|
+
return {
|
|
255
|
+
dispatch_id: dispatchId,
|
|
256
|
+
stage,
|
|
257
|
+
started_at: Date.now(),
|
|
258
|
+
worker,
|
|
259
|
+
retry_attempt: 0,
|
|
260
|
+
status: 'dispatched',
|
|
261
|
+
lease_key: ownership.leaseKey || null,
|
|
262
|
+
lease_token: ownership.leaseToken || null,
|
|
263
|
+
quota_owned: ownership.quotaOwned === true
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Set dispatch metadata.
|
|
269
|
+
*/
|
|
270
|
+
async function setDispatchMetadata(redis, bookId, chapterId, sceneId, stage, metadata) {
|
|
271
|
+
const key = getDispatchMetaKey(bookId, chapterId, sceneId, stage);
|
|
272
|
+
await redis.set(key, JSON.stringify(metadata), 'EX', _leaseTtl(stage));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get dispatch metadata.
|
|
277
|
+
*/
|
|
278
|
+
async function getDispatchMetadata(redis, bookId, chapterId, sceneId, stage) {
|
|
279
|
+
const key = getDispatchMetaKey(bookId, chapterId, sceneId, stage);
|
|
280
|
+
const raw = await redis.get(key);
|
|
281
|
+
return raw ? JSON.parse(raw) : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function verifyDispatchIdentity(redis, bookId, chapterId, sceneId, stage, dispatchId) {
|
|
285
|
+
if (!dispatchId || typeof dispatchId !== 'string') {
|
|
286
|
+
return { valid: false, reason: 'missing_dispatch_id', metadata: null };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
|
|
290
|
+
if (!metadata) {
|
|
291
|
+
return { valid: false, reason: 'no_active_dispatch', metadata: null };
|
|
292
|
+
}
|
|
293
|
+
if (!metadata.dispatch_id) {
|
|
294
|
+
return { valid: false, reason: 'metadata_missing_dispatch_id', metadata };
|
|
295
|
+
}
|
|
296
|
+
if (metadata.dispatch_id !== dispatchId) {
|
|
297
|
+
return {
|
|
298
|
+
valid: false,
|
|
299
|
+
reason: 'stale_dispatch',
|
|
300
|
+
metadata,
|
|
301
|
+
currentDispatchId: metadata.dispatch_id
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return { valid: true, reason: 'current_dispatch', metadata };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Delete dispatch metadata (on completion/failure).
|
|
309
|
+
*/
|
|
310
|
+
async function deleteDispatchMetadata(redis, bookId, chapterId, sceneId, stage) {
|
|
311
|
+
const key = getDispatchMetaKey(bookId, chapterId, sceneId, stage);
|
|
312
|
+
await redis.del(key);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ======================================================
|
|
316
|
+
// IU IN-FLIGHT MARKER OWNERSHIP (image stage)
|
|
317
|
+
// ======================================================
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Per-dispatch index key of iu-in-flight markers created by this dispatch.
|
|
321
|
+
*/
|
|
322
|
+
function getInFlightIndexKey(dispatchId) {
|
|
323
|
+
return `${IU_IN_FLIGHT_INDEX_PREFIX}:${dispatchId}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Register an iu-in-flight marker under the owning dispatch (iu-processor
|
|
328
|
+
* calls this right after creating the marker, BEFORE gpu.send).
|
|
329
|
+
*/
|
|
330
|
+
async function registerInFlightMarker(redis, dispatchId, markerKey) {
|
|
331
|
+
if (!dispatchId || typeof dispatchId !== 'string') return;
|
|
332
|
+
const indexKey = getInFlightIndexKey(dispatchId);
|
|
333
|
+
await redis.sadd(indexKey, markerKey);
|
|
334
|
+
await redis.expire(indexKey, IU_IN_FLIGHT_MARKER_TTL_S());
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Remove a marker from the owning dispatch index WITHOUT deleting the marker
|
|
339
|
+
* itself. Called after gpu.send:
|
|
340
|
+
* - sent:true → the GPU job is really running; the marker now belongs to
|
|
341
|
+
* the job lifecycle (completion callback clears it) and must
|
|
342
|
+
* NOT be touched by dispatch cancellation;
|
|
343
|
+
* - sent:false → the caller deletes the marker itself (job never existed).
|
|
344
|
+
*/
|
|
345
|
+
async function unregisterInFlightMarker(redis, dispatchId, markerKey) {
|
|
346
|
+
if (!dispatchId || typeof dispatchId !== 'string') return;
|
|
347
|
+
await redis.srem(getInFlightIndexKey(dispatchId), markerKey);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Atomic compare-and-delete for an owned marker (single Lua step).
|
|
352
|
+
* GET+compare+DEL отдельными командами оставляли TOCTOU-окно: claim
|
|
353
|
+
* finalizeDispatch освобождает lease/metadata ДО очистки маркеров (шаг 2.5),
|
|
354
|
+
* поэтому конкурентный новый dispatch мог успеть пере-создать маркер между
|
|
355
|
+
* GET и DEL — и запоздалый cleanup старого dispatch'а удалил бы чужой маркер.
|
|
356
|
+
* Returns:
|
|
357
|
+
* { deleted: true } — marker removed (we owned it)
|
|
358
|
+
* { deleted: false, reason: 'missing' } — key already gone
|
|
359
|
+
* { deleted: false, reason: 'owner_changed' } — re-owned by another dispatch
|
|
360
|
+
*/
|
|
361
|
+
const COMPARE_AND_DELETE_MARKER_SCRIPT = `
|
|
362
|
+
local key = KEYS[1]
|
|
363
|
+
local expected = ARGV[1]
|
|
364
|
+
|
|
365
|
+
local current = redis.call('GET', key)
|
|
366
|
+
|
|
367
|
+
if not current then
|
|
368
|
+
return 0
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
if current ~= ARGV[1] then
|
|
372
|
+
return -1
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
redis.call('DEL', KEYS[1])
|
|
376
|
+
|
|
377
|
+
return 1
|
|
378
|
+
`;
|
|
379
|
+
|
|
380
|
+
async function compareAndDeleteMarker(redis, markerKey, expectedOwner) {
|
|
381
|
+
const result = Number(await redis.eval(COMPARE_AND_DELETE_MARKER_SCRIPT, 1, markerKey, expectedOwner));
|
|
382
|
+
if (result === 1) return { deleted: true };
|
|
383
|
+
if (result === 0) return { deleted: false, reason: 'missing' };
|
|
384
|
+
return { deleted: false, reason: 'owner_changed' };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Release all iu-in-flight markers still owned by a dispatch (i.e. markers
|
|
389
|
+
* whose GPU job was NEVER actually sent). Atomic compare-and-delete by
|
|
390
|
+
* dispatch_id: a marker re-created by a newer dispatch is never touched.
|
|
391
|
+
* Returns { removed, kept, errors } — errors MUST NOT be swallowed by callers.
|
|
392
|
+
*/
|
|
393
|
+
async function releaseDispatchInFlightMarkers(redis, dispatchId) {
|
|
394
|
+
if (!dispatchId || typeof dispatchId !== 'string') {
|
|
395
|
+
return { removed: 0, kept: 0, errors: [] };
|
|
396
|
+
}
|
|
397
|
+
const indexKey = getInFlightIndexKey(dispatchId);
|
|
398
|
+
const errors = [];
|
|
399
|
+
let members = [];
|
|
400
|
+
try {
|
|
401
|
+
members = await redis.smembers(indexKey);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
errors.push(`index_read:${err.message}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let removed = 0;
|
|
407
|
+
let kept = 0;
|
|
408
|
+
for (const markerKey of members || []) {
|
|
409
|
+
try {
|
|
410
|
+
const cas = await compareAndDeleteMarker(redis, markerKey, dispatchId);
|
|
411
|
+
if (cas.deleted) {
|
|
412
|
+
removed++;
|
|
413
|
+
} else if (cas.reason === 'owner_changed') {
|
|
414
|
+
// Re-owned by a newer dispatch — a job may really be running.
|
|
415
|
+
kept++;
|
|
416
|
+
}
|
|
417
|
+
// 'missing' — already gone (TTL/explicit cleanup), nothing to do
|
|
418
|
+
} catch (err) {
|
|
419
|
+
errors.push(`${markerKey}:${err.message}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
await redis.del(indexKey);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
errors.push(`index_del:${err.message}`);
|
|
427
|
+
}
|
|
428
|
+
return { removed, kept, errors };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ======================================================
|
|
432
|
+
// ORPHAN GENERATING DETECTION & REPAIR (audit d9d67a3)
|
|
433
|
+
// ======================================================
|
|
434
|
+
// GENERATING without ANY evidence of a live generation is an orphan: the
|
|
435
|
+
// dispatch died (crash/restart/TTL expiry) and no rollback ever ran, so the
|
|
436
|
+
// scene is stuck in GENERATING forever (UI ghost "0/9", Stop All powerless).
|
|
437
|
+
//
|
|
438
|
+
// Evidence of a LIVE generation (any one is enough):
|
|
439
|
+
// - dispatch lease (held + renewed from dispatch to finalization)
|
|
440
|
+
// - dispatch metadata (same TTL as the lease, owns the dispatch_id)
|
|
441
|
+
// - iu-in-flight marker (image only: a GPU job may still be running)
|
|
442
|
+
//
|
|
443
|
+
// A stage is only ever set GENERATING AFTER lease+metadata exist (dispatch
|
|
444
|
+
// order: acquireLease → setDispatchMetadata → executor markGenerating), so
|
|
445
|
+
// the absence of ALL evidence proves the generation is dead. Age alone is
|
|
446
|
+
// NEVER a reason to repair — live long jobs keep their lease renewed.
|
|
447
|
+
|
|
448
|
+
const IU_IN_FLIGHT_KEY_PREFIX = 'animastor:iu-in-flight';
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Check whether a stage's generation is still alive.
|
|
452
|
+
* @returns {Promise<{alive: boolean, reason: string}>}
|
|
453
|
+
*/
|
|
454
|
+
async function getDispatchEvidence(redis, bookId, chapterId, sceneId, stage) {
|
|
455
|
+
const leaseToken = await redis.get(getLeaseKey(bookId, chapterId, sceneId, stage));
|
|
456
|
+
if (leaseToken) {
|
|
457
|
+
return { alive: true, reason: 'lease_present' };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
|
|
461
|
+
if (metadata) {
|
|
462
|
+
return { alive: true, reason: 'dispatch_meta_present' };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (stage === 'image') {
|
|
466
|
+
// Marker key: animastor:iu-in-flight:{bookId}_{chapterId}_{sceneId}_{unitId}
|
|
467
|
+
const pattern = `${IU_IN_FLIGHT_KEY_PREFIX}:${bookId}_${chapterId}_${sceneId}_*`;
|
|
468
|
+
let cursor = '0';
|
|
469
|
+
do {
|
|
470
|
+
const [next, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
|
|
471
|
+
cursor = next;
|
|
472
|
+
if (keys && keys.length > 0) {
|
|
473
|
+
return { alive: true, reason: 'iu_in_flight_present' };
|
|
474
|
+
}
|
|
475
|
+
} while (cursor !== '0');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return { alive: false, reason: 'no_evidence' };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Repair orphan GENERATING states for a whole book.
|
|
483
|
+
*
|
|
484
|
+
* Every per-asset state stuck in GENERATING with NO live-generation evidence
|
|
485
|
+
* (lease/dispatch-meta/in-flight marker) is rolled back through the FSM-safe
|
|
486
|
+
* path GENERATING → DIRTY → PENDING (orchestrator.rollbackStageToPending —
|
|
487
|
+
* a direct GENERATING → PENDING is forbidden by the FSM).
|
|
488
|
+
*
|
|
489
|
+
* Used by Stop All (cancel-generation) after clearAllLeasesForBook: that
|
|
490
|
+
* call only cancels lease-backed dispatches; a GENERATING state whose
|
|
491
|
+
* dispatch/lease already died would otherwise survive the cancel forever.
|
|
492
|
+
*
|
|
493
|
+
* Never touches a live GENERATING (any evidence → skip). Does NOT dispatch
|
|
494
|
+
* anything and does not require workers.
|
|
495
|
+
*
|
|
496
|
+
* @param {Object} redis
|
|
497
|
+
* @param {string} bookId
|
|
498
|
+
* @param {{reason?: string}} [opts]
|
|
499
|
+
* @returns {Promise<{repaired: Array<{chapterId, sceneId, stage, path}>}>}
|
|
500
|
+
*/
|
|
501
|
+
async function repairOrphanGeneratingStates(redis, bookId, opts = {}) {
|
|
502
|
+
const stateModule = lazyHostBinding('state');
|
|
503
|
+
// S-5: FSM-safe rollback via the injected orchestration seam
|
|
504
|
+
const rollbackStageToPending = orchestrationSeams.getOrchestrationSeam('rollbackStageToPending');
|
|
505
|
+
const reason = opts.reason || 'orphan_generating_repair';
|
|
506
|
+
const repaired = [];
|
|
507
|
+
|
|
508
|
+
const pattern = `${stateModule.ASSET_STATE_KEY_PREFIX}:${bookId}:*`;
|
|
509
|
+
const seen = new Set();
|
|
510
|
+
let cursor = '0';
|
|
511
|
+
do {
|
|
512
|
+
const [next, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
|
|
513
|
+
cursor = next;
|
|
514
|
+
for (const key of keys || []) {
|
|
515
|
+
// animastor:asset-state:{bookId}:{chapterId}:{sceneId}
|
|
516
|
+
// (bookId/chapterId/sceneId never contain ':')
|
|
517
|
+
const parts = key.split(':');
|
|
518
|
+
if (parts.length < 5) continue;
|
|
519
|
+
const chapterId = parts[3];
|
|
520
|
+
const sceneId = parts[4];
|
|
521
|
+
const sceneKey = `${chapterId}:${sceneId}`;
|
|
522
|
+
if (seen.has(sceneKey)) continue;
|
|
523
|
+
seen.add(sceneKey);
|
|
524
|
+
|
|
525
|
+
const states = await stateModule.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
526
|
+
for (const stage of _allStages()) {
|
|
527
|
+
if (states[stage] !== stateModule.AssetState.GENERATING) continue;
|
|
528
|
+
const evidence = await getDispatchEvidence(redis, bookId, chapterId, sceneId, stage);
|
|
529
|
+
if (evidence.alive) continue;
|
|
530
|
+
const rollback = await rollbackStageToPending(
|
|
531
|
+
redis, bookId, chapterId, sceneId, stage, null, reason
|
|
532
|
+
);
|
|
533
|
+
if (rollback.changed) {
|
|
534
|
+
repaired.push({ chapterId, sceneId, stage, path: rollback.path });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
} while (cursor !== '0');
|
|
539
|
+
|
|
540
|
+
return { repaired };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ======================================================
|
|
544
|
+
// ACTIVE COUNTERS (for backpressure)
|
|
545
|
+
// ======================================================
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Increment active stage counter.
|
|
549
|
+
*/
|
|
550
|
+
async function incrementActiveCounter(redis, stage) {
|
|
551
|
+
const key = getActiveCounterKey(stage);
|
|
552
|
+
return await redis.incr(key);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Decrement active stage counter.
|
|
557
|
+
*/
|
|
558
|
+
async function decrementActiveCounter(redis, stage) {
|
|
559
|
+
const key = getActiveCounterKey(stage);
|
|
560
|
+
const current = await redis.get(key);
|
|
561
|
+
if (current) {
|
|
562
|
+
const val = parseInt(current, 10);
|
|
563
|
+
if (val > 0) {
|
|
564
|
+
return await redis.decr(key);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return 0;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Get active counter value.
|
|
572
|
+
*/
|
|
573
|
+
async function getActiveCounter(redis, stage) {
|
|
574
|
+
const key = getActiveCounterKey(stage);
|
|
575
|
+
const val = await redis.get(key);
|
|
576
|
+
return parseInt(val || '0', 10);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ======================================================
|
|
580
|
+
// BACKPRESSURE QUOTAS
|
|
581
|
+
// ======================================================
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Check if quota is available for a stage.
|
|
585
|
+
*/
|
|
586
|
+
async function checkQuota(redis, stage) {
|
|
587
|
+
const current = await getActiveCounter(redis, stage);
|
|
588
|
+
const max = _maxActive(stage);
|
|
589
|
+
return { exceeded: current >= max, current, max };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Lua script for atomic quota acquire: check limit and increment in one Redis call.
|
|
593
|
+
// Returns 0 if quota exceeded, or new counter value if acquired.
|
|
594
|
+
const ATOMIC_ACQUIRE_SCRIPT = `
|
|
595
|
+
local key = KEYS[1]
|
|
596
|
+
local max = tonumber(ARGV[1])
|
|
597
|
+
local current = redis.call('GET', key)
|
|
598
|
+
if current and tonumber(current) >= max then
|
|
599
|
+
return 0
|
|
600
|
+
end
|
|
601
|
+
return redis.call('INCR', key)
|
|
602
|
+
`;
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Acquire quota slot — atomically.
|
|
606
|
+
* Uses Lua EVAL to check limit and increment in a single Redis call.
|
|
607
|
+
* Eliminates race condition between checkQuota (GET) and incrementActiveCounter (INCR).
|
|
608
|
+
*/
|
|
609
|
+
async function acquireQuota(redis, stage) {
|
|
610
|
+
const key = getActiveCounterKey(stage);
|
|
611
|
+
const max = _maxActive(stage);
|
|
612
|
+
|
|
613
|
+
const result = await redis.eval(ATOMIC_ACQUIRE_SCRIPT, 1, key, max);
|
|
614
|
+
|
|
615
|
+
// ioredis may return EVAL result as string '0' — use loose check
|
|
616
|
+
if (!result || result === 0) {
|
|
617
|
+
const current = await getActiveCounter(redis, stage);
|
|
618
|
+
return { acquired: false, reason: 'quota_exceeded', current, max };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return { acquired: true, current: result, max };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Release quota slot.
|
|
626
|
+
*/
|
|
627
|
+
async function releaseQuota(redis, stage) {
|
|
628
|
+
await decrementActiveCounter(redis, stage);
|
|
629
|
+
const current = await getActiveCounter(redis, stage);
|
|
630
|
+
return { released: true, current };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ======================================================
|
|
634
|
+
// SCHEDULER TICK LOCK (single-flight)
|
|
635
|
+
// ======================================================
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Acquire scheduler tick lock.
|
|
639
|
+
*/
|
|
640
|
+
async function acquireSchedulerTickLock(redis) {
|
|
641
|
+
const token = generateDispatchToken();
|
|
642
|
+
const acquired = await redis.set(SCHEDULER_TICK_LOCK, token, 'NX', 'EX', SCHEDULER_TICK_LOCK_TTL);
|
|
643
|
+
return { acquired, token, lockKey: SCHEDULER_TICK_LOCK };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Release scheduler tick lock.
|
|
648
|
+
*/
|
|
649
|
+
async function releaseSchedulerTickLock(redis, token) {
|
|
650
|
+
const current = await redis.get(SCHEDULER_TICK_LOCK);
|
|
651
|
+
if (!current) return { released: true, reason: 'already_expired' };
|
|
652
|
+
if (current !== token) return { released: false, reason: 'token_mismatch' };
|
|
653
|
+
await redis.del(SCHEDULER_TICK_LOCK);
|
|
654
|
+
return { released: true, reason: 'success' };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Check if scheduler tick is running.
|
|
659
|
+
*/
|
|
660
|
+
async function isSchedulerTickRunning(redis) {
|
|
661
|
+
return await redis.exists(SCHEDULER_TICK_LOCK);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ======================================================
|
|
665
|
+
// STAGE DISPATCH LOGIC
|
|
666
|
+
// ======================================================
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Log dispatch event to journal.
|
|
670
|
+
*/
|
|
671
|
+
async function logDispatchEvent(redis, bookId, chapterId, sceneId, eventType, stage, details = {}) {
|
|
672
|
+
await journal.appendSceneEvent(
|
|
673
|
+
redis,
|
|
674
|
+
bookId,
|
|
675
|
+
chapterId,
|
|
676
|
+
sceneId,
|
|
677
|
+
`DISPATCH_${eventType}`,
|
|
678
|
+
stage,
|
|
679
|
+
{ ...details, dispatchedBy: 'dispatch-engine' }
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Check if dispatch should be skipped (duplicate detection).
|
|
685
|
+
*
|
|
686
|
+
* Liveness uses the same canonical signal as reconciliation-engine: the
|
|
687
|
+
* lease's REMAINING TTL (kept pinned by renewal), never metadata.started_at.
|
|
688
|
+
* A live renewed lease — however old — is `lease_active` (skip, no duplicate);
|
|
689
|
+
* only a lease whose TTL decayed below target - grace (renewals stopped) is
|
|
690
|
+
* `stale_lease` and may be recovered. (audit c8b79f6: the started_at age
|
|
691
|
+
* heuristic here mirrored the reconciliation bug that re-dispatched live
|
|
692
|
+
* video jobs every ~28 min.)
|
|
693
|
+
*/
|
|
694
|
+
async function shouldSkipDispatch(redis, bookId, chapterId, sceneId, stage) {
|
|
695
|
+
const { leaseKey, token } = await getLeaseData(redis, bookId, chapterId, sceneId, stage);
|
|
696
|
+
|
|
697
|
+
if (token) {
|
|
698
|
+
// Lease exists — decide liveness by remaining TTL, not started_at age.
|
|
699
|
+
const leaseTtlS = await redis.ttl(leaseKey);
|
|
700
|
+
|
|
701
|
+
if (leaseManager.isLeaseStale(leaseTtlS, stage)) {
|
|
702
|
+
// Renewals stopped and the TTL decayed past the grace window — the
|
|
703
|
+
// owner is gone. Surface the token so the caller can release it.
|
|
704
|
+
warn(`STALE_LEASE: ${bookId}/${chapterId}/${sceneId}:${stage} (ttl=${leaseTtlS}s, target=${leaseManager.getRenewalTargetTtlS(stage)}s)`);
|
|
705
|
+
return { skip: true, reason: 'stale_lease', leaseKey, currentToken: token };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return { skip: true, reason: 'lease_active', leaseKey, currentToken: token };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// No lease - proceed with dispatch
|
|
712
|
+
return { skip: false, reason: 'no_lease' };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Evaluate policy for dispatch decision — UNUSED (removed in Phase 6).
|
|
717
|
+
* Legacy governance modules (policyEngine, workloadClassifier, costEstimator)
|
|
718
|
+
* were loaded via safeRequire but never wired into the production dispatch path.
|
|
719
|
+
*/
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Dispatch a scene stage.
|
|
723
|
+
* This is the ONLY way to start a scene stage.
|
|
724
|
+
* When options.force=true, any existing lease is cleared before dispatch.
|
|
725
|
+
*
|
|
726
|
+
* Returns:
|
|
727
|
+
* - { dispatched: true, dispatchId, stage, leaseKey }
|
|
728
|
+
* - { dispatched: false, reason, skip: true/false }
|
|
729
|
+
*/
|
|
730
|
+
async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBook, buildId, options = {}) {
|
|
731
|
+
const { force = false } = options;
|
|
732
|
+
const dispatchId = generateDispatchToken();
|
|
733
|
+
|
|
734
|
+
log(`DISPATCH_REQUEST: ${bookId}/${chapterId}/${sceneId}:${stage}${force ? ' (force=true)' : ''}`);
|
|
735
|
+
|
|
736
|
+
// Force mode pre-clear: cancel existing dispatch, release quota ТОЛЬКО при наличии lease
|
|
737
|
+
if (force) {
|
|
738
|
+
const { leaseKey, token } = await getLeaseData(redis, bookId, chapterId, sceneId, stage);
|
|
739
|
+
if (token) {
|
|
740
|
+
log(`FORCE_CLEAR_LEASE: ${bookId}/${chapterId}/${sceneId}:${stage} — cancelling existing dispatch`);
|
|
741
|
+
// T5: Используем cancelActiveDispatch для корректного освобождения
|
|
742
|
+
await cancelActiveDispatch(redis, bookId, chapterId, sceneId, stage, 'force_reset');
|
|
743
|
+
} else {
|
|
744
|
+
log(`FORCE_CLEAR: ${bookId}/${chapterId}/${sceneId}:${stage} — no active lease, only clearing metadata`);
|
|
745
|
+
await deleteDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
|
|
746
|
+
// T5: Не освобождаем quota — не было lease → не было quota для этой сцены
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Phase 9 Step 0: Check circuit breaker (with auto-recovery: an OPEN
|
|
751
|
+
// circuit that has cooled down transitions to HALF_OPEN and lets this
|
|
752
|
+
// dispatch through as a test request — see checkDispatchWithRecovery).
|
|
753
|
+
const circuitStatus = await circuitBreaker.checkDispatchWithRecovery(redis, stage);
|
|
754
|
+
if (circuitStatus.recovered) {
|
|
755
|
+
log(`CIRCUIT_RECOVERED_TEST: ${bookId}/${chapterId}/${sceneId}:${stage} → half-open test dispatch`);
|
|
756
|
+
}
|
|
757
|
+
// Half-open test-permit tracking: if this dispatch was admitted as a
|
|
758
|
+
// half-open test request, its permit MUST be released on every abort path
|
|
759
|
+
// below that does not reach finalizeDispatch('success'|'failure') — those
|
|
760
|
+
// two outcomes release it themselves via recordSuccess/recordFailure.
|
|
761
|
+
// Otherwise the permit leaks and the stage stays blocked on
|
|
762
|
+
// 'half_open_limit_reached' forever (2026-08-26 video incident follow-up).
|
|
763
|
+
const isCircuitTestRequest = Boolean(circuitStatus.allowed && circuitStatus.isTestRequest);
|
|
764
|
+
const releaseTestPermit = () => isCircuitTestRequest
|
|
765
|
+
? circuitBreaker.releaseHalfOpenPermit(redis, stage)
|
|
766
|
+
: Promise.resolve({ released: false });
|
|
767
|
+
if (!circuitStatus.allowed) {
|
|
768
|
+
log(`CIRCUIT_BLOCKED: ${bookId}/${chapterId}/${sceneId}:${stage} (circuit: ${circuitStatus.circuitState})`);
|
|
769
|
+
await journal.appendSceneEvent(
|
|
770
|
+
redis,
|
|
771
|
+
bookId,
|
|
772
|
+
chapterId,
|
|
773
|
+
sceneId,
|
|
774
|
+
'DISPATCH_BLOCKED_CIRCUIT',
|
|
775
|
+
stage,
|
|
776
|
+
{ circuit: circuitStatus.circuitState, reason: circuitStatus.reason }
|
|
777
|
+
);
|
|
778
|
+
return { dispatched: false, reason: 'circuit_open', circuitState: circuitStatus.circuitState, dispatchId };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Step 1: Check for duplicate/lease (skipped in force mode — lease was already cleared)
|
|
782
|
+
if (!force) {
|
|
783
|
+
const shouldSkip = await shouldSkipDispatch(redis, bookId, chapterId, sceneId, stage);
|
|
784
|
+
if (shouldSkip.skip) {
|
|
785
|
+
if (shouldSkip.reason === 'stale_lease') {
|
|
786
|
+
// Recover the stale lease through the canonical cancellation
|
|
787
|
+
// path (lease + owned quota + renewal timer + completion
|
|
788
|
+
// marker) instead of a bare lease delete — a bare delete would
|
|
789
|
+
// leak the quota slot and the in-memory renewal timer.
|
|
790
|
+
await cancelActiveDispatch(redis, bookId, chapterId, sceneId, stage, 'stale_lease_recovery');
|
|
791
|
+
// Fall through to create new lease
|
|
792
|
+
} else {
|
|
793
|
+
await logDispatchEvent(redis, bookId, chapterId, sceneId, 'SKIPPED_DUPLICATE', stage, {
|
|
794
|
+
reason: shouldSkip.reason,
|
|
795
|
+
dispatchId
|
|
796
|
+
}); await releaseTestPermit();
|
|
797
|
+
return { dispatched: false, reason: 'duplicate', skip: true, dispatchId };
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
} else {
|
|
801
|
+
log(`FORCE_DISPATCH: ${bookId}/${chapterId}/${sceneId}:${stage} — duplicate check skipped`);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Step 2: Acquire quota
|
|
805
|
+
const quota = await acquireQuota(redis, stage);
|
|
806
|
+
if (!quota.acquired) {
|
|
807
|
+
log(`BACKPRESSURE_DELAY: ${bookId}/${chapterId}/${sceneId}:${stage} (active=${quota.current}/${quota.max})`);
|
|
808
|
+
// B5: НЕ вызывать releaseQuota — квота не была захвачена (Lua скрипт вернул 0, INCR не было).
|
|
809
|
+
// releaseQuota здесь декрементировал бы счётчик ниже нуля → дрифт активных счётчиков.
|
|
810
|
+
await logDispatchEvent(redis, bookId, chapterId, sceneId, 'BACKPRESSURE_DELAY', stage, {
|
|
811
|
+
current: quota.current,
|
|
812
|
+
max: quota.max
|
|
813
|
+
});
|
|
814
|
+
await releaseTestPermit();
|
|
815
|
+
return { dispatched: false, reason: 'backpressure', quota: quota, dispatchId };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Phase 9: Check retry budget
|
|
819
|
+
const failureType = 'transient';
|
|
820
|
+
const retryBudgetCheck = await retryBudget.checkRetryBudget(
|
|
821
|
+
redis,
|
|
822
|
+
bookId,
|
|
823
|
+
chapterId,
|
|
824
|
+
sceneId,
|
|
825
|
+
stage,
|
|
826
|
+
failureType,
|
|
827
|
+
'scheduler'
|
|
828
|
+
);
|
|
829
|
+
if (!retryBudgetCheck.allowed) {
|
|
830
|
+
log(`RETRY_BUDGET_EXCEEDED: ${bookId}/${chapterId}/${sceneId}:${stage} (${retryBudgetCheck.reason})`);
|
|
831
|
+
await releaseQuota(redis, stage);
|
|
832
|
+
await releaseTestPermit();
|
|
833
|
+
await journal.appendSceneEvent(
|
|
834
|
+
redis,
|
|
835
|
+
bookId,
|
|
836
|
+
chapterId,
|
|
837
|
+
sceneId,
|
|
838
|
+
'RETRY_BUDGET_EXCEEDED',
|
|
839
|
+
stage,
|
|
840
|
+
{ reason: retryBudgetCheck.reason, budgets: retryBudgetCheck.budgets }
|
|
841
|
+
);
|
|
842
|
+
return { dispatched: false, reason: 'retry_budget_exceeded', budgets: retryBudgetCheck.budgets, dispatchId };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Step 3: Acquire lease (force mode bypasses existing lease)
|
|
846
|
+
const lease = await acquireStageLease(redis, bookId, chapterId, sceneId, stage, force);
|
|
847
|
+
if (!lease.acquired) {
|
|
848
|
+
// Release quota if lease acquisition failed
|
|
849
|
+
await releaseQuota(redis, stage);
|
|
850
|
+
await releaseTestPermit();
|
|
851
|
+
return { dispatched: false, reason: lease.reason, dispatchId };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Step 4: Set dispatch metadata
|
|
855
|
+
const metadata = createDispatchMetadata(dispatchId, stage, 'scheduler', {
|
|
856
|
+
leaseKey: lease.leaseKey,
|
|
857
|
+
leaseToken: lease.token,
|
|
858
|
+
quotaOwned: true
|
|
859
|
+
});
|
|
860
|
+
await setDispatchMetadata(redis, bookId, chapterId, sceneId, stage, metadata);
|
|
861
|
+
|
|
862
|
+
// Step 5: Log dispatch event
|
|
863
|
+
await logDispatchEvent(redis, bookId, chapterId, sceneId, 'STARTED', stage, {
|
|
864
|
+
dispatchId,
|
|
865
|
+
leaseKey: lease.leaseKey,
|
|
866
|
+
started_at: metadata.started_at
|
|
867
|
+
}); // Step 6: Get orchestrator to perform dispatch
|
|
868
|
+
try {
|
|
869
|
+
// S-5: the stage executor entry (scene-orchestrator.dispatchStage) is
|
|
870
|
+
// injected by the composition root — runtime must not import
|
|
871
|
+
// orchestration implementation directly.
|
|
872
|
+
const executeStageDispatch = orchestrationSeams.getOrchestrationSeam('dispatchStage');
|
|
873
|
+
|
|
874
|
+
// T4: передаём dispatchId — executor включит его в job specs для GPU Hub
|
|
875
|
+
// dispatchId устанавливается в metadata ДО вызова executor, поэтому
|
|
876
|
+
// callback при проверке identity увидит актуальный dispatchId.
|
|
877
|
+
metadata.dispatch_id = dispatchId;
|
|
878
|
+
await setDispatchMetadata(redis, bookId, chapterId, sceneId, stage, metadata);
|
|
879
|
+
|
|
880
|
+
const result = await executeStageDispatch(
|
|
881
|
+
redis,
|
|
882
|
+
{ book_id: bookId, chapter_id: chapterId, scene_id: sceneId },
|
|
883
|
+
loadedBook,
|
|
884
|
+
buildId,
|
|
885
|
+
stage,
|
|
886
|
+
dispatchId // T4: передаём dispatchId в executor
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
// If orchestrator didn't dispatch (already done/cached), release quota and lease
|
|
890
|
+
if (!result.dispatched) {
|
|
891
|
+
log(`DISPATCH_SKIPPED: ${bookId}/${chapterId}/${sceneId}:${stage} - ${result.reason || 'already_done'}`);
|
|
892
|
+
if (!result.completed) {
|
|
893
|
+
await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
|
|
894
|
+
outcome: 'cancelled',
|
|
895
|
+
dispatchId,
|
|
896
|
+
reason: result.reason || 'executor_sent_no_jobs'
|
|
897
|
+
});
|
|
898
|
+
// Rollback состояния выполняет executor (scene-orchestrator);
|
|
899
|
+
// если он не удался — не глотаем, явный error-лог.
|
|
900
|
+
if (result.rollbackFailed) {
|
|
901
|
+
error(`STATE_ROLLBACK_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${result.rollbackReason || 'unknown'} — stage may be stuck in GENERATING`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// Cancelled finalize does not touch the circuit breaker — release
|
|
905
|
+
// the test permit here so a half-open circuit can keep recovering.
|
|
906
|
+
await releaseTestPermit();
|
|
907
|
+
return { dispatched: false, dispatchId, reason: result.reason || 'already_done', result };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// T6: Start lease renewal for this actively dispatched job
|
|
911
|
+
// Only started after confirming dispatcher returned dispatched:true
|
|
912
|
+
// (not for cache hits, backpressure, or empty executor results)
|
|
913
|
+
startDispatchRenewal(redis, bookId, chapterId, sceneId, stage, lease.leaseKey, lease.token);
|
|
914
|
+
|
|
915
|
+
// Set 3s debounce — task actually sent to GPU hub
|
|
916
|
+
await redis.set(`animastor:runtime:last-active:${stage}`, '1', 'EX', 3);
|
|
917
|
+
|
|
918
|
+
return { dispatched: true, dispatchId, stage, leaseKey: lease.leaseKey, result };
|
|
919
|
+
} catch (err) {
|
|
920
|
+
error(`DISPATCH_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${err.message}`);
|
|
921
|
+
await releaseTestPermit().catch(() => {});
|
|
922
|
+
await logDispatchEvent(redis, bookId, chapterId, sceneId, 'FAILED', stage, {
|
|
923
|
+
dispatchId,
|
|
924
|
+
error: err.message
|
|
925
|
+
});
|
|
926
|
+
// FSM-валидный rollback состояния: executor мог перевести стадию в
|
|
927
|
+
// GENERATING до throw. Без rollback сцена осталась бы в GENERATING
|
|
928
|
+
// навсегда (ghost, forensic audit 6929ba5). Ошибка rollback не
|
|
929
|
+
// глотается — явный error-лог (rollbackStageToPending дополнительно
|
|
930
|
+
// пишет journal + метрику).
|
|
931
|
+
try {
|
|
932
|
+
const rollback = await orchestrationSeams.getOrchestrationSeam('rollbackStageToPending')(
|
|
933
|
+
redis, bookId, chapterId, sceneId, stage, buildId,
|
|
934
|
+
`dispatch_error:${err.message}`
|
|
935
|
+
);
|
|
936
|
+
if (!rollback.changed && !rollback.alreadyPending && rollback.reason !== 'already_ready') {
|
|
937
|
+
error(`STATE_ROLLBACK_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${rollback.reason} — stage may be stuck in GENERATING`);
|
|
938
|
+
}
|
|
939
|
+
} catch (rollbackErr) {
|
|
940
|
+
error(`STATE_ROLLBACK_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${rollbackErr.message} — stage may be stuck in GENERATING`);
|
|
941
|
+
}
|
|
942
|
+
await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
|
|
943
|
+
outcome: 'cancelled',
|
|
944
|
+
dispatchId,
|
|
945
|
+
reason: `dispatch_error:${err.message}`
|
|
946
|
+
}).catch(finalizeErr => {
|
|
947
|
+
warn(`Dispatch rollback finalization failed: ${finalizeErr.message}`);
|
|
948
|
+
});
|
|
949
|
+
return { dispatched: false, reason: 'dispatch_error', error: err.message, dispatchId };
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ======================================================
|
|
954
|
+
// T2: Единая finalization — finalizeDispatch
|
|
955
|
+
// ======================================================
|
|
956
|
+
// Единственная точка финализации dispatch. Принимает outcome:
|
|
957
|
+
// 'success' → recordSuccess, DISPATCH_COMPLETED, без retry budget
|
|
958
|
+
// 'failure' → recordFailure, DISPATCH_FAILED, consumeRetryBudget ровно 1×
|
|
959
|
+
// 'cancelled' → без circuit breaker, без retry budget, DISPATCH_CANCELLED
|
|
960
|
+
//
|
|
961
|
+
// Идемпотентность: один dispatch финализируется ровно один раз
|
|
962
|
+
// (проверка completion marker). Повторный вызов для того же dispatch
|
|
963
|
+
// возвращает { finalized: false, reason: 'already_finalized' }.
|
|
964
|
+
//
|
|
965
|
+
// Порядок:
|
|
966
|
+
// 1. Одним Lua-шагом проверить metadata + lease owner, claim marker,
|
|
967
|
+
// удалить metadata/lease и освободить owned quota
|
|
968
|
+
// 2. Stop renewal
|
|
969
|
+
// 3. Circuit breaker согласно outcome
|
|
970
|
+
// 4. Retry budget только для failure
|
|
971
|
+
// 5. Записать journal event
|
|
972
|
+
// ======================================================
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Finalize a dispatch with a specific outcome.
|
|
976
|
+
*
|
|
977
|
+
* @param {Object} redis
|
|
978
|
+
* @param {string} bookId
|
|
979
|
+
* @param {string} chapterId
|
|
980
|
+
* @param {string} sceneId
|
|
981
|
+
* @param {string} stage - 'audio'|'image'|'video'
|
|
982
|
+
* @param {Object} options
|
|
983
|
+
* @param {'success'|'failure'|'cancelled'} options.outcome
|
|
984
|
+
* @param {string} [options.dispatchId]
|
|
985
|
+
* @param {string} [options.reason]
|
|
986
|
+
* @param {string} [options.failureType] - for failure-taxonomy
|
|
987
|
+
* @param {string} [options.workerId]
|
|
988
|
+
* @returns {Promise<{finalized: boolean, reason: string}>}
|
|
989
|
+
*/
|
|
990
|
+
const CLAIM_FINALIZATION_SCRIPT = `
|
|
991
|
+
local metadata = redis.call('GET', KEYS[1])
|
|
992
|
+
if not metadata then
|
|
993
|
+
if redis.call('EXISTS', KEYS[2]) == 1 then
|
|
994
|
+
return 2
|
|
995
|
+
end
|
|
996
|
+
return 0
|
|
997
|
+
end
|
|
998
|
+
if metadata ~= ARGV[1] then
|
|
999
|
+
return -1
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
local lease = redis.call('GET', KEYS[3])
|
|
1003
|
+
local expected_lease = ARGV[4]
|
|
1004
|
+
if lease and (expected_lease == '' or lease ~= expected_lease) then
|
|
1005
|
+
return -2
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
local claimed = redis.call(
|
|
1009
|
+
'SET',
|
|
1010
|
+
KEYS[2],
|
|
1011
|
+
ARGV[2],
|
|
1012
|
+
'NX',
|
|
1013
|
+
'EX',
|
|
1014
|
+
tonumber(ARGV[3])
|
|
1015
|
+
)
|
|
1016
|
+
if not claimed then
|
|
1017
|
+
return 2
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
redis.call('DEL', KEYS[1])
|
|
1021
|
+
|
|
1022
|
+
if lease and expected_lease ~= '' then
|
|
1023
|
+
redis.call('DEL', KEYS[3])
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
if ARGV[5] == '1' then
|
|
1027
|
+
local quota = tonumber(redis.call('GET', KEYS[4]) or '0')
|
|
1028
|
+
if quota > 0 then
|
|
1029
|
+
redis.call('DECR', KEYS[4])
|
|
1030
|
+
end
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
return 1
|
|
1034
|
+
`;
|
|
1035
|
+
|
|
1036
|
+
async function claimFinalization(
|
|
1037
|
+
redis,
|
|
1038
|
+
metadataKey,
|
|
1039
|
+
completedKey,
|
|
1040
|
+
leaseKey,
|
|
1041
|
+
quotaKey,
|
|
1042
|
+
expectedMetadataRaw,
|
|
1043
|
+
markerValue,
|
|
1044
|
+
markerTtl,
|
|
1045
|
+
leaseToken,
|
|
1046
|
+
quotaOwned
|
|
1047
|
+
) {
|
|
1048
|
+
return redis.eval(
|
|
1049
|
+
CLAIM_FINALIZATION_SCRIPT,
|
|
1050
|
+
4,
|
|
1051
|
+
metadataKey,
|
|
1052
|
+
completedKey,
|
|
1053
|
+
leaseKey,
|
|
1054
|
+
quotaKey,
|
|
1055
|
+
expectedMetadataRaw,
|
|
1056
|
+
markerValue,
|
|
1057
|
+
markerTtl,
|
|
1058
|
+
leaseToken || '',
|
|
1059
|
+
quotaOwned ? '1' : '0'
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async function finalizeDispatch(redis, bookId, chapterId, sceneId, stage, options = {}) {
|
|
1064
|
+
const { outcome = 'success', dispatchId, reason, failureType, workerId } = options;
|
|
1065
|
+
|
|
1066
|
+
if (!['success', 'failure', 'cancelled'].includes(outcome)) {
|
|
1067
|
+
throw new Error(`finalizeDispatch: invalid outcome '${outcome}' — must be success|failure|cancelled`);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (!dispatchId || typeof dispatchId !== 'string') {
|
|
1071
|
+
return { finalized: false, reason: 'missing_dispatch_id' };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const completedKey = getDispatchCompletedKey(bookId, chapterId, sceneId, stage, dispatchId);
|
|
1075
|
+
if (await redis.get(completedKey)) {
|
|
1076
|
+
return { finalized: false, reason: 'already_finalized' };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const metadataKey = getDispatchMetaKey(bookId, chapterId, sceneId, stage);
|
|
1080
|
+
const metadataRaw = await redis.get(metadataKey);
|
|
1081
|
+
if (!metadataRaw) {
|
|
1082
|
+
return { finalized: false, reason: 'no_active_dispatch' };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
let metadata;
|
|
1086
|
+
try {
|
|
1087
|
+
metadata = JSON.parse(metadataRaw);
|
|
1088
|
+
} catch (parseErr) {
|
|
1089
|
+
return { finalized: false, reason: 'invalid_dispatch_metadata' };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (metadata.dispatch_id !== dispatchId) {
|
|
1093
|
+
return {
|
|
1094
|
+
finalized: false,
|
|
1095
|
+
reason: 'stale_dispatch',
|
|
1096
|
+
currentDispatchId: metadata.dispatch_id || null
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const leaseKey = metadata.lease_key || getLeaseKey(bookId, chapterId, sceneId, stage);
|
|
1101
|
+
const leaseToken = metadata.lease_token || null;
|
|
1102
|
+
if (metadata.quota_owned === true && !leaseToken) {
|
|
1103
|
+
return { finalized: false, reason: 'ownership_metadata_incomplete' };
|
|
1104
|
+
}
|
|
1105
|
+
const currentLeaseToken = await redis.get(leaseKey);
|
|
1106
|
+
if (currentLeaseToken && (!leaseToken || currentLeaseToken !== leaseToken)) {
|
|
1107
|
+
return { finalized: false, reason: 'lease_token_mismatch' };
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// ── 1. Atomically claim finalization and release Redis-owned resources ──
|
|
1111
|
+
const markerValue = JSON.stringify({
|
|
1112
|
+
outcome,
|
|
1113
|
+
dispatch_id: dispatchId,
|
|
1114
|
+
finalized_at: Date.now()
|
|
1115
|
+
});
|
|
1116
|
+
const claimResult = Number(await claimFinalization(
|
|
1117
|
+
redis,
|
|
1118
|
+
metadataKey,
|
|
1119
|
+
completedKey,
|
|
1120
|
+
leaseKey,
|
|
1121
|
+
getActiveCounterKey(stage),
|
|
1122
|
+
metadataRaw,
|
|
1123
|
+
markerValue,
|
|
1124
|
+
_leaseTtl(stage),
|
|
1125
|
+
leaseToken,
|
|
1126
|
+
metadata.quota_owned === true
|
|
1127
|
+
));
|
|
1128
|
+
if (claimResult === 2) {
|
|
1129
|
+
return { finalized: false, reason: 'already_finalized' };
|
|
1130
|
+
}
|
|
1131
|
+
if (claimResult === 0) {
|
|
1132
|
+
return { finalized: false, reason: 'no_active_dispatch' };
|
|
1133
|
+
}
|
|
1134
|
+
if (claimResult === -1) {
|
|
1135
|
+
return { finalized: false, reason: 'dispatch_metadata_changed' };
|
|
1136
|
+
}
|
|
1137
|
+
if (claimResult === -2) {
|
|
1138
|
+
return { finalized: false, reason: 'lease_token_mismatch' };
|
|
1139
|
+
}
|
|
1140
|
+
if (claimResult !== 1) {
|
|
1141
|
+
throw new Error(`finalizeDispatch: unexpected claim result '${claimResult}'`);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
log(`FINALIZE_${outcome.toUpperCase()}: ${bookId}/${chapterId}/${sceneId}:${stage}${reason ? ' (' + reason + ')' : ''}`);
|
|
1145
|
+
|
|
1146
|
+
// ── 2. Stop renewal ──
|
|
1147
|
+
stopDispatchRenewal(bookId, chapterId, sceneId, stage);
|
|
1148
|
+
|
|
1149
|
+
const cleanupErrors = [];
|
|
1150
|
+
|
|
1151
|
+
// ── 2.5. Release iu-in-flight markers owned by this dispatch ──
|
|
1152
|
+
// Только для cancelled/failure и только маркеры НЕотправленных jobs
|
|
1153
|
+
// (отправленный job снимается с индекса сразу после gpu.send, его маркер
|
|
1154
|
+
// трогать нельзя — job реально выполняется). Для success очистку делает
|
|
1155
|
+
// completion callback по всему scene-prefix. Ошибки не глотаем — в
|
|
1156
|
+
// cleanupErrors и в лог.
|
|
1157
|
+
if (outcome === 'cancelled' || outcome === 'failure') {
|
|
1158
|
+
try {
|
|
1159
|
+
const markerCleanup = await releaseDispatchInFlightMarkers(redis, dispatchId);
|
|
1160
|
+
if (markerCleanup.removed > 0 || markerCleanup.kept > 0) {
|
|
1161
|
+
log(`IU_IN_FLIGHT_CLEANUP: ${bookId}/${chapterId}/${sceneId}:${stage} dispatch=${dispatchId.slice(0, 24)}... removed=${markerCleanup.removed} kept_running=${markerCleanup.kept}`);
|
|
1162
|
+
}
|
|
1163
|
+
if (markerCleanup.errors.length > 0) {
|
|
1164
|
+
cleanupErrors.push(...markerCleanup.errors.map(e => `iu_in_flight:${e}`));
|
|
1165
|
+
warn(`IU_IN_FLIGHT_CLEANUP_ERRORS: ${bookId}/${chapterId}/${sceneId}:${stage} dispatch=${dispatchId.slice(0, 24)}...: ${markerCleanup.errors.join('; ')}`);
|
|
1166
|
+
}
|
|
1167
|
+
} catch (cleanupErr) {
|
|
1168
|
+
cleanupErrors.push(`iu_in_flight:${cleanupErr.message}`);
|
|
1169
|
+
warn(`IU_IN_FLIGHT_CLEANUP_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage} dispatch=${dispatchId.slice(0, 24)}...: ${cleanupErr.message}`);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// ── 3-4. Circuit breaker + retry budget согласно outcome ──
|
|
1174
|
+
if (outcome === 'success') {
|
|
1175
|
+
try {
|
|
1176
|
+
const circuitResult = await circuitBreaker.recordSuccess(redis, stage);
|
|
1177
|
+
if (circuitResult.healed) {
|
|
1178
|
+
log(`CIRCUIT_HALF_OPEN_TEST_SUCCEEDED: ${stage} — circuit closed`);
|
|
1179
|
+
}
|
|
1180
|
+
} catch (circuitErr) {
|
|
1181
|
+
cleanupErrors.push(`circuit:${circuitErr.message}`);
|
|
1182
|
+
warn(`recordSuccess failed: ${circuitErr.message}`);
|
|
1183
|
+
}
|
|
1184
|
+
// Success не расходует retry budget
|
|
1185
|
+
} else if (outcome === 'failure') {
|
|
1186
|
+
try {
|
|
1187
|
+
await circuitBreaker.recordFailure(redis, stage);
|
|
1188
|
+
} catch (circuitErr) {
|
|
1189
|
+
cleanupErrors.push(`circuit:${circuitErr.message}`);
|
|
1190
|
+
warn(`recordFailure failed: ${circuitErr.message}`);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// T2.8: Расходуем retry budget ровно один раз на принятый failure
|
|
1194
|
+
try {
|
|
1195
|
+
await retryBudget.consumeRetryBudget(redis, bookId, chapterId, sceneId, stage, failureType || 'transient', workerId);
|
|
1196
|
+
log(`RETRY_BUDGET_CONSUMED: ${bookId}/${chapterId}/${sceneId}:${stage} (type=${failureType || 'transient'})`);
|
|
1197
|
+
} catch (budgetErr) {
|
|
1198
|
+
cleanupErrors.push(`retry_budget:${budgetErr.message}`);
|
|
1199
|
+
warn(`consumeRetryBudget failed: ${budgetErr.message}`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
// 'cancelled': не трогаем circuit breaker, не расходуем retry budget
|
|
1203
|
+
|
|
1204
|
+
// ── 5. Journal event ──
|
|
1205
|
+
const eventSuffix = outcome === 'success' ? 'COMPLETED' : outcome === 'failure' ? 'FAILED' : 'CANCELLED';
|
|
1206
|
+
try {
|
|
1207
|
+
await logDispatchEvent(redis, bookId, chapterId, sceneId, eventSuffix, stage, {
|
|
1208
|
+
outcome,
|
|
1209
|
+
reason: reason || null,
|
|
1210
|
+
failureType: failureType || null,
|
|
1211
|
+
workerId: workerId || null,
|
|
1212
|
+
dispatchId,
|
|
1213
|
+
cleanupErrors
|
|
1214
|
+
});
|
|
1215
|
+
} catch (journalErr) {
|
|
1216
|
+
cleanupErrors.push(`journal:${journalErr.message}`);
|
|
1217
|
+
warn(`finalization journal write failed: ${journalErr.message}`);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// ── 6. Phase 5: report the finalization through the Runtime Result seam ──
|
|
1221
|
+
// The emitter maps the dispatch-internal outcome (success|failure|cancelled)
|
|
1222
|
+
// onto the contract status (completed|failed|cancelled); the outcome
|
|
1223
|
+
// vocabulary stays inside runtime. Emission is best-effort and never throws.
|
|
1224
|
+
try {
|
|
1225
|
+
await runtimeResultEmitter.emitRuntimeResult({
|
|
1226
|
+
bookId,
|
|
1227
|
+
chapterId,
|
|
1228
|
+
sceneId,
|
|
1229
|
+
stage,
|
|
1230
|
+
dispatchId,
|
|
1231
|
+
outcome,
|
|
1232
|
+
error: reason || null,
|
|
1233
|
+
metadata: {
|
|
1234
|
+
outcome,
|
|
1235
|
+
finalizedAt: Date.now(),
|
|
1236
|
+
cleanupErrors,
|
|
1237
|
+
},
|
|
1238
|
+
});
|
|
1239
|
+
} catch (emitErr) {
|
|
1240
|
+
warn(`runtime result emit failed: ${emitErr.message}`);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
return { finalized: true, reason: outcome, cleanupErrors };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* T2: markDispatchCompleted оставлен как обратно-совместимая обёртка
|
|
1248
|
+
* для callers, которые пока не переведены на finalizeDispatch.
|
|
1249
|
+
* Все новые вызовы должны использовать finalizeDispatch.
|
|
1250
|
+
*/
|
|
1251
|
+
async function markDispatchCompleted(redis, bookId, chapterId, sceneId, stage) {
|
|
1252
|
+
const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
|
|
1253
|
+
return finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
|
|
1254
|
+
outcome: 'success',
|
|
1255
|
+
dispatchId: metadata?.dispatch_id
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* T2: markDispatchFailed — обёртка над finalizeDispatch('failure').
|
|
1261
|
+
*/
|
|
1262
|
+
async function markDispatchFailed(redis, bookId, chapterId, sceneId, stage, error) {
|
|
1263
|
+
const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
|
|
1264
|
+
return finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
|
|
1265
|
+
outcome: 'failure',
|
|
1266
|
+
reason: error,
|
|
1267
|
+
dispatchId: metadata?.dispatch_id
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// ======================================================
|
|
1272
|
+
// T5: Cancellation + quota-safe lease clearing
|
|
1273
|
+
// ======================================================
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Cancel an active dispatch: stop renewal, release lease+quota, save final record, log event.
|
|
1277
|
+
* Используется force mode и clearLeasesForScenes.
|
|
1278
|
+
* T6: Останавливает in-memory renewal timer, чтобы избежать утечки.
|
|
1279
|
+
*/
|
|
1280
|
+
async function cancelActiveDispatch(redis, bookId, chapterId, sceneId, stage, reason = 'cancelled') {
|
|
1281
|
+
const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
|
|
1282
|
+
if (metadata?.dispatch_id) {
|
|
1283
|
+
const result = await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
|
|
1284
|
+
outcome: 'cancelled',
|
|
1285
|
+
dispatchId: metadata.dispatch_id,
|
|
1286
|
+
reason
|
|
1287
|
+
});
|
|
1288
|
+
return {
|
|
1289
|
+
cancelled: result.finalized,
|
|
1290
|
+
hadLease: !!metadata.lease_token,
|
|
1291
|
+
quotaReleased: result.finalized && metadata.quota_owned === true,
|
|
1292
|
+
dispatchId: metadata.dispatch_id,
|
|
1293
|
+
reason: result.reason
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const { leaseKey, token } = await getLeaseData(redis, bookId, chapterId, sceneId, stage);
|
|
1298
|
+
stopDispatchRenewal(bookId, chapterId, sceneId, stage);
|
|
1299
|
+
if (token) {
|
|
1300
|
+
await releaseStageLease(redis, leaseKey, token);
|
|
1301
|
+
warn(`CANCEL_ORPHAN_LEASE: ${bookId}/${chapterId}/${sceneId}:${stage} — quota ownership unknown; counter reconciliation required`);
|
|
1302
|
+
return {
|
|
1303
|
+
cancelled: true,
|
|
1304
|
+
hadLease: true,
|
|
1305
|
+
quotaReleased: false,
|
|
1306
|
+
dispatchId: null,
|
|
1307
|
+
reason: 'orphan_lease'
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
return {
|
|
1311
|
+
cancelled: false,
|
|
1312
|
+
hadLease: false,
|
|
1313
|
+
quotaReleased: false,
|
|
1314
|
+
dispatchId: null,
|
|
1315
|
+
reason: 'no_active_dispatch'
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Clear leases for specific scenes — T5: корректно освобождает quota
|
|
1321
|
+
* для каждого существующего dispatch. Не удаляет quota без ownership.
|
|
1322
|
+
*
|
|
1323
|
+
* @param {Array<{chapter_id:string,scene_id:string,stages?:string[]}>} scenes - scenes to clear
|
|
1324
|
+
* Each scene may restrict cancellation to specific stages. When omitted, all
|
|
1325
|
+
* stages are cleared for backward compatibility.
|
|
1326
|
+
*/
|
|
1327
|
+
async function clearLeasesForScenes(redis, bookId, scenes) {
|
|
1328
|
+
if (!scenes || scenes.length === 0) {
|
|
1329
|
+
return { cancelled: 0, quotaReleased: 0, dispatchIds: [] };
|
|
1330
|
+
}
|
|
1331
|
+
const allStages = _allStages();
|
|
1332
|
+
let cancelled = 0;
|
|
1333
|
+
let quotaReleased = 0;
|
|
1334
|
+
const dispatchIds = [];
|
|
1335
|
+
|
|
1336
|
+
for (const s of scenes) {
|
|
1337
|
+
const stages = Array.isArray(s.stages)
|
|
1338
|
+
? s.stages.filter(stage => allStages.includes(stage))
|
|
1339
|
+
: allStages;
|
|
1340
|
+
for (const stage of stages) {
|
|
1341
|
+
const result = await cancelActiveDispatch(
|
|
1342
|
+
redis,
|
|
1343
|
+
bookId,
|
|
1344
|
+
s.chapter_id,
|
|
1345
|
+
s.scene_id,
|
|
1346
|
+
stage,
|
|
1347
|
+
'scene_reset'
|
|
1348
|
+
);
|
|
1349
|
+
if (result.cancelled) {
|
|
1350
|
+
cancelled++;
|
|
1351
|
+
}
|
|
1352
|
+
if (result.quotaReleased) {
|
|
1353
|
+
quotaReleased++;
|
|
1354
|
+
}
|
|
1355
|
+
if (result.dispatchId) {
|
|
1356
|
+
dispatchIds.push(result.dispatchId);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (cancelled > 0) {
|
|
1362
|
+
log(`CLEAR_SCENE_LEASES: ${bookId} — ${cancelled} stages cancelled, ${quotaReleased} quota slots released for ${scenes.length} scene(s)`);
|
|
1363
|
+
}
|
|
1364
|
+
return { cancelled, quotaReleased, dispatchIds };
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Remove specific cancelled dispatches from GPU Hub.
|
|
1369
|
+
* Kept here with dispatch cancellation so every caller uses the same auth and
|
|
1370
|
+
* error-handling contract.
|
|
1371
|
+
*/
|
|
1372
|
+
async function clearHubDispatches(dispatchIds, options = {}) {
|
|
1373
|
+
const ids = [...new Set((dispatchIds || []).filter(Boolean))];
|
|
1374
|
+
const hubUrl = options.hubUrl || runtimeConfig.HUB_URL;
|
|
1375
|
+
const apiKey = options.apiKey !== undefined
|
|
1376
|
+
? options.apiKey
|
|
1377
|
+
: runtimeConfig.GPU_HUB_API_KEY;
|
|
1378
|
+
const context = options.context || 'DISPATCH';
|
|
1379
|
+
const reportWarning = typeof options.warn === 'function' ? options.warn : warn;
|
|
1380
|
+
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
1381
|
+
let cleared = 0;
|
|
1382
|
+
let failed = 0;
|
|
1383
|
+
|
|
1384
|
+
for (const dispatchId of ids) {
|
|
1385
|
+
try {
|
|
1386
|
+
const headers = {};
|
|
1387
|
+
if (apiKey) headers['x-api-key'] = apiKey;
|
|
1388
|
+
const response = await fetchImpl(
|
|
1389
|
+
`${hubUrl}/queue/clear?dispatch_id=${encodeURIComponent(dispatchId)}`,
|
|
1390
|
+
{ method: 'DELETE', headers }
|
|
1391
|
+
);
|
|
1392
|
+
if (!response.ok) {
|
|
1393
|
+
failed++;
|
|
1394
|
+
reportWarning(`${context}: GPU hub cleanup returned ${response.status} for ${dispatchId}`);
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
cleared++;
|
|
1398
|
+
} catch (err) {
|
|
1399
|
+
failed++;
|
|
1400
|
+
reportWarning(`${context}: GPU hub cleanup failed for ${dispatchId}: ${err.message}`);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
return { requested: ids.length, cleared, failed };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Clear all dispatch leases and metadata for a specific stage within a book.
|
|
1409
|
+
* T5: quota-safe — uses cancelActiveDispatch for each lease.
|
|
1410
|
+
* Non-cancelled stages keep their leases and quotas intact.
|
|
1411
|
+
*/
|
|
1412
|
+
async function clearLeasesForBookByStage(redis, bookId, stage) {
|
|
1413
|
+
let deleted = 0;
|
|
1414
|
+
let quotaReleased = 0;
|
|
1415
|
+
const dispatchIds = [];
|
|
1416
|
+
let cursor = 0;
|
|
1417
|
+
|
|
1418
|
+
const leasePattern = `${DISPATCH_LEASE_PREFIX}:${bookId}:*:${stage}`;
|
|
1419
|
+
do {
|
|
1420
|
+
const result = await redis.scan(cursor, 'MATCH', leasePattern, 'COUNT', 200);
|
|
1421
|
+
cursor = parseInt(result[0], 10);
|
|
1422
|
+
const keys = result[1];
|
|
1423
|
+
|
|
1424
|
+
for (const key of keys) {
|
|
1425
|
+
const parts = key.split(':');
|
|
1426
|
+
if (parts.length >= 6) {
|
|
1427
|
+
const chapterId = parts[3];
|
|
1428
|
+
const sceneId = parts[4];
|
|
1429
|
+
stopDispatchRenewal(bookId, chapterId, sceneId, stage);
|
|
1430
|
+
|
|
1431
|
+
const token = await redis.get(key);
|
|
1432
|
+
if (token) {
|
|
1433
|
+
const result = await cancelActiveDispatch(
|
|
1434
|
+
redis,
|
|
1435
|
+
bookId,
|
|
1436
|
+
chapterId,
|
|
1437
|
+
sceneId,
|
|
1438
|
+
stage,
|
|
1439
|
+
'stage_reset'
|
|
1440
|
+
);
|
|
1441
|
+
if (result.cancelled && result.reason !== 'orphan_lease') {
|
|
1442
|
+
quotaReleased++;
|
|
1443
|
+
}
|
|
1444
|
+
if (result.dispatchId) dispatchIds.push(result.dispatchId);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
await redis.del(key).catch(() => {});
|
|
1448
|
+
deleted++;
|
|
1449
|
+
}
|
|
1450
|
+
} while (cursor !== 0);
|
|
1451
|
+
|
|
1452
|
+
// Clean up orphan metadata for this stage
|
|
1453
|
+
cursor = 0;
|
|
1454
|
+
const metaPattern = `${DISPATCH_META_PREFIX}:${bookId}:*:${stage}`;
|
|
1455
|
+
do {
|
|
1456
|
+
const result = await redis.scan(cursor, 'MATCH', metaPattern, 'COUNT', 200);
|
|
1457
|
+
cursor = parseInt(result[0], 10);
|
|
1458
|
+
const keys = result[1];
|
|
1459
|
+
if (keys.length > 0) {
|
|
1460
|
+
await redis.del(...keys);
|
|
1461
|
+
deleted += keys.length;
|
|
1462
|
+
}
|
|
1463
|
+
} while (cursor !== 0);
|
|
1464
|
+
|
|
1465
|
+
if (deleted > 0 || quotaReleased > 0) {
|
|
1466
|
+
log(`CLEAR_STAGE_LEASES: ${bookId}/${stage} — ${deleted} keys deleted, ${quotaReleased} quota slots released`);
|
|
1467
|
+
}
|
|
1468
|
+
return { deleted, quotaReleased, dispatchIds };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Clear all dispatch leases and metadata for a book — T5: quota-safe.
|
|
1473
|
+
*/
|
|
1474
|
+
async function clearAllLeasesForBook(redis, bookId) {
|
|
1475
|
+
let deleted = 0;
|
|
1476
|
+
let quotaReleased = 0;
|
|
1477
|
+
const dispatchIds = [];
|
|
1478
|
+
let cursor = 0;
|
|
1479
|
+
|
|
1480
|
+
// Scan all dispatch leases for this book
|
|
1481
|
+
const leasePattern = `${DISPATCH_LEASE_PREFIX}:${bookId}:*`;
|
|
1482
|
+
do {
|
|
1483
|
+
const result = await redis.scan(cursor, 'MATCH', leasePattern, 'COUNT', 200);
|
|
1484
|
+
cursor = parseInt(result[0], 10);
|
|
1485
|
+
const keys = result[1];
|
|
1486
|
+
|
|
1487
|
+
for (const key of keys) {
|
|
1488
|
+
// Parse: animastor:dispatch-lease:bookId:chapterId:sceneId:stage
|
|
1489
|
+
const parts = key.split(':');
|
|
1490
|
+
if (parts.length >= 6) {
|
|
1491
|
+
const chapterId = parts[3];
|
|
1492
|
+
const sceneId = parts[4];
|
|
1493
|
+
const stage = parts[5];
|
|
1494
|
+
|
|
1495
|
+
// T6: Останавливаем renewal timer перед удалением lease
|
|
1496
|
+
stopDispatchRenewal(bookId, chapterId, sceneId, stage);
|
|
1497
|
+
|
|
1498
|
+
const token = await redis.get(key);
|
|
1499
|
+
if (token) {
|
|
1500
|
+
const result = await cancelActiveDispatch(
|
|
1501
|
+
redis,
|
|
1502
|
+
bookId,
|
|
1503
|
+
chapterId,
|
|
1504
|
+
sceneId,
|
|
1505
|
+
stage,
|
|
1506
|
+
'book_reset'
|
|
1507
|
+
);
|
|
1508
|
+
if (result.cancelled && result.reason !== 'orphan_lease') {
|
|
1509
|
+
quotaReleased++;
|
|
1510
|
+
}
|
|
1511
|
+
if (result.dispatchId) dispatchIds.push(result.dispatchId);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
await redis.del(key).catch(() => {});
|
|
1515
|
+
deleted++;
|
|
1516
|
+
}
|
|
1517
|
+
} while (cursor !== 0);
|
|
1518
|
+
|
|
1519
|
+
// Delete orphan metadata. Per-dispatch completion markers are retained until TTL
|
|
1520
|
+
// so late callbacks remain recognizable as finalized/stale.
|
|
1521
|
+
for (const prefix of [DISPATCH_META_PREFIX]) {
|
|
1522
|
+
cursor = 0;
|
|
1523
|
+
const pattern = `${prefix}:${bookId}:*`;
|
|
1524
|
+
do {
|
|
1525
|
+
const result = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
|
|
1526
|
+
cursor = parseInt(result[0], 10);
|
|
1527
|
+
const keys = result[1];
|
|
1528
|
+
if (keys.length > 0) {
|
|
1529
|
+
await redis.del(...keys);
|
|
1530
|
+
deleted += keys.length;
|
|
1531
|
+
}
|
|
1532
|
+
} while (cursor !== 0);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
if (deleted > 0 || quotaReleased > 0) {
|
|
1536
|
+
log(`CLEAR_ALL_LEASES: ${bookId} — ${deleted} keys deleted, ${quotaReleased} quota slots released`);
|
|
1537
|
+
}
|
|
1538
|
+
return { deleted, quotaReleased, dispatchIds };
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// ======================================================
|
|
1542
|
+
// METRICS
|
|
1543
|
+
// ======================================================
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Get current dispatch metrics.
|
|
1547
|
+
*/
|
|
1548
|
+
async function getMetrics(redis) {
|
|
1549
|
+
const stages = _allStages();
|
|
1550
|
+
const activeCounts = await Promise.all(stages.map(s => getActiveCounter(redis, s)));
|
|
1551
|
+
const activeMap = Object.fromEntries(stages.map((s, i) => [s, activeCounts[i]]));
|
|
1552
|
+
|
|
1553
|
+
return {
|
|
1554
|
+
quotas: Object.fromEntries(_allStages().map(s => [s, { max: _maxActive(s) }])),
|
|
1555
|
+
active: activeMap,
|
|
1556
|
+
schedulerTickRunning: await isSchedulerTickRunning(redis)
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* Get all active dispatches (leases with metadata).
|
|
1562
|
+
*/
|
|
1563
|
+
async function getActiveDispatches(redis, limit = 100) {
|
|
1564
|
+
const activeDispatches = [];
|
|
1565
|
+
|
|
1566
|
+
// Scan for all dispatch leases
|
|
1567
|
+
const pattern = `${DISPATCH_LEASE_PREFIX}:*`;
|
|
1568
|
+
let cursor = 0;
|
|
1569
|
+
let count = 0;
|
|
1570
|
+
|
|
1571
|
+
do {
|
|
1572
|
+
const result = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
1573
|
+
cursor = parseInt(result[0], 10);
|
|
1574
|
+
const keys = result[1];
|
|
1575
|
+
|
|
1576
|
+
for (const key of keys) {
|
|
1577
|
+
if (count >= limit) break;
|
|
1578
|
+
|
|
1579
|
+
// Parse: animastor:dispatch-lease:bookId:chapterId:sceneId:stage
|
|
1580
|
+
const parts = key.split(':');
|
|
1581
|
+
if (parts.length >= 6) {
|
|
1582
|
+
const bookId = parts[2];
|
|
1583
|
+
const chapterId = parts[3];
|
|
1584
|
+
const sceneId = parts[4];
|
|
1585
|
+
const stage = parts[5];
|
|
1586
|
+
|
|
1587
|
+
const token = await redis.get(key);
|
|
1588
|
+
const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
|
|
1589
|
+
|
|
1590
|
+
activeDispatches.push({
|
|
1591
|
+
key,
|
|
1592
|
+
scene: { bookId, chapterId, sceneId },
|
|
1593
|
+
stage,
|
|
1594
|
+
token: token || null,
|
|
1595
|
+
metadata
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
count++;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
} while (cursor !== 0 && count < limit);
|
|
1602
|
+
|
|
1603
|
+
return activeDispatches;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/**
|
|
1607
|
+
* Get all active leases (for debug).
|
|
1608
|
+
*/
|
|
1609
|
+
async function getActiveLeases(redis) {
|
|
1610
|
+
const pattern = `${DISPATCH_LEASE_PREFIX}:*`;
|
|
1611
|
+
let cursor = 0;
|
|
1612
|
+
const leases = [];
|
|
1613
|
+
|
|
1614
|
+
do {
|
|
1615
|
+
const result = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
1616
|
+
cursor = parseInt(result[0], 10);
|
|
1617
|
+
const keys = result[1];
|
|
1618
|
+
|
|
1619
|
+
for (const key of keys) {
|
|
1620
|
+
const token = await redis.get(key);
|
|
1621
|
+
const parts = key.split(':');
|
|
1622
|
+
const leaseData = {
|
|
1623
|
+
key,
|
|
1624
|
+
scene: parts.length >= 6 ? {
|
|
1625
|
+
bookId: parts[2],
|
|
1626
|
+
chapterId: parts[3],
|
|
1627
|
+
sceneId: parts[4],
|
|
1628
|
+
stage: parts[5]
|
|
1629
|
+
} : null,
|
|
1630
|
+
token: token || null
|
|
1631
|
+
};
|
|
1632
|
+
leases.push(leaseData);
|
|
1633
|
+
}
|
|
1634
|
+
} while (cursor !== 0);
|
|
1635
|
+
|
|
1636
|
+
return leases;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/**
|
|
1640
|
+
* Get current quota status.
|
|
1641
|
+
*/
|
|
1642
|
+
async function getQuotaStatus(redis) {
|
|
1643
|
+
const stages = _allStages();
|
|
1644
|
+
const counts = await Promise.all(stages.map(s => getActiveCounter(redis, s)));
|
|
1645
|
+
const countMap = Object.fromEntries(stages.map((s, i) => [s, counts[i]]));
|
|
1646
|
+
|
|
1647
|
+
const result = {};
|
|
1648
|
+
for (const s of stages) {
|
|
1649
|
+
const max = _maxActive(s);
|
|
1650
|
+
result[s] = { current: countMap[s], max, available: max - countMap[s] };
|
|
1651
|
+
}
|
|
1652
|
+
return result;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// ======================================================
|
|
1656
|
+
// LEASE RENEWAL TIMER MANAGEMENT
|
|
1657
|
+
// ======================================================
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* Start lease renewal timer for a dispatch.
|
|
1661
|
+
* Called after successful dispatch.
|
|
1662
|
+
*/
|
|
1663
|
+
function startDispatchRenewal(redis, bookId, chapterId, sceneId, stage, leaseKey, token) {
|
|
1664
|
+
return leaseManager.startLeaseRenewal(redis, bookId, chapterId, sceneId, stage, leaseKey, token);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* Stop lease renewal timer for a dispatch.
|
|
1669
|
+
* Called on completion/failure/recovery.
|
|
1670
|
+
*/
|
|
1671
|
+
function stopDispatchRenewal(bookId, chapterId, sceneId, stage) {
|
|
1672
|
+
return leaseManager.stopLeaseRenewal(bookId, chapterId, sceneId, stage);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Get metrics for runtime.
|
|
1677
|
+
*/
|
|
1678
|
+
async function getRuntimeMetrics(redis) {
|
|
1679
|
+
return await runtimeMetrics.getMetricsReport(redis);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// ======================================================
|
|
1683
|
+
// EXPORTS
|
|
1684
|
+
// ======================================================
|
|
1685
|
+
|
|
1686
|
+
module.exports = {
|
|
1687
|
+
// Keys and patterns
|
|
1688
|
+
getLeaseKey,
|
|
1689
|
+
getDispatchMetaKey,
|
|
1690
|
+
getDispatchCompletedKey,
|
|
1691
|
+
getActiveCounterKey,
|
|
1692
|
+
SCHEDULER_TICK_LOCK,
|
|
1693
|
+
SCHEDULER_TICK_LOCK_TTL,
|
|
1694
|
+
|
|
1695
|
+
// Lease management
|
|
1696
|
+
acquireStageLease,
|
|
1697
|
+
releaseStageLease,
|
|
1698
|
+
isLeaseValid,
|
|
1699
|
+
getLeaseData,
|
|
1700
|
+
|
|
1701
|
+
// Dispatch metadata
|
|
1702
|
+
createDispatchMetadata,
|
|
1703
|
+
setDispatchMetadata,
|
|
1704
|
+
getDispatchMetadata,
|
|
1705
|
+
verifyDispatchIdentity,
|
|
1706
|
+
deleteDispatchMetadata,
|
|
1707
|
+
|
|
1708
|
+
// IU in-flight marker ownership (image stage)
|
|
1709
|
+
getInFlightIndexKey,
|
|
1710
|
+
registerInFlightMarker,
|
|
1711
|
+
unregisterInFlightMarker,
|
|
1712
|
+
releaseDispatchInFlightMarkers,
|
|
1713
|
+
compareAndDeleteMarker,
|
|
1714
|
+
IU_IN_FLIGHT_INDEX_PREFIX,
|
|
1715
|
+
get IU_IN_FLIGHT_MARKER_TTL_S() { return IU_IN_FLIGHT_MARKER_TTL_S(); },
|
|
1716
|
+
|
|
1717
|
+
// Orphan GENERATING detection & repair (audit d9d67a3)
|
|
1718
|
+
getDispatchEvidence,
|
|
1719
|
+
repairOrphanGeneratingStates,
|
|
1720
|
+
|
|
1721
|
+
// Active counters (backpressure)
|
|
1722
|
+
incrementActiveCounter,
|
|
1723
|
+
decrementActiveCounter,
|
|
1724
|
+
getActiveCounter,
|
|
1725
|
+
checkQuota,
|
|
1726
|
+
acquireQuota,
|
|
1727
|
+
releaseQuota,
|
|
1728
|
+
|
|
1729
|
+
// Scheduler tick lock
|
|
1730
|
+
acquireSchedulerTickLock,
|
|
1731
|
+
releaseSchedulerTickLock,
|
|
1732
|
+
isSchedulerTickRunning,
|
|
1733
|
+
|
|
1734
|
+
// Dispatch control
|
|
1735
|
+
dispatchStage,
|
|
1736
|
+
shouldSkipDispatch,
|
|
1737
|
+
finalizeDispatch,
|
|
1738
|
+
cancelActiveDispatch,
|
|
1739
|
+
markDispatchCompleted,
|
|
1740
|
+
markDispatchFailed,
|
|
1741
|
+
|
|
1742
|
+
// Renewal management
|
|
1743
|
+
startDispatchRenewal,
|
|
1744
|
+
stopDispatchRenewal,
|
|
1745
|
+
|
|
1746
|
+
// Recovery
|
|
1747
|
+
clearAllLeasesForBook,
|
|
1748
|
+
clearLeasesForBookByStage,
|
|
1749
|
+
clearLeasesForScenes,
|
|
1750
|
+
clearHubDispatches,
|
|
1751
|
+
|
|
1752
|
+
// Runtime metrics
|
|
1753
|
+
getRuntimeMetrics,
|
|
1754
|
+
|
|
1755
|
+
// Metrics and debug
|
|
1756
|
+
getMetrics,
|
|
1757
|
+
getActiveDispatches,
|
|
1758
|
+
getActiveLeases,
|
|
1759
|
+
getQuotaStatus,
|
|
1760
|
+
|
|
1761
|
+
// Constants — S-2: lazy views over the media registry (single source of
|
|
1762
|
+
// truth); exported for backward compatibility with tests/metrics.
|
|
1763
|
+
LEASE_TTLS,
|
|
1764
|
+
QUOTAS,
|
|
1765
|
+
|
|
1766
|
+
// PW-2: dispatch identity generation (crypto-hardened)
|
|
1767
|
+
generateDispatchToken
|
|
1768
|
+
};
|