@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,229 @@
1
+ const mediaRegistry = require('@animastor/generation').mediaRegistry;
2
+ // ======================================================
3
+ // RETRY BUDGET MANAGER (MINIMAL)
4
+ // ======================================================
5
+ // S1.3 (2026-07-19): сокращён с 520 до ~140 строк.
6
+ // Удалены: refillBudgets (мёртвый wildcard-код, Б6), formatBudget,
7
+ // getGlobalBudgetOverview, getSceneBudgets (read-only debug), resetSceneStageBudget,
8
+ // избыточные export'ы.
9
+ //
10
+ // Production usage (dispatch-engine.js):
11
+ // checkRetryBudget() — перед dispatch'ем, решает можно ли попробовать
12
+ // consumeRetryBudget() — после finalizeDispatch('failure')
13
+ //
14
+ // Логика: per-(scene,stage) budget → 0 = stop retry.
15
+ // Глобальный, по-worker, по-type — оставлены как soft caps (TTL, без сложных policies).
16
+ // Этого достаточно для 1–15 concurrent пользователей (см. CAPACITY_AND_COMPLEXITY.md).
17
+
18
+ const logPrefix = '[RETRY-BUDGET]';
19
+ function log(msg) { console.log(`${logPrefix} ${msg}`); }
20
+
21
+ // ======================================================
22
+ // CONFIGURATION
23
+ // ======================================================
24
+
25
+ // S-2: per-scene limits resolved through media registry
26
+ function _perSceneLimit(stage) { return mediaRegistry.resolveRetryBudget(stage, 10); }
27
+ const GLOBAL_LIMITS = { retriesPerMinute: 100, retriesPerWorkerPerMinute: 20 };
28
+ const FAILURE_TYPE_LIMITS = { transient: 50, permanent: 0, infrastructure: 30, orchestration: 20 };
29
+
30
+ // ======================================================
31
+ // KEYS
32
+ // ======================================================
33
+
34
+ function getSceneStageBudgetKey(bookId, chapterId, sceneId, stage) {
35
+ return `animastor:retry-budget:${bookId}:${chapterId}:${sceneId}:${stage}`;
36
+ }
37
+
38
+ function getGlobalBudgetKey() {
39
+ return 'animastor:retry-budget:global';
40
+ }
41
+
42
+ function getFailureTypeBudgetKey(failureType) {
43
+ return `animastor:retry-budget:type:${failureType}`;
44
+ }
45
+
46
+ function getWorkerBudgetKey(workerId) {
47
+ return `animastor:retry-budget:worker:${workerId}`;
48
+ }
49
+
50
+ // ======================================================
51
+ // BUDGET GETTERS (read current budget value)
52
+ // ======================================================
53
+
54
+ async function getSceneStageBudget(redis, bookId, chapterId, sceneId, stage) {
55
+ const key = getSceneStageBudgetKey(bookId, chapterId, sceneId, stage);
56
+ const current = await redis.get(key);
57
+ const defaultValue = _perSceneLimit(stage);
58
+ return parseInt(current || String(defaultValue), 10);
59
+ }
60
+
61
+ async function getGlobalBudget(redis) {
62
+ const key = getGlobalBudgetKey();
63
+ const current = await redis.get(key);
64
+ return {
65
+ current: parseInt(current || String(GLOBAL_LIMITS.retriesPerMinute), 10),
66
+ max: GLOBAL_LIMITS.retriesPerMinute
67
+ };
68
+ }
69
+
70
+ async function getFailureTypeBudget(redis, failureType) {
71
+ const key = getFailureTypeBudgetKey(failureType);
72
+ const current = await redis.get(key);
73
+ const maxValue = FAILURE_TYPE_LIMITS[failureType] || 50;
74
+ return {
75
+ current: parseInt(current || String(maxValue), 10),
76
+ max: maxValue,
77
+ type: failureType
78
+ };
79
+ }
80
+
81
+ async function getWorkerBudget(redis, workerId) {
82
+ const key = getWorkerBudgetKey(workerId);
83
+ const current = await redis.get(key);
84
+ const maxValue = GLOBAL_LIMITS.retriesPerWorkerPerMinute;
85
+ return {
86
+ current: parseInt(current || String(maxValue), 10),
87
+ max: maxValue,
88
+ workerId
89
+ };
90
+ }
91
+
92
+ // ======================================================
93
+ // BUDGET CONSUMERS (atomic decrement)
94
+ // ======================================================
95
+
96
+ async function consumeSceneStageBudget(redis, bookId, chapterId, sceneId, stage) {
97
+ const key = getSceneStageBudgetKey(bookId, chapterId, sceneId, stage);
98
+ const current = await redis.get(key);
99
+ const value = parseInt(current || String(_perSceneLimit(stage)), 10);
100
+
101
+ if (value <= 0) {
102
+ return { consumed: false, remaining: 0, exceeded: true };
103
+ }
104
+
105
+ const remaining = value - 1;
106
+ await redis.set(key, remaining.toString());
107
+ log(`BUDGET_CONSUMED: ${bookId}/${chapterId}/${sceneId}:${stage} (remaining: ${remaining})`);
108
+ return { consumed: true, remaining, exceeded: false };
109
+ }
110
+
111
+ async function consumeGlobalBudget(redis) {
112
+ const key = getGlobalBudgetKey();
113
+ const current = await redis.get(key);
114
+ const currentValue = parseInt(current || String(GLOBAL_LIMITS.retriesPerMinute), 10);
115
+
116
+ if (currentValue <= 0) {
117
+ return { consumed: false, remaining: 0, exceeded: true, global: true };
118
+ }
119
+
120
+ const remaining = currentValue - 1;
121
+ await redis.set(key, remaining.toString(), 'EX', 60);
122
+ return { consumed: true, remaining, exceeded: false, global: true };
123
+ }
124
+
125
+ async function consumeFailureTypeBudget(redis, failureType) {
126
+ const key = getFailureTypeBudgetKey(failureType);
127
+ const current = await redis.get(key);
128
+ const maxValue = FAILURE_TYPE_LIMITS[failureType] || 50;
129
+ const currentValue = parseInt(current || String(maxValue), 10);
130
+
131
+ if (currentValue <= 0) {
132
+ return { consumed: false, remaining: 0, exceeded: true, failureType };
133
+ }
134
+
135
+ const remaining = currentValue - 1;
136
+ await redis.set(key, remaining.toString(), 'EX', 300);
137
+ return { consumed: true, remaining, exceeded: false, failureType };
138
+ }
139
+
140
+ async function consumeWorkerBudget(redis, workerId) {
141
+ if (!workerId) return { consumed: true, skipped: true };
142
+ const key = getWorkerBudgetKey(workerId);
143
+ const current = await redis.get(key);
144
+ const maxValue = GLOBAL_LIMITS.retriesPerWorkerPerMinute;
145
+ const currentValue = parseInt(current || String(maxValue), 10);
146
+
147
+ if (currentValue <= 0) {
148
+ return { consumed: false, remaining: 0, exceeded: true, workerId };
149
+ }
150
+
151
+ const remaining = currentValue - 1;
152
+ await redis.set(key, remaining.toString(), 'EX', 60);
153
+ return { consumed: true, remaining, exceeded: false, workerId };
154
+ }
155
+
156
+ // ======================================================
157
+ // CHECK + CONSUME (production API)
158
+ // ======================================================
159
+
160
+ /**
161
+ * Check if a retry is allowed before dispatching.
162
+ * Returns: { allowed, reason, budgets }
163
+ *
164
+ * Order: scene → failureType → global → worker. First exhausted wins.
165
+ */
166
+ async function checkRetryBudget(redis, bookId, chapterId, sceneId, stage, failureType, workerId) {
167
+ const sceneBudget = await getSceneStageBudget(redis, bookId, chapterId, sceneId, stage);
168
+ const budgets = {
169
+ sceneStage: { current: sceneBudget, limit: _perSceneLimit(stage), allowed: sceneBudget > 0 }
170
+ };
171
+
172
+ if (sceneBudget <= 0) {
173
+ return { allowed: false, reason: 'scene_budget_exceeded', budgets };
174
+ }
175
+
176
+ const typeBudget = await getFailureTypeBudget(redis, failureType);
177
+ budgets.byType = { current: typeBudget.current, max: typeBudget.max, allowed: typeBudget.current > 0 };
178
+ if (typeBudget.current <= 0 && FAILURE_TYPE_LIMITS[failureType] !== 0) {
179
+ return { allowed: false, reason: 'type_budget_exceeded', budgets };
180
+ }
181
+
182
+ const globalBudget = await getGlobalBudget(redis);
183
+ budgets.global = { current: globalBudget.current, max: globalBudget.max, allowed: globalBudget.current > 0 };
184
+ if (globalBudget.current <= 0) {
185
+ return { allowed: false, reason: 'global_budget_exceeded', budgets };
186
+ }
187
+
188
+ if (workerId) {
189
+ const workerBudget = await getWorkerBudget(redis, workerId);
190
+ budgets.worker = { current: workerBudget.current, max: workerBudget.max, allowed: workerBudget.current > 0 };
191
+ if (workerBudget.current <= 0) {
192
+ return { allowed: false, reason: 'worker_budget_exceeded', budgets };
193
+ }
194
+ }
195
+
196
+ return { allowed: true, reason: 'budget_available', budgets };
197
+ }
198
+
199
+ /**
200
+ * Consume retry budget after a failed dispatch finalization (called once per failure).
201
+ * Returns: { consumed, budgets }
202
+ */
203
+ async function consumeRetryBudget(redis, bookId, chapterId, sceneId, stage, failureType, workerId) {
204
+ const budgets = {
205
+ sceneStage: { ...await consumeSceneStageBudget(redis, bookId, chapterId, sceneId, stage), limit: _perSceneLimit(stage) },
206
+ byType: await consumeFailureTypeBudget(redis, failureType),
207
+ global: await consumeGlobalBudget(redis),
208
+ worker: await consumeWorkerBudget(redis, workerId)
209
+ };
210
+ return { consumed: true, budgets };
211
+ }
212
+
213
+ // ======================================================
214
+ // EXPORTS
215
+ // ======================================================
216
+
217
+ module.exports = {
218
+ // Config (для диагностики) — S-2: lazy view over the media registry
219
+ PER_SCENE_LIMITS: new Proxy({}, {
220
+ get(_, stage) { return mediaRegistry.resolveRetryBudget(String(stage), undefined); },
221
+ ownKeys() { return mediaRegistry.listMediaTypes(); },
222
+ }),
223
+ GLOBAL_LIMITS,
224
+ FAILURE_TYPE_LIMITS,
225
+
226
+ // Production API
227
+ checkRetryBudget,
228
+ consumeRetryBudget
229
+ };
@@ -0,0 +1,427 @@
1
+ // ======================================================
2
+ // RUNTIME METRICS LAYER
3
+ // ======================================================
4
+ // Collects and exports runtime metrics for observability.
5
+ //
6
+ // Metrics stored in Redis:
7
+ // - animastor:runtime:metrics:current
8
+ // - animastor:runtime:metrics:history (sorted set)
9
+ //
10
+ // Metrics tracked:
11
+ // - active scenes
12
+ // - active dispatches/leases
13
+ // - quota utilization
14
+ // - scheduler ticks (count, duration)
15
+ // - recovery actions
16
+ // - stale recoveries
17
+ // - duplicate dispatch skips
18
+ // - counter drift
19
+
20
+ const counterReconciliation = require('./counter-reconciliation');
21
+ // S-2: media types and quotas resolved from media registry
22
+ const mediaRegistry = require('@animastor/generation').mediaRegistry;
23
+
24
+ const logPrefix = '[METRICS]';
25
+
26
+ function log(msg) {
27
+ console.log(`${logPrefix} ${msg}`);
28
+ }
29
+
30
+ function warn(msg) {
31
+ console.warn(`${logPrefix} ⚠️ ${msg}`);
32
+ }
33
+
34
+ function error(msg) {
35
+ console.error(`${logPrefix} ❌ ${msg}`);
36
+ }
37
+
38
+ // ======================================================
39
+ // CONFIGURATION
40
+ // ======================================================
41
+
42
+ const METRICS_KEY = 'animastor:runtime:metrics:current';
43
+ const METRICS_HISTORY_KEY = 'animastor:runtime:metrics:history';
44
+ const METRICS_RETENTION_LIMIT = 1000;
45
+
46
+ // ======================================================
47
+ // METRIC DEFINITIONS
48
+ // ======================================================
49
+
50
+ const METRIC_DEFS = {
51
+ // Scene metrics
52
+ activeScenes: { type: 'gauge', description: 'Number of active scenes' },
53
+ activeAudioScenes: { type: 'gauge', description: 'Active scenes in audio stages' },
54
+ activeImageScenes: { type: 'gauge', description: 'Active scenes in image stages' },
55
+ activeVideoScenes: { type: 'gauge', description: 'Active scenes in video stages' },
56
+
57
+ // Dispatch/lease metrics
58
+ activeLeases: { type: 'gauge', description: 'Total active leases' },
59
+ activeAudioLeases: { type: 'gauge', description: 'Active audio leases' },
60
+ activeImageLeases: { type: 'gauge', description: 'Active image leases' },
61
+ activeVideoLeases: { type: 'gauge', description: 'Active video leases' },
62
+ leaseRenewalsInFlight: { type: 'gauge', description: 'Active lease renewal timers' },
63
+
64
+ // Quota metrics
65
+ quotaAudioUsed: { type: 'gauge', description: 'Active audio dispatches' },
66
+ quotaAudioMax: { type: 'gauge', description: 'Max concurrent audio' },
67
+ quotaImageUsed: { type: 'gauge', description: 'Active image dispatches' },
68
+ quotaImageMax: { type: 'gauge', description: 'Max concurrent image' },
69
+ quotaVideoUsed: { type: 'gauge', description: 'Active video dispatches' },
70
+ quotaVideoMax: { type: 'gauge', description: 'Max concurrent video' },
71
+
72
+ // Scheduler metrics
73
+ schedulerTicks: { type: 'counter', description: 'Total scheduler ticks' },
74
+ schedulerTickDurationAvg: { type: 'gauge', description: 'Average tick duration (ms)' },
75
+ schedulerLastTickDuration: { type: 'gauge', description: 'Last tick duration (ms)' },
76
+
77
+ // Dispatch metrics
78
+ dispatchesAttempted: { type: 'counter', description: 'Total dispatch attempts' },
79
+ dispatchesSuccess: { type: 'counter', description: 'Successful dispatches' },
80
+ dispatchesSkipped: { type: 'counter', description: 'Skipped dispatches (lease exists)' },
81
+ dispatchesBackpressure: { type: 'counter', description: 'BackpressureBlocked dispatches' },
82
+
83
+ // Recovery metrics
84
+ recoveryActions: { type: 'counter', description: 'Total recovery actions' },
85
+ staleLeaseRecoveries: { type: 'counter', description: 'Stale lease recoveries' },
86
+ counterDriftCorrections: { type: 'counter', description: 'Counter drift corrections' },
87
+ stateRollbackFailures: { type: 'counter', description: 'Failed FSM rollbacks after jobless dispatch cancellation (ghost GENERATING risk)' },
88
+ iuInFlightStaleCleared: { type: 'counter', description: 'Stale iu-in-flight markers cleared by ownership self-heal' },
89
+
90
+ // Event metrics
91
+ callbackDuplicates: { type: 'counter', description: 'Duplicate callback events' },
92
+ queueBlocked: { type: 'counter', description: 'Queue blocked events' },
93
+
94
+ // Drift metrics
95
+ counterDriftTotal: { type: 'gauge', description: 'Total counter drift magnitude' }
96
+ };
97
+
98
+ // ======================================================
99
+ // GETTER FUNCTIONS
100
+ // ======================================================
101
+
102
+ /**
103
+ * Get metric definitions.
104
+ */
105
+ function getMetricDefinitions() {
106
+ return METRIC_DEFS;
107
+ }
108
+
109
+ /**
110
+ * Get current runtime metrics from Redis.
111
+ */
112
+ async function getCurrentMetrics(redis, additionalData = {}) {
113
+ const timestamp = Date.now();
114
+
115
+ // Get active scenes
116
+ const activeScenesKey = 'animastor:active-scenes';
117
+ const activeScenes = await redis.scard(activeScenesKey);
118
+
119
+ // Get active leases by stage
120
+ const leasePatternAudio = 'animastor:dispatch-lease:*:audio';
121
+ const leasePatternImage = 'animastor:dispatch-lease:*:image';
122
+ const leasePatternVideo = 'animastor:dispatch-lease:*:video';
123
+
124
+ const [audioCount, imageCount, videoCount] = await Promise.all([
125
+ countPattern(redis, leasePatternAudio),
126
+ countPattern(redis, leasePatternImage),
127
+ countPattern(redis, leasePatternVideo)
128
+ ]);
129
+
130
+ // Get active counters
131
+ const activeKeys = [
132
+ 'animastor:runtime:active-audio',
133
+ 'animastor:runtime:active-image',
134
+ 'animastor:runtime:active-video'
135
+ ];
136
+
137
+ const [audioCounter, imageCounter, videoCounter] = await Promise.all([
138
+ getCounter(redis, activeKeys[0]),
139
+ getCounter(redis, activeKeys[1]),
140
+ getCounter(redis, activeKeys[2])
141
+ ]);
142
+
143
+ // Get quota settings
144
+ const quotas = Object.fromEntries(mediaRegistry.listMediaTypes().map(t => ['max' + t.charAt(0).toUpperCase() + t.slice(1), mediaRegistry.resolveMaxActive(t)]));
145
+
146
+ // Get lease renewal count
147
+ const renewalKey = 'animastor:runtime:renewal-timers';
148
+ const renewalCount = await redis.scard(renewalKey);
149
+
150
+ // Get scheduler history
151
+ const schedulerKey = 'animastor:runtime:scheduler:history';
152
+ const schedulerHistory = await redis.lrange(schedulerKey, -10, -1);
153
+
154
+ let schedulerTicks = 0;
155
+ let totalTickDuration = 0;
156
+ let lastTickDuration = 0;
157
+
158
+ if (schedulerHistory.length > 0) {
159
+ schedulerTicks = schedulerHistory.length;
160
+ for (const item of schedulerHistory) {
161
+ try {
162
+ const data = JSON.parse(item);
163
+ totalTickDuration += data.durationMs || 0;
164
+ lastTickDuration = data.durationMs || 0;
165
+ } catch (e) {
166
+ // Skip invalid entries
167
+ }
168
+ }
169
+ }
170
+
171
+ // Get recovery counts
172
+ const recoveryKey = 'animastor:runtime:recovery:actions';
173
+ const recoveryActions = await redis.scard(recoveryKey);
174
+
175
+ const metrics = {
176
+ // Timestamp
177
+ timestamp,
178
+ createdAt: new Date(timestamp).toISOString(),
179
+
180
+ // Scenes
181
+ activeScenes,
182
+ activeAudioScenes: await getScenesInStage(redis, 'audio_generating'),
183
+ activeImageScenes: await getScenesInStage(redis, 'image_generating'),
184
+ activeVideoScenes: await getScenesInStage(redis, 'video_generating'),
185
+
186
+ // Leases
187
+ activeLeases: audioCount + imageCount + videoCount,
188
+ activeAudioLeases: audioCount,
189
+ activeImageLeases: imageCount,
190
+ activeVideoLeases: videoCount,
191
+ leaseRenewalsInFlight: renewalCount,
192
+
193
+ // Quotas
194
+ quotaAudioUsed: audioCounter,
195
+ quotaAudioMax: quotas.maxAudio,
196
+ quotaImageUsed: imageCounter,
197
+ quotaImageMax: quotas.maxImage,
198
+ quotaVideoUsed: videoCounter,
199
+ quotaVideoMax: quotas.maxVideo,
200
+
201
+ // Scheduler
202
+ schedulerTicks,
203
+ schedulerTickDurationAvg: schedulerTicks > 0 ? Math.round(totalTickDuration / schedulerTicks) : 0,
204
+ schedulerLastTickDuration: lastTickDuration,
205
+
206
+ // Additional data
207
+ ...additionalData
208
+ };
209
+
210
+ return metrics;
211
+ }
212
+
213
+ /**
214
+ * Count keys matching pattern.
215
+ */
216
+ async function countPattern(redis, pattern) {
217
+ let cursor = 0;
218
+ let count = 0;
219
+
220
+ do {
221
+ const result = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 500);
222
+ cursor = parseInt(result[0], 10);
223
+ count += result[1].length;
224
+ } while (cursor !== 0 && count < 10000);
225
+
226
+ return count;
227
+ }
228
+
229
+ /**
230
+ * Get counter value.
231
+ */
232
+ async function getCounter(redis, key) {
233
+ const val = await redis.get(key);
234
+ return parseInt(val || '0', 10);
235
+ }
236
+
237
+ /**
238
+ * Get scenes in a specific stage (stub — scene-state removed).
239
+ */
240
+ async function getScenesInStage(redis, stage) {
241
+ return 0;
242
+ }
243
+
244
+ /**
245
+ * Store current metrics in Redis.
246
+ */
247
+ async function storeMetrics(redis, metrics) {
248
+ // Store current
249
+ await redis.set(METRICS_KEY, JSON.stringify(metrics));
250
+
251
+ // Add to history (sorted set with timestamp as score)
252
+ const historyKey = METRICS_HISTORY_KEY;
253
+ await redis.zadd(historyKey, metrics.timestamp, JSON.stringify(metrics));
254
+
255
+ // Trim history
256
+ const count = await redis.zcard(historyKey);
257
+ if (count > METRICS_RETENTION_LIMIT) {
258
+ await redis.zremrangebyrank(historyKey, 0, count - METRICS_RETENTION_LIMIT - 1);
259
+ }
260
+
261
+ return { stored: true, key: METRICS_KEY, historyKey, historyLength: count };
262
+ }
263
+
264
+ /**
265
+ * Get metrics history.
266
+ */
267
+ async function getMetricsHistory(redis, limit = 100, offset = 0) {
268
+ const historyKey = METRICS_HISTORY_KEY;
269
+ const count = await redis.zcard(historyKey);
270
+
271
+ const start = Math.max(0, count - limit - offset);
272
+ const end = count - offset - 1;
273
+
274
+ const range = await redis.zrange(historyKey, start, end);
275
+ const metrics = range.map(item => {
276
+ try {
277
+ return JSON.parse(item);
278
+ } catch (e) {
279
+ return null;
280
+ }
281
+ }).filter(m => m !== null);
282
+
283
+ return {
284
+ total: count,
285
+ metrics,
286
+ returned: metrics.length
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Increment counter metric.
292
+ */
293
+ async function incrementCounter(redis, name, value = 1) {
294
+ const key = `${METRICS_KEY}:${name}`;
295
+ const newValue = await redis.incrby(key, value);
296
+ return { name, key, value: newValue };
297
+ }
298
+
299
+ /**
300
+ * Get counter metric.
301
+ */
302
+ async function getCounterMetric(redis, name) {
303
+ const key = `${METRICS_KEY}:${name}`;
304
+ const val = await redis.get(key);
305
+ return parseInt(val || '0', 10);
306
+ }
307
+
308
+ /**
309
+ * Record scheduler tick.
310
+ */
311
+ async function recordSchedulerTick(redis, tickMetrics) {
312
+ const historyKey = 'animastor:runtime:scheduler:history';
313
+ await redis.rpush(historyKey, JSON.stringify(tickMetrics));
314
+ await redis.ltrim(historyKey, -1000, -1); // Keep last 1000
315
+
316
+ return { recorded: true, historyKey };
317
+ }
318
+
319
+ /**
320
+ * Record recovery action.
321
+ */
322
+ async function recordRecoveryAction(redis, action) {
323
+ const key = 'animastor:runtime:recovery:actions';
324
+ const data = {
325
+ ...action,
326
+ timestamp: Date.now()
327
+ };
328
+ await redis.sadd(key, JSON.stringify(data));
329
+ return { recorded: true, key, action };
330
+ }
331
+
332
+ /**
333
+ * Record duplicate callback.
334
+ */
335
+ async function recordDuplicateCallback(redis) {
336
+ return await incrementCounter(redis, 'callbackDuplicates');
337
+ }
338
+
339
+ /**
340
+ * Record backpressure-blocked dispatch.
341
+ */
342
+ async function recordBackpressureBlocked(redis) {
343
+ return await incrementCounter(redis, 'dispatchesBackpressure');
344
+ }
345
+
346
+ /**
347
+ * Record stuck scene detection.
348
+ */
349
+ async function recordStuckScene(redis, scene) {
350
+ const key = 'animastor:runtime:stuck-scenes';
351
+ const data = {
352
+ ...scene,
353
+ timestamp: Date.now()
354
+ };
355
+ await redis.sadd(key, JSON.stringify(data));
356
+ return { recorded: true, key, scene };
357
+ }
358
+
359
+ /**
360
+ * Get comprehensive metrics report.
361
+ */
362
+ async function getMetricsReport(redis) {
363
+ const current = await getCurrentMetrics(redis);
364
+ const history = await getMetricsHistory(redis, 10);
365
+ const driftReport = await counterReconciliation.checkForDrift(redis);
366
+
367
+ return {
368
+ current,
369
+ history: history.metrics,
370
+ drift: driftReport
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Get runtime health status.
376
+ */
377
+ async function getHealthStatus(redis) {
378
+ const current = await getCurrentMetrics(redis);
379
+
380
+ // Check if leases are growing faster than counter consumption
381
+ const leaseGrowth = current.activeLeases > 0 && current.quotaAudioUsed > current.activeLeases;
382
+ const potentialLeak = current.activeLeases > 100; // Suspiciously high
383
+
384
+ return {
385
+ healthy: !potentialLeak,
386
+ issues: potentialLeak ? ['high_active_leases'] : [],
387
+ metrics: current
388
+ };
389
+ }
390
+
391
+ // ======================================================
392
+ // EXPORTS
393
+ // ======================================================
394
+
395
+ module.exports = {
396
+ // Configuration
397
+ METRICS_KEY,
398
+ METRICS_HISTORY_KEY,
399
+ METRICS_RETENTION_LIMIT,
400
+
401
+ // Definitions
402
+ getMetricDefinitions,
403
+ METRIC_DEFS,
404
+
405
+ // Getters
406
+ getCurrentMetrics,
407
+ getMetricsHistory,
408
+ getMetricsReport,
409
+ getHealthStatus,
410
+
411
+ // Storage
412
+ storeMetrics,
413
+ recordSchedulerTick,
414
+ recordRecoveryAction,
415
+ recordDuplicateCallback,
416
+ recordBackpressureBlocked,
417
+ recordStuckScene,
418
+
419
+ // Counters
420
+ incrementCounter,
421
+ getCounterMetric,
422
+
423
+ // Helper
424
+ countPattern,
425
+ getCounter,
426
+ getScenesInStage
427
+ };