@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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/package.json +51 -0
  4. package/src/host/host-bindings.js +138 -0
  5. package/src/index.js +99 -0
  6. package/src/orchestration/index.js +40 -0
  7. package/src/orchestration/orchestrator.js +630 -0
  8. package/src/orchestration/runtime-result-consumer.js +54 -0
  9. package/src/orchestration/scene-callbacks.js +542 -0
  10. package/src/orchestration/scene-orchestrator.js +601 -0
  11. package/src/orchestration/scene-restoration.js +129 -0
  12. package/src/orchestration/scene-utils.js +36 -0
  13. package/src/runtime/active-scenes-index.js +164 -0
  14. package/src/runtime/audio-fsm-port.js +187 -0
  15. package/src/runtime/circuit-breaker.js +588 -0
  16. package/src/runtime/counter-reconciliation.js +308 -0
  17. package/src/runtime/dispatch-engine.js +1768 -0
  18. package/src/runtime/failure-taxonomy.js +125 -0
  19. package/src/runtime/hub-cancel-port.js +122 -0
  20. package/src/runtime/layer-config-port.js +136 -0
  21. package/src/runtime/lease-manager.js +607 -0
  22. package/src/runtime/persistence-port.js +162 -0
  23. package/src/runtime/placeholder-audio-port.js +117 -0
  24. package/src/runtime/progress-events-port.js +121 -0
  25. package/src/runtime/reconciliation-engine.js +2405 -0
  26. package/src/runtime/retry-budget-manager.js +229 -0
  27. package/src/runtime/runtime-metrics.js +427 -0
  28. package/src/runtime/runtime-result-emitter.js +123 -0
  29. package/src/runtime/runtime-scheduler.js +629 -0
  30. package/src/runtime/scene-data-port.js +114 -0
  31. package/src/runtime/scene-window.js +827 -0
  32. package/src/runtime/video-fsm-port.js +189 -0
  33. package/src/runtime/worker-health.js +270 -0
  34. package/test/counter-reconciliation.test.js +164 -0
  35. package/test/mocks/redis-mock.js +221 -0
  36. 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
+ };