@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,607 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// LEASE MANAGER - RENEWABLE LEASES
|
|
3
|
+
// ======================================================
|
|
4
|
+
// Manages refreshable leases for long-running dispatches.
|
|
5
|
+
// Implements:
|
|
6
|
+
// - Renewable leases with periodic TTL extension
|
|
7
|
+
// - Lease heartbeat timer management
|
|
8
|
+
// - Safe Lua-based release operations
|
|
9
|
+
// - Ownership validation
|
|
10
|
+
|
|
11
|
+
const logPrefix = '[LEASE]';
|
|
12
|
+
|
|
13
|
+
function log(msg) {
|
|
14
|
+
console.log(`${logPrefix} ${msg}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function warn(msg) {
|
|
18
|
+
console.warn(`${logPrefix} ⚠️ ${msg}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function error(msg) {
|
|
22
|
+
console.error(`${logPrefix} ❌ ${msg}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ======================================================
|
|
26
|
+
// CONFIGURATION
|
|
27
|
+
// ======================================================
|
|
28
|
+
|
|
29
|
+
const RENEWAL_INTERVAL_MS = 30000; // 30 seconds
|
|
30
|
+
const RESTART_DELAY_MS = 5000; // 5 seconds before first renewal
|
|
31
|
+
// НЕ "прибавка к текущему TTL". Renewal ПЕРЕСТАВЛЯЕТ (re-pin) TTL lease на
|
|
32
|
+
// фиксированную цель LEASE_TOTAL_TTLS[stage] + LEASE_RENEWAL_TTL_ADD (Lua
|
|
33
|
+
// EXPIRE key, target), а не добавляет 180s к остатку. Поэтому TTL ограничен
|
|
34
|
+
// сверху (нет "вечного" lease) и не растёт с каждым renewal. 180s — буфер
|
|
35
|
+
// сверх базового TTL, чтобы lease не истёк между двумя соседними renewal.
|
|
36
|
+
const LEASE_RENEWAL_TTL_ADD = 180;
|
|
37
|
+
|
|
38
|
+
// Lease TTL extensions (total TTL after first acquire)
|
|
39
|
+
// ЕДИНЫЙ источник — LEASE_TTL_S в config/runtime-config.js (тот же реестр,
|
|
40
|
+
// что dispatch-engine использует при первоначальном acquire). Renewal
|
|
41
|
+
// переставляет lease на LEASE_TOTAL_TTLS[stage] + LEASE_RENEWAL_TTL_ADD.
|
|
42
|
+
// S-2: lease TTL resolved through the media registry (values still owned by
|
|
43
|
+
// runtime-config — registry reads them at registration time). LEASE_TOTAL_TTLS
|
|
44
|
+
// stays exported as a lazy view for backward compatibility (tests assert the
|
|
45
|
+
// runtime-config unification through it).
|
|
46
|
+
const mediaRegistry = require('@animastor/generation').mediaRegistry;
|
|
47
|
+
const LEASE_TOTAL_TTLS = new Proxy({}, {
|
|
48
|
+
get(_, stage) { return mediaRegistry.resolveLeaseTtl(stage); },
|
|
49
|
+
ownKeys() { return mediaRegistry.listMediaTypes(); },
|
|
50
|
+
getOwnPropertyDescriptor(_, stage) {
|
|
51
|
+
const v = mediaRegistry.resolveLeaseTtl(stage);
|
|
52
|
+
return v === undefined ? undefined : { value: v, enumerable: true, configurable: true };
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ======================================================
|
|
57
|
+
// STALE-LEASE SEMANTICS (audit c8b79f6, incident 2026-08-28)
|
|
58
|
+
// ======================================================
|
|
59
|
+
// Canonical liveness signal of a dispatch is the REMAINING TTL of its lease
|
|
60
|
+
// key, NOT metadata.started_at. renewLeaseIfOwner re-pins the TTL to
|
|
61
|
+
// getRenewalTargetTtlS(stage) every RENEWAL_INTERVAL_MS while the owner is
|
|
62
|
+
// alive; started_at is written once at acquire and never refreshed, so any
|
|
63
|
+
// age check based on it eventually flags a healthy renewed dispatch as stale
|
|
64
|
+
// (video incident: 0.9×TTL threshold = 27 min < real 60-min job → ~86
|
|
65
|
+
// duplicate dispatches in 14 h).
|
|
66
|
+
//
|
|
67
|
+
// ФОРМУЛА (единая для checkStaleDispatchLeases и shouldSkipDispatch):
|
|
68
|
+
// lease stale ⇔ remainingTTL < getRenewalTargetTtlS(stage) - STALE_LEASE_GRACE_S
|
|
69
|
+
// где getRenewalTargetTtlS(stage) = LEASE_TOTAL_TTLS[stage] + LEASE_RENEWAL_TTL_ADD.
|
|
70
|
+
//
|
|
71
|
+
// Что это означает на практике (6 случаев):
|
|
72
|
+
// 1. HEALTHY при нормальном renewal: renewal каждые 30s переставляет TTL на
|
|
73
|
+
// target, поэтому остаток всегда в [target-30, target] и НИКОГДА не
|
|
74
|
+
// опускается ниже target-grace → lease не stale сколь угодно долго
|
|
75
|
+
// (даже 60-минутное видео). started_at не влияет.
|
|
76
|
+
// 2. ПОСЛЕ ПОТЕРИ renewal (владелец умер ПОСЛЕ хотя бы одного renewal):
|
|
77
|
+
// TTL затухает от target; stale ровно когда остаток упадёт ниже
|
|
78
|
+
// target-grace, т.е. через STALE_LEASE_GRACE_S (600s ≈ 10 мин) после
|
|
79
|
+
// последнего успешного renewal (±30s из-за фазы цикла renewal).
|
|
80
|
+
// 3. BACKEND RESTART: in-memory таймеры renewal теряются, renewal больше нет,
|
|
81
|
+
// TTL затухает и lease либо детектится stale в пределах grace, либо
|
|
82
|
+
// авто-истекает по TTL. "Вечного" lease нет — TTL всегда конечен.
|
|
83
|
+
// 4. РЕАЛЬНО ЗАВИСШИЙ dispatch (backend жив, renewal тикает, но GPU-джоб
|
|
84
|
+
// стоит): lease остаётся HEALTHY — renewal привязан к живости backend-
|
|
85
|
+
// процесса, а не к прогрессу джоба. Это ОСОЗНАННО: зависший джоб ловит
|
|
86
|
+
// отдельный stall-failsafe (checkStalledAudio/VideoScenes по last_chunk/
|
|
87
|
+
// last_group_at), а не lease TTL.
|
|
88
|
+
// 5. TTL = -1 (ключ есть, но без expiry): считается stale — это сломанный
|
|
89
|
+
// lease, который никогда бы не истёк (вечный), поэтому его принудительно
|
|
90
|
+
// восстанавливаем. В норме не возникает: acquire/renewal всегда ставят EX.
|
|
91
|
+
// 6. TTL = -2 (ключа нет): НЕ stale — восстанавливать нечего; вызывающий код
|
|
92
|
+
// трактует это как отсутствие lease и диспатчит заново.
|
|
93
|
+
//
|
|
94
|
+
// ВАЖНО про "10 мин": STALE_LEASE_GRACE_S = 600s означает ровно 10 мин БЕЗ
|
|
95
|
+
// успешного renewal только ПОСЛЕ первого успешного renewal (когда TTL был
|
|
96
|
+
// поднят до target). Если владелец умер ДО первого renewal (TTL ещё равен
|
|
97
|
+
// базовому LEASE_TOTAL_TTLS = target - LEASE_RENEWAL_TTL_ADD), детекция
|
|
98
|
+
// происходит через grace - LEASE_RENEWAL_TTL_ADD = 420s (7 мин). Это не баг,
|
|
99
|
+
// а следствие того, что acquire ставит базовый TTL, а не target.
|
|
100
|
+
|
|
101
|
+
const STALE_LEASE_GRACE_S = 10 * 60; // 600s без успешного renewal (после 1-го renewal)
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* TTL that renewal keeps the lease pinned to (seconds).
|
|
105
|
+
*/
|
|
106
|
+
function getRenewalTargetTtlS(stage) {
|
|
107
|
+
return LEASE_TOTAL_TTLS[stage] + LEASE_RENEWAL_TTL_ADD;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Decide whether a lease with the given remaining TTL is stale.
|
|
112
|
+
* @param {number|null} ttl — redis TTL() result: -2 key gone, -1 no expiry, else seconds
|
|
113
|
+
* @param {string} stage — 'audio'|'image'|'video'
|
|
114
|
+
*/
|
|
115
|
+
function isLeaseStale(ttl, stage) {
|
|
116
|
+
if (ttl === null || ttl === undefined) return false;
|
|
117
|
+
if (ttl <= -2) return false; // key gone → no lease to be stale
|
|
118
|
+
if (ttl === -1) return true; // lease without expiry — broken, recover it
|
|
119
|
+
return ttl < getRenewalTargetTtlS(stage) - STALE_LEASE_GRACE_S;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Lease keys
|
|
123
|
+
const LEASE_PREFIX = 'animastor:dispatch-lease';
|
|
124
|
+
const LEASE_HB_PREFIX = 'animastor:lease-heartbeat';
|
|
125
|
+
const RENEWAL_TIMERS_KEY = 'animastor:runtime:renewal-timers';
|
|
126
|
+
|
|
127
|
+
// ======================================================
|
|
128
|
+
// KEY GENERATORS
|
|
129
|
+
// ======================================================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get lease key for scene:stage.
|
|
133
|
+
*/
|
|
134
|
+
function getLeaseKey(bookId, chapterId, sceneId, stage) {
|
|
135
|
+
return `${LEASE_PREFIX}:${bookId}:${chapterId}:${sceneId}:${stage}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get lease heartbeat key.
|
|
140
|
+
*/
|
|
141
|
+
function getLeaseHeartbeatKey(bookId, chapterId, sceneId, stage) {
|
|
142
|
+
return `${LEASE_HB_PREFIX}:${bookId}:${chapterId}:${sceneId}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get active renewal timers key.
|
|
147
|
+
*/
|
|
148
|
+
function getRenewalTimersKey() {
|
|
149
|
+
return RENEWAL_TIMERS_KEY;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ======================================================
|
|
153
|
+
// ACTIVE TIMER TRACKING
|
|
154
|
+
// ======================================================
|
|
155
|
+
|
|
156
|
+
const activeLeaseRenewals = new Map();
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Start lease renewal timer for dispatch.
|
|
160
|
+
* @param {Object} redis — Redis client for lease renewal operations
|
|
161
|
+
*/
|
|
162
|
+
function startLeaseRenewal(redis, bookId, chapterId, sceneId, stage, leaseKey, token) {
|
|
163
|
+
const timerKey = `${bookId}:${chapterId}:${sceneId}:${stage}`;
|
|
164
|
+
|
|
165
|
+
// Clear existing timer if any
|
|
166
|
+
if (activeLeaseRenewals.has(timerKey)) {
|
|
167
|
+
const existing = activeLeaseRenewals.get(timerKey);
|
|
168
|
+
if (existing.timerId) clearTimeout(existing.timerId);
|
|
169
|
+
if (existing.intervalId) clearInterval(existing.intervalId);
|
|
170
|
+
log(`RENEWAL_RESTART: ${timerKey}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const timerId = setTimeout(async () => {
|
|
174
|
+
try {
|
|
175
|
+
await renewLeaseIfOwner(redis, leaseKey, token);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
warn(`Renewal error for ${timerKey}: ${err.message}`);
|
|
178
|
+
}
|
|
179
|
+
}, RESTART_DELAY_MS);
|
|
180
|
+
|
|
181
|
+
const intervalId = setInterval(async () => {
|
|
182
|
+
try {
|
|
183
|
+
await renewLeaseIfOwner(redis, leaseKey, token);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
warn(`Renewal error for ${timerKey}: ${err.message}`);
|
|
186
|
+
}
|
|
187
|
+
}, RENEWAL_INTERVAL_MS);
|
|
188
|
+
|
|
189
|
+
activeLeaseRenewals.set(timerKey, {
|
|
190
|
+
timerId,
|
|
191
|
+
intervalId,
|
|
192
|
+
leaseKey,
|
|
193
|
+
token,
|
|
194
|
+
stage,
|
|
195
|
+
lastRenewal: Date.now(),
|
|
196
|
+
bookId,
|
|
197
|
+
chapterId,
|
|
198
|
+
sceneId
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
log(`RENEWAL_STARTED: ${timerKey} (first in ${RESTART_DELAY_MS}ms, then every ${RENEWAL_INTERVAL_MS}ms)`);
|
|
202
|
+
|
|
203
|
+
return { timerKey, timerId, intervalId };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Stop lease renewal timer.
|
|
208
|
+
*/
|
|
209
|
+
function stopLeaseRenewal(bookId, chapterId, sceneId, stage) {
|
|
210
|
+
const timerKey = `${bookId}:${chapterId}:${sceneId}:${stage}`;
|
|
211
|
+
const entry = activeLeaseRenewals.get(timerKey);
|
|
212
|
+
|
|
213
|
+
if (!entry) {
|
|
214
|
+
return { stopped: false, reason: 'not_found' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const { timerId, intervalId } = entry;
|
|
218
|
+
|
|
219
|
+
if (timerId) {
|
|
220
|
+
clearTimeout(timerId);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (intervalId) {
|
|
224
|
+
clearInterval(intervalId);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
activeLeaseRenewals.delete(timerKey);
|
|
228
|
+
|
|
229
|
+
log(`RENEWAL_STOPPED: ${timerKey}`);
|
|
230
|
+
|
|
231
|
+
return { stopped: true, reason: 'success', stage };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Renew lease if we are still the owner.
|
|
236
|
+
* Uses Lua script for atomic compare-and-renew operation.
|
|
237
|
+
* @param {Object} redis — Redis client (must be provided by caller)
|
|
238
|
+
*/
|
|
239
|
+
async function renewLeaseIfOwner(redis, leaseKey, expectedToken) {
|
|
240
|
+
if (!redis) {
|
|
241
|
+
warn(`No Redis context for renewal: ${leaseKey}`);
|
|
242
|
+
return { renewed: false, reason: 'no_redis' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const metadataKey = leaseKey.replace(
|
|
246
|
+
'animastor:dispatch-lease:',
|
|
247
|
+
'animastor:dispatch-meta:'
|
|
248
|
+
);
|
|
249
|
+
const luaScript = `
|
|
250
|
+
local key = KEYS[1]
|
|
251
|
+
local expected = ARGV[1]
|
|
252
|
+
local ttl_add = tonumber(ARGV[2])
|
|
253
|
+
local now = tonumber(ARGV[3])
|
|
254
|
+
|
|
255
|
+
local current = redis.call('GET', key)
|
|
256
|
+
|
|
257
|
+
if not current then
|
|
258
|
+
return {tostring(false), 'no_lease'}
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
if current ~= expected then
|
|
262
|
+
return {tostring(false), 'token_mismatch', current}
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
-- Renew the lease and matching dispatch metadata together.
|
|
266
|
+
redis.call('EXPIRE', key, ttl_add)
|
|
267
|
+
if redis.call('EXISTS', KEYS[2]) == 1 then
|
|
268
|
+
redis.call('EXPIRE', KEYS[2], ttl_add)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
return {tostring(true), 'renewed', tostring(now)}
|
|
272
|
+
`;
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
|
|
276
|
+
const result = await redis.eval(
|
|
277
|
+
luaScript,
|
|
278
|
+
2,
|
|
279
|
+
leaseKey,
|
|
280
|
+
metadataKey,
|
|
281
|
+
expectedToken,
|
|
282
|
+
LEASE_TOTAL_TTLS[getStageFromKey(leaseKey)] + LEASE_RENEWAL_TTL_ADD,
|
|
283
|
+
Date.now()
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (result[0] === 'true') {
|
|
287
|
+
log(`RENEWED: ${leaseKey} (ttl re-pinned to ${getRenewalTargetTtlS(getStageFromKey(leaseKey))}s)`);
|
|
288
|
+
return { renewed: true, reason: 'success' };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (result[1] === 'token_mismatch') {
|
|
292
|
+
warn(`OWNERSHIP_LOST: ${leaseKey} (current=${result[2]?.slice(0, 20)}...)`);
|
|
293
|
+
return { renewed: false, reason: 'ownership_lost' };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (result[1] === 'no_lease') {
|
|
297
|
+
log(`LEASE_EXPIRED: ${leaseKey}`);
|
|
298
|
+
return { renewed: false, reason: 'lease_expired' };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { renewed: false, reason: result[1] || 'unknown' };
|
|
302
|
+
|
|
303
|
+
} catch (err) {
|
|
304
|
+
error(`Renewal failed: ${leaseKey}: ${err.message}`);
|
|
305
|
+
return { renewed: false, reason: 'error', error: err.message };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Extract stage from lease key.
|
|
311
|
+
*/
|
|
312
|
+
function getStageFromKey(leaseKey) {
|
|
313
|
+
const parts = leaseKey.split(':');
|
|
314
|
+
return parts[parts.length - 1];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Get count of active renewals.
|
|
319
|
+
*/
|
|
320
|
+
function getActiveRenewalCount() {
|
|
321
|
+
return activeLeaseRenewals.size;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Get list of active renewal entries.
|
|
326
|
+
*/
|
|
327
|
+
function getActiveRenewals() {
|
|
328
|
+
const renewals = [];
|
|
329
|
+
for (const [key, entry] of activeLeaseRenewals) {
|
|
330
|
+
renewals.push({
|
|
331
|
+
key,
|
|
332
|
+
stage: entry.stage,
|
|
333
|
+
lastRenewal: entry.lastRenewal,
|
|
334
|
+
leaseKey: entry.leaseKey,
|
|
335
|
+
token: entry.token ? entry.token.slice(0, 16) + '...' : null,
|
|
336
|
+
bookId: entry.bookId,
|
|
337
|
+
chapterId: entry.chapterId,
|
|
338
|
+
sceneId: entry.sceneId
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return renewals;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ======================================================
|
|
345
|
+
// SAFE RELEASE WITH LUA
|
|
346
|
+
// ======================================================
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Acquire lease atomically (with Lua for safety).
|
|
350
|
+
*/
|
|
351
|
+
async function acquireLeaseSafe(redis, bookId, chapterId, sceneId, stage) {
|
|
352
|
+
const leaseKey = getLeaseKey(bookId, chapterId, sceneId, stage);
|
|
353
|
+
const token = generateDispatchToken();
|
|
354
|
+
const ttl = LEASE_TOTAL_TTLS[stage];
|
|
355
|
+
|
|
356
|
+
const luaScript = `
|
|
357
|
+
local key = KEYS[1]
|
|
358
|
+
local token = ARGV[1]
|
|
359
|
+
local ttl = tonumber(ARGV[2])
|
|
360
|
+
|
|
361
|
+
local current = redis.call('GET', key)
|
|
362
|
+
|
|
363
|
+
if current then
|
|
364
|
+
return {tostring(false), 'already_exists', current}
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
redis.call('SET', key, token, 'EX', ttl)
|
|
368
|
+
|
|
369
|
+
return {tostring(true), 'acquired', token}
|
|
370
|
+
`;
|
|
371
|
+
|
|
372
|
+
const result = await redis.eval(luaScript, 1, leaseKey, token, ttl);
|
|
373
|
+
|
|
374
|
+
if (result[0] === 'true') {
|
|
375
|
+
log(`LEASE_ACQUIRED: ${leaseKey} (token=${token.slice(0, 16)}...)`);
|
|
376
|
+
return { acquired: true, token, leaseKey };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (result[1] === 'already_exists') {
|
|
380
|
+
warn(`LEASE_EXISTS: ${leaseKey} (current=${result[2]?.slice(0, 16)}...)`);
|
|
381
|
+
return { acquired: false, reason: 'exists', currentToken: result[2] };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { acquired: false, reason: result[1] || 'unknown' };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Release lease atomically if still owner (using Lua compare-and-delete).
|
|
389
|
+
*/
|
|
390
|
+
async function releaseLeaseSafe(redis, leaseKey, expectedToken) {
|
|
391
|
+
const luaScript = `
|
|
392
|
+
local key = KEYS[1]
|
|
393
|
+
local expected = ARGV[1]
|
|
394
|
+
|
|
395
|
+
local current = redis.call('GET', key)
|
|
396
|
+
|
|
397
|
+
if not current then
|
|
398
|
+
return {tostring(true), 'already_expired'}
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
if current ~= expected then
|
|
402
|
+
return {tostring(false), 'token_mismatch', current}
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
redis.call('DEL', key)
|
|
406
|
+
|
|
407
|
+
return {tostring(true), 'released'}
|
|
408
|
+
`;
|
|
409
|
+
|
|
410
|
+
const result = await redis.eval(luaScript, 1, leaseKey, expectedToken);
|
|
411
|
+
|
|
412
|
+
if (result[0] === 'true') {
|
|
413
|
+
if (result[1] === 'released') {
|
|
414
|
+
log(`LEASE_RELEASED: ${leaseKey}`);
|
|
415
|
+
} else {
|
|
416
|
+
log(`LEASE_EXPIRED: ${leaseKey} (auto-expired)`);
|
|
417
|
+
}
|
|
418
|
+
return { released: true, leaseKey };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (result[1] === 'token_mismatch') {
|
|
422
|
+
warn(`RELEASE_FAILED: ${leaseKey} (token mismatch, current=${result[2]?.slice(0, 16)}...)`);
|
|
423
|
+
return { released: false, leaseKey, reason: 'token_mismatch' };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return { released: false, leaseKey, reason: result[1] || 'unknown' };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Release lease with token validation.
|
|
431
|
+
* Uses Lua script for atomic compare-and-delete.
|
|
432
|
+
*/
|
|
433
|
+
async function releaseLeaseWithToken(redis, leaseKey, token) {
|
|
434
|
+
// Check if Redis is available
|
|
435
|
+
if (!redis) {
|
|
436
|
+
warn(`Release failed: No Redis: ${leaseKey}`);
|
|
437
|
+
return { released: false, reason: 'no_redis' };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Use Lua script for atomicity
|
|
441
|
+
const luaScript = `
|
|
442
|
+
local key = KEYS[1]
|
|
443
|
+
local expected = ARGV[1]
|
|
444
|
+
|
|
445
|
+
local current = redis.call('GET', key)
|
|
446
|
+
|
|
447
|
+
if not current then
|
|
448
|
+
return {tostring(true), 'not_found'}
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
if current ~= expected then
|
|
452
|
+
return {tostring(false), 'token_mismatch'}
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
redis.call('DEL', key)
|
|
456
|
+
|
|
457
|
+
return {tostring(true), 'released'}
|
|
458
|
+
`;
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
const result = await redis.eval(luaScript, 1, leaseKey, token);
|
|
462
|
+
|
|
463
|
+
if (result[0] === 'true') {
|
|
464
|
+
if (result[1] === 'released') {
|
|
465
|
+
log(`LEASE_RELEASED: ${leaseKey} (owner)`);
|
|
466
|
+
return { released: true, leaseKey, reason: 'success' };
|
|
467
|
+
}
|
|
468
|
+
if (result[1] === 'not_found') {
|
|
469
|
+
log(`LEASE_EXPIRED: ${leaseKey} (auto-expired)`);
|
|
470
|
+
return { released: true, leaseKey, reason: 'expired' };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
warn(`LEASE_RELEASE_FAILED: ${leaseKey} (reason: ${result[1]})`);
|
|
475
|
+
return { released: false, leaseKey, reason: result[1] || 'unknown' };
|
|
476
|
+
|
|
477
|
+
} catch (err) {
|
|
478
|
+
error(`Release error: ${leaseKey}: ${err.message}`);
|
|
479
|
+
return { released: false, leaseKey, reason: 'error', error: err.message };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ======================================================
|
|
484
|
+
// HELPER FUNCTIONS
|
|
485
|
+
// ======================================================
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Generate unique dispatch token.
|
|
489
|
+
* (Uses same logic as dispatch-engine)
|
|
490
|
+
*/
|
|
491
|
+
function generateDispatchToken() {
|
|
492
|
+
return `dispatch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Get current lease TTL (server-side).
|
|
497
|
+
*/
|
|
498
|
+
async function getLeaseTTL(redis, leaseKey) {
|
|
499
|
+
const ttl = await redis.ttl(leaseKey);
|
|
500
|
+
return ttl > 0 ? ttl : 0;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Check if lease is still valid.
|
|
505
|
+
*/
|
|
506
|
+
async function isLeaseValid(redis, leaseKey, expectedToken) {
|
|
507
|
+
const current = await redis.get(leaseKey);
|
|
508
|
+
return current === expectedToken;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Get lease data.
|
|
513
|
+
*/
|
|
514
|
+
async function getLeaseData(redis, leaseKey) {
|
|
515
|
+
const token = await redis.get(leaseKey);
|
|
516
|
+
const ttl = await redis.ttl(leaseKey);
|
|
517
|
+
return { token, ttl };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Get active lease count by stage.
|
|
522
|
+
*/
|
|
523
|
+
async function getActiveCountByStage(redis, stage) {
|
|
524
|
+
const pattern = `${LEASE_PREFIX}:*:${stage}`;
|
|
525
|
+
let cursor = 0;
|
|
526
|
+
let count = 0;
|
|
527
|
+
|
|
528
|
+
do {
|
|
529
|
+
const result = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
530
|
+
cursor = parseInt(result[0], 10);
|
|
531
|
+
count += result[1].length;
|
|
532
|
+
} while (cursor !== 0 && count < 1000);
|
|
533
|
+
|
|
534
|
+
return count;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Get count of renewals in flight.
|
|
539
|
+
*/
|
|
540
|
+
function getRenewalCount() {
|
|
541
|
+
return activeLeaseRenewals.size;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Get metrics for all active renewals.
|
|
546
|
+
*/
|
|
547
|
+
function getRenewalMetrics() {
|
|
548
|
+
const renewals = getActiveRenewals();
|
|
549
|
+
const now = Date.now();
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
total: renewals.length,
|
|
553
|
+
byStage: {},
|
|
554
|
+
avgAge: 0,
|
|
555
|
+
oldest: null,
|
|
556
|
+
renewals
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ======================================================
|
|
561
|
+
// EXPORTS
|
|
562
|
+
// ======================================================
|
|
563
|
+
|
|
564
|
+
module.exports = {
|
|
565
|
+
// Configuration
|
|
566
|
+
RENEWAL_INTERVAL_MS,
|
|
567
|
+
RESTART_DELAY_MS,
|
|
568
|
+
LEASE_TOTAL_TTLS,
|
|
569
|
+
LEASE_RENEWAL_TTL_ADD,
|
|
570
|
+
STALE_LEASE_GRACE_S,
|
|
571
|
+
|
|
572
|
+
// Stale-lease semantics (audit c8b79f6)
|
|
573
|
+
getRenewalTargetTtlS,
|
|
574
|
+
isLeaseStale,
|
|
575
|
+
|
|
576
|
+
// Key generation
|
|
577
|
+
getLeaseKey,
|
|
578
|
+
getLeaseHeartbeatKey,
|
|
579
|
+
getRenewalTimersKey,
|
|
580
|
+
|
|
581
|
+
// Active renewal tracking
|
|
582
|
+
activeLeaseRenewals,
|
|
583
|
+
startLeaseRenewal,
|
|
584
|
+
stopLeaseRenewal,
|
|
585
|
+
renewLeaseIfOwner,
|
|
586
|
+
getActiveRenewalCount,
|
|
587
|
+
getActiveRenewals,
|
|
588
|
+
getRenewalCount,
|
|
589
|
+
getRenewalMetrics,
|
|
590
|
+
|
|
591
|
+
// Safe lease operations (with Lua)
|
|
592
|
+
acquireLeaseSafe,
|
|
593
|
+
releaseLeaseWithToken,
|
|
594
|
+
releaseLeaseSafe,
|
|
595
|
+
|
|
596
|
+
// Lease validation
|
|
597
|
+
getLeaseTTL,
|
|
598
|
+
isLeaseValid,
|
|
599
|
+
getLeaseData,
|
|
600
|
+
|
|
601
|
+
// Helpers
|
|
602
|
+
getActiveCountByStage,
|
|
603
|
+
generateDispatchToken,
|
|
604
|
+
|
|
605
|
+
// Utility
|
|
606
|
+
getStageFromKey
|
|
607
|
+
};
|