mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
Improve lock publish diagnostics and scheduler failure summaries
This commit is contained in:
1 parent
872593f4fd
commit
46198a500e
4 files changed
+238
-21
No files matched your search
@@ -50,6 +50,23 @@ function parseJsonFromOutput(text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonEventsFromOutput(text) {
|
||||
const events = [];
|
||||
for (const line of String(text || '').split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('{')) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
events.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON lines.
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
async function runCommand(command, args = [], options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
@@ -428,6 +445,7 @@ async function acquireLockWithRetry({ selectedAgent, cadence, platform, lockRetr
|
||||
const lockCommandArgs = ['run', 'lock:lock', '--', '--agent', selectedAgent, '--cadence', cadence];
|
||||
const backoffScheduleMs = [];
|
||||
let attempts = 0;
|
||||
const correlationId = idempotencyKey || `${cadence}:${selectedAgent}:${getRunDateKey()}`;
|
||||
|
||||
while (true) {
|
||||
attempts += 1;
|
||||
@@ -435,11 +453,13 @@ async function acquireLockWithRetry({ selectedAgent, cadence, platform, lockRetr
|
||||
env: {
|
||||
AGENT_PLATFORM: platform,
|
||||
...(idempotencyKey ? { SCHEDULER_LOCK_IDEMPOTENCY_KEY: idempotencyKey } : {}),
|
||||
SCHEDULER_LOCK_CORRELATION_ID: correlationId,
|
||||
SCHEDULER_LOCK_ATTEMPT_ID: String(attempts),
|
||||
},
|
||||
});
|
||||
|
||||
if (result.code !== 2) {
|
||||
return { result, attempts, backoffScheduleMs };
|
||||
return { result, attempts, backoffScheduleMs, correlationId };
|
||||
}
|
||||
|
||||
if (attempts > lockRetry.maxRetries) {
|
||||
@@ -447,6 +467,7 @@ async function acquireLockWithRetry({ selectedAgent, cadence, platform, lockRetr
|
||||
result,
|
||||
attempts,
|
||||
backoffScheduleMs,
|
||||
correlationId,
|
||||
finalBackendCategory: classifyLockBackendError(`${result.stderr}\n${result.stdout}`),
|
||||
};
|
||||
}
|
||||
@@ -463,6 +484,7 @@ async function acquireLockWithRetry({ selectedAgent, cadence, platform, lockRetr
|
||||
attempt: attempts,
|
||||
max_retries: lockRetry.maxRetries,
|
||||
delay_ms: delayMs,
|
||||
correlation_id: correlationId,
|
||||
selected_agent: selectedAgent,
|
||||
cadence,
|
||||
}));
|
||||
@@ -473,6 +495,34 @@ async function acquireLockWithRetry({ selectedAgent, cadence, platform, lockRetr
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeLockFailureReasons(outputText) {
|
||||
const events = parseJsonEventsFromOutput(outputText);
|
||||
const quorumFailure = [...events].reverse().find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
if (quorumFailure && quorumFailure.reasonDistribution && typeof quorumFailure.reasonDistribution === 'object') {
|
||||
return {
|
||||
reasonDistribution: quorumFailure.reasonDistribution,
|
||||
attemptId: quorumFailure.attemptId || null,
|
||||
correlationId: quorumFailure.correlationId || null,
|
||||
totalElapsedMs: quorumFailure.totalElapsedMs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const distribution = {};
|
||||
for (const entry of events) {
|
||||
if (entry.event !== 'lock_publish_failure') continue;
|
||||
const reason = typeof entry.reason === 'string' && entry.reason.trim()
|
||||
? entry.reason.trim()
|
||||
: 'unknown';
|
||||
distribution[reason] = (distribution[reason] || 0) + 1;
|
||||
}
|
||||
return {
|
||||
reasonDistribution: distribution,
|
||||
attemptId: null,
|
||||
correlationId: null,
|
||||
totalElapsedMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toYamlScalar(value) {
|
||||
const str = String(value ?? '');
|
||||
return `'${str.replace(/'/g, "''")}'`;
|
||||
@@ -622,6 +672,7 @@ async function main() {
|
||||
|
||||
if (lockResult.code === 2) {
|
||||
const combinedLockOutput = `${lockResult.stderr}\n${lockResult.stdout}`;
|
||||
const diagnosticsSummary = summarizeLockFailureReasons(combinedLockOutput);
|
||||
const backendCategory = lockAttempt.finalBackendCategory || classifyLockBackendError(combinedLockOutput);
|
||||
const lockCommand = `AGENT_PLATFORM=${platform} npm run lock:lock -- --agent ${selectedAgent} --cadence ${cadence}`;
|
||||
const stderrExcerpt = excerptText(lockResult.stderr);
|
||||
@@ -678,6 +729,10 @@ async function main() {
|
||||
failure_class: 'backend_unavailable',
|
||||
lock_attempts_total: lockAttempt.attempts,
|
||||
lock_backoff_schedule_ms: backoffSchedule || '(none)',
|
||||
lock_correlation_id: diagnosticsSummary.correlationId || lockAttempt.correlationId,
|
||||
lock_attempt_id: diagnosticsSummary.attemptId || String(lockAttempt.attempts),
|
||||
lock_total_retry_timeline_ms: diagnosticsSummary.totalElapsedMs,
|
||||
lock_failure_reason_distribution: JSON.stringify(diagnosticsSummary.reasonDistribution),
|
||||
backend_category: backendCategory,
|
||||
deferral_attempt_count: deferralAttemptCount,
|
||||
deferral_first_failure_timestamp: firstFailureAt,
|
||||
|
||||
+102
-5
@@ -69,6 +69,39 @@ function relayListLabel(relays) {
|
||||
|
||||
function classifyPublishError(message) {
|
||||
const normalized = String(message || '').toLowerCase();
|
||||
if (normalized.includes('publish timed out after') || normalized.includes('publish timeout')) {
|
||||
return 'publish_timeout';
|
||||
}
|
||||
if (
|
||||
normalized.includes('enotfound')
|
||||
|| normalized.includes('eai_again')
|
||||
|| normalized.includes('getaddrinfo')
|
||||
|| (normalized.includes('dns') && normalized.includes('websocket'))
|
||||
) {
|
||||
return 'dns_resolution';
|
||||
}
|
||||
if (
|
||||
normalized.includes('connect etimedout')
|
||||
|| normalized.includes('tcp connect timed out')
|
||||
|| normalized.includes('connect timeout')
|
||||
) {
|
||||
return 'tcp_connect_timeout';
|
||||
}
|
||||
if (
|
||||
normalized.includes('tls')
|
||||
|| normalized.includes('ssl')
|
||||
|| normalized.includes('certificate')
|
||||
|| normalized.includes('handshake')
|
||||
) {
|
||||
return 'tls_handshake';
|
||||
}
|
||||
if (
|
||||
normalized.includes('websocket')
|
||||
|| normalized.includes('bad response')
|
||||
|| normalized.includes('unexpected server response')
|
||||
) {
|
||||
return 'websocket_open_failure';
|
||||
}
|
||||
if (normalized.includes('timed out') || normalized.includes('timeout') || normalized.includes('etimedout')) {
|
||||
return 'network_timeout';
|
||||
}
|
||||
@@ -89,7 +122,16 @@ function classifyPublishError(message) {
|
||||
}
|
||||
|
||||
function isTransientPublishCategory(category) {
|
||||
return category === 'network_timeout' || category === 'connection_reset' || category === 'relay_unavailable';
|
||||
return [
|
||||
'publish_timeout',
|
||||
'dns_resolution',
|
||||
'tcp_connect_timeout',
|
||||
'tls_handshake',
|
||||
'websocket_open_failure',
|
||||
'network_timeout',
|
||||
'connection_reset',
|
||||
'relay_unavailable',
|
||||
].includes(category);
|
||||
}
|
||||
|
||||
function calculateBackoffDelayMs(attemptNumber, baseMs, capMs, randomFn = Math.random) {
|
||||
@@ -366,8 +408,12 @@ export async function publishLock(relays, event, deps = {}) {
|
||||
randomFn = Math.random,
|
||||
telemetryLogger = console.error,
|
||||
healthLogger = console.error,
|
||||
diagnostics = {},
|
||||
} = deps;
|
||||
|
||||
const correlationId = diagnostics.correlationId || process.env.SCHEDULER_LOCK_CORRELATION_ID || 'none';
|
||||
const attemptId = diagnostics.attemptId || process.env.SCHEDULER_LOCK_ATTEMPT_ID || '1';
|
||||
|
||||
const pool = poolFactory();
|
||||
const publishTimeoutMs = getPublishTimeoutMsFn();
|
||||
const minSuccesses = getMinSuccessfulRelayPublishesFn();
|
||||
@@ -423,11 +469,32 @@ export async function publishLock(relays, event, deps = {}) {
|
||||
try {
|
||||
maybeLogHealthSnapshot(allRelays, healthConfig, healthLogger, 'publish:periodic');
|
||||
let lastAttemptState = null;
|
||||
const retryTimeline = [];
|
||||
const overallStartedAt = Date.now();
|
||||
for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
|
||||
const startedAtMs = Date.now();
|
||||
lastAttemptState = await publishOnce();
|
||||
const elapsedMs = Date.now() - startedAtMs;
|
||||
retryTimeline.push({
|
||||
publishAttempt: attemptNumber,
|
||||
successCount: lastAttemptState.successCount,
|
||||
relayAttemptedCount: lastAttemptState.attempted.size,
|
||||
elapsedMs,
|
||||
});
|
||||
|
||||
if (lastAttemptState.successCount >= minSuccesses) {
|
||||
telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_quorum_met',
|
||||
correlationId,
|
||||
attemptId,
|
||||
publishAttempt: attemptNumber,
|
||||
successCount: lastAttemptState.successCount,
|
||||
relayAttemptedCount: lastAttemptState.attempted.size,
|
||||
requiredSuccesses: minSuccesses,
|
||||
timeoutMs: publishTimeoutMs,
|
||||
retryTimeline,
|
||||
totalElapsedMs: Date.now() - overallStartedAt,
|
||||
}));
|
||||
console.error(
|
||||
` Published to ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} relays `
|
||||
+ `(required=${minSuccesses}, timeout=${publishTimeoutMs}ms)`,
|
||||
@@ -443,13 +510,14 @@ export async function publishLock(relays, event, deps = {}) {
|
||||
break;
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - startedAtMs;
|
||||
const nextDelayMs = calculateBackoffDelayMs(attemptNumber, retryBaseDelayMs, retryCapDelayMs, randomFn);
|
||||
for (const failure of lastAttemptState.failures) {
|
||||
if (!isTransientPublishCategory(failure.category)) continue;
|
||||
telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_retry',
|
||||
attempt: attemptNumber,
|
||||
correlationId,
|
||||
attemptId,
|
||||
publishAttempt: attemptNumber,
|
||||
relayUrl: failure.relay,
|
||||
errorCategory: failure.category,
|
||||
elapsedMs,
|
||||
@@ -460,13 +528,42 @@ export async function publishLock(relays, event, deps = {}) {
|
||||
await sleepFn(nextDelayMs);
|
||||
}
|
||||
|
||||
const reasonDistribution = {};
|
||||
const failureLines = lastAttemptState.failures
|
||||
.map((result) => `${result.relay} (${result.phase}, category=${result.category}): ${result.message}`);
|
||||
.map((result) => {
|
||||
reasonDistribution[result.category] = (reasonDistribution[result.category] || 0) + 1;
|
||||
telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_failure',
|
||||
correlationId,
|
||||
attemptId,
|
||||
relayUrl: result.relay,
|
||||
phase: result.phase,
|
||||
reason: result.category,
|
||||
message: result.message,
|
||||
}));
|
||||
return `${result.relay} (${result.phase}, reason=${result.category}): ${result.message}`;
|
||||
});
|
||||
|
||||
telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_quorum_failed',
|
||||
correlationId,
|
||||
attemptId,
|
||||
successCount: lastAttemptState.successCount,
|
||||
relayAttemptedCount: lastAttemptState.attempted.size,
|
||||
requiredSuccesses: minSuccesses,
|
||||
timeoutMs: publishTimeoutMs,
|
||||
attempts: maxAttempts,
|
||||
reasonDistribution,
|
||||
retryTimeline,
|
||||
totalElapsedMs: Date.now() - overallStartedAt,
|
||||
}));
|
||||
|
||||
maybeLogHealthSnapshot(allRelays, healthConfig, healthLogger, 'publish:failure', true);
|
||||
throw new Error(
|
||||
`Failed relay publish quorum in publish phase: ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} successful `
|
||||
+ `(required=${minSuccesses}, timeout=${publishTimeoutMs}ms, attempts=${maxAttempts})\n ${failureLines.join('\n ')}`,
|
||||
+ `(required=${minSuccesses}, timeout=${publishTimeoutMs}ms, attempts=${maxAttempts}, attempt_id=${attemptId}, correlation_id=${correlationId}, total_retry_timeline_ms=${Date.now() - overallStartedAt})\n`
|
||||
+ ` retry timeline: ${retryTimeline.map((item) => `#${item.publishAttempt}:${item.elapsedMs}ms`).join(', ')}\n`
|
||||
+ ` ${failureLines.join('\n ')}`,
|
||||
);
|
||||
} finally {
|
||||
pool.close(allRelays);
|
||||
|
||||
+77
-15
@@ -109,30 +109,47 @@ describe('lock-ops', () => {
|
||||
telemetry.push(JSON.parse(line));
|
||||
},
|
||||
healthLogger: () => {},
|
||||
diagnostics: {
|
||||
attemptId: 'attempt-1',
|
||||
correlationId: 'corr-1',
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
assert.strictEqual(publishCalls, 2);
|
||||
assert.deepStrictEqual(sleepCalls, [250]);
|
||||
assert.strictEqual(telemetry.length, 2);
|
||||
assert.deepStrictEqual(telemetry[0], {
|
||||
|
||||
const retryEvents = telemetry.filter((entry) => entry.event === 'lock_publish_retry');
|
||||
assert.strictEqual(retryEvents.length, 2);
|
||||
assert.deepStrictEqual(retryEvents[0], {
|
||||
event: 'lock_publish_retry',
|
||||
attempt: 1,
|
||||
correlationId: 'corr-1',
|
||||
attemptId: 'attempt-1',
|
||||
publishAttempt: 1,
|
||||
relayUrl: 'wss://primary-a',
|
||||
errorCategory: 'connection_reset',
|
||||
elapsedMs: telemetry[0].elapsedMs,
|
||||
elapsedMs: retryEvents[0].elapsedMs,
|
||||
nextDelayMs: 250,
|
||||
});
|
||||
assert.deepStrictEqual(telemetry[1], {
|
||||
assert.deepStrictEqual(retryEvents[1], {
|
||||
event: 'lock_publish_retry',
|
||||
attempt: 1,
|
||||
correlationId: 'corr-1',
|
||||
attemptId: 'attempt-1',
|
||||
publishAttempt: 1,
|
||||
relayUrl: 'wss://primary-b',
|
||||
errorCategory: 'connection_reset',
|
||||
elapsedMs: telemetry[1].elapsedMs,
|
||||
elapsedMs: retryEvents[1].elapsedMs,
|
||||
nextDelayMs: 250,
|
||||
});
|
||||
assert.ok(Number.isInteger(telemetry[0].elapsedMs));
|
||||
assert.ok(Number.isInteger(telemetry[1].elapsedMs));
|
||||
assert.ok(Number.isInteger(retryEvents[0].elapsedMs));
|
||||
assert.ok(Number.isInteger(retryEvents[1].elapsedMs));
|
||||
|
||||
const metEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_met');
|
||||
assert.ok(metEvent);
|
||||
assert.equal(metEvent.correlationId, 'corr-1');
|
||||
assert.equal(metEvent.attemptId, 'attempt-1');
|
||||
assert.equal(metEvent.publishAttempt, 2);
|
||||
assert.ok(Array.isArray(metEvent.retryTimeline));
|
||||
});
|
||||
|
||||
it('fails after retry budget is exhausted for persistent transient failures', async () => {
|
||||
@@ -162,7 +179,7 @@ describe('lock-ops', () => {
|
||||
telemetryLogger: () => {},
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/Failed relay publish quorum in publish phase: 0\/1 successful \(required=1, timeout=200ms, attempts=3\)/,
|
||||
/Failed relay publish quorum in publish phase: 0\/1 successful \(required=1, timeout=200ms, attempts=3,/,
|
||||
);
|
||||
|
||||
assert.strictEqual(publishCalls, 3);
|
||||
@@ -190,17 +207,63 @@ describe('lock-ops', () => {
|
||||
sleepFn: async () => {
|
||||
throw new Error('should not sleep for permanent failures');
|
||||
},
|
||||
telemetryLogger: () => {
|
||||
throw new Error('should not emit retry telemetry for permanent failures');
|
||||
},
|
||||
telemetryLogger: () => {},
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/Failed relay publish quorum in publish phase: 0\/1 successful \(required=1, timeout=200ms, attempts=4\)/,
|
||||
/Failed relay publish quorum in publish phase: 0\/1 successful \(required=1, timeout=200ms, attempts=4,/,
|
||||
);
|
||||
|
||||
assert.strictEqual(publishCalls, 1);
|
||||
});
|
||||
|
||||
it('includes per-relay reason categories and retry metadata in failure diagnostics', async () => {
|
||||
const telemetry = [];
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => {
|
||||
if (relay.includes('dns')) return Promise.reject(new Error('getaddrinfo ENOTFOUND relay.example'));
|
||||
if (relay.includes('tcp')) return Promise.reject(new Error('connect ETIMEDOUT 1.2.3.4:443'));
|
||||
if (relay.includes('tls')) return Promise.reject(new Error('TLS handshake failure'));
|
||||
if (relay.includes('ws')) return Promise.reject(new Error('websocket: unexpected server response'));
|
||||
return Promise.reject(new Error('[publish:primary] Publish timed out after 200ms'));
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://dns', 'wss://tcp', 'wss://tls', 'wss://ws', 'relay-timeout'], { id: 'evt-diag' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
telemetryLogger: (line) => telemetry.push(JSON.parse(line)),
|
||||
healthLogger: () => {},
|
||||
diagnostics: { attemptId: 'attempt-42', correlationId: 'corr-42' },
|
||||
}),
|
||||
);
|
||||
|
||||
const summaryEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(summaryEvent);
|
||||
const failureEvents = telemetry.filter((entry) => entry.event === 'lock_publish_failure');
|
||||
const failureText = failureEvents.map((entry) => `${entry.relayUrl}:${entry.reason}`).join('\n');
|
||||
|
||||
assert.match(failureText, /wss:\/\/dns:dns_resolution/);
|
||||
assert.match(failureText, /wss:\/\/tcp:tcp_connect_timeout/);
|
||||
assert.match(failureText, /wss:\/\/tls:tls_handshake/);
|
||||
assert.match(failureText, /wss:\/\/ws:websocket_open_failure/);
|
||||
assert.match(failureText, /relay-timeout:publish_timeout/);
|
||||
|
||||
|
||||
assert.deepStrictEqual(summaryEvent.reasonDistribution, {
|
||||
dns_resolution: 1,
|
||||
tcp_connect_timeout: 1,
|
||||
tls_handshake: 1,
|
||||
websocket_open_failure: 1,
|
||||
publish_timeout: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns success when mixed relay outcomes still satisfy quorum', async () => {
|
||||
let publishCalls = 0;
|
||||
const mockPool = {
|
||||
@@ -269,7 +332,6 @@ describe('lock-ops', () => {
|
||||
|
||||
const event = { id: 'evt-health' };
|
||||
|
||||
// Warm up failure history so bad relay enters quarantine.
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await publishLock(['wss://bad', 'wss://healthy'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
|
||||
@@ -193,6 +193,9 @@ test('records backend failure metadata when lock command exits with code 2', { c
|
||||
assert.match(failedBody, /lock_command: 'AGENT_PLATFORM=codex npm run lock:lock -- --agent agent-a --cadence daily'/);
|
||||
assert.match(failedBody, /lock_stderr_excerpt: 'publish failed to all relays token=\[REDACTED\] SECRET_KEY=\[REDACTED\]'/);
|
||||
assert.match(failedBody, /lock_stdout_excerpt: 'websocket: connection refused'/);
|
||||
assert.match(failedBody, /lock_failure_reason_distribution: '\{\}'/);
|
||||
assert.match(failedBody, /lock_attempt_id: '3'/);
|
||||
assert.match(failedBody, /lock_correlation_id: 'daily:agent-a:/);
|
||||
assert.match(failedBody, /Retry AGENT_PLATFORM=codex npm run lock:lock -- --agent agent-a --cadence daily/);
|
||||
assert.match(failedBody, /platform: 'codex'/);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user