@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,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
|
+
};
|