Skip to content

Commit a0badea

Browse files
Surface backend admission severity rollup on operator metrics
OperatorMetrics::snapshot()['backend']['severity'] now exposes the worst per-issue verdict (error > warning > info > ok), matching the rollout-safety contract row that cli, server, and waterline read. BackendCapabilities, OperatorMetrics, and rollout-safety doc tests pin each rung of the rollup so future drift trips a regression.
1 parent 64033f7 commit a0badea

4 files changed

Lines changed: 146 additions & 0 deletions

File tree

src/V2/Support/BackendCapabilities.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public static function snapshot(
4444
'cache' => $cache,
4545
'codec' => $codec,
4646
'structural_limits' => $limits,
47+
'severity' => self::severityRollup($issues),
4748
'issues' => $issues,
4849
];
4950
}
@@ -346,6 +347,41 @@ private static function hasErrors(mixed $issues): bool
346347
return false;
347348
}
348349

350+
/**
351+
* Rolls up the worst per-issue severity into a single admission verdict
352+
* the rollout-safety contract pins on `OperatorMetrics::snapshot()['backend']['severity']`.
353+
* Order: `error` (boot must fail in `fail`/`throw` mode) > `warning`
354+
* (boot may continue with a logged gap) > `info` (operator-visible note,
355+
* no admission concern) > `ok` (no issues at all).
356+
*
357+
* @param array<int, mixed> $issues
358+
*/
359+
private static function severityRollup(array $issues): string
360+
{
361+
$rank = 0;
362+
363+
foreach ($issues as $issue) {
364+
if (! is_array($issue)) {
365+
continue;
366+
}
367+
368+
$severity = $issue['severity'] ?? null;
369+
$rank = max($rank, match ($severity) {
370+
'error' => 3,
371+
'warning' => 2,
372+
'info' => 1,
373+
default => 0,
374+
});
375+
}
376+
377+
return match ($rank) {
378+
3 => 'error',
379+
2 => 'warning',
380+
1 => 'info',
381+
default => 'ok',
382+
};
383+
}
384+
349385
/**
350386
* @return array{component: string, severity: string, code: string, message: string}
351387
*/

tests/Feature/V2/V2OperatorMetricsTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ public function testSnapshotSummarizesDurableBacklogRepairCompatibilityAndWorker
336336
$this->assertSame('redis', $snapshot['backend']['queue']['driver']);
337337
$this->assertSame('array', $snapshot['backend']['cache']['store']);
338338
$this->assertSame([], $snapshot['backend']['issues']);
339+
$this->assertSame('ok', $snapshot['backend']['severity']);
339340
$this->assertSame(9, $snapshot['update_wait']['completion_timeout_seconds']);
340341
$this->assertSame(25, $snapshot['update_wait']['poll_interval_milliseconds']);
341342
$this->assertSame(7, $snapshot['repair_policy']['redispatch_after_seconds']);
@@ -1728,6 +1729,20 @@ public function testSnapshotReportsMissingRunSummaryProjectionAgeAsZeroWhenNoRun
17281729
$this->assertSame(0, $snapshot['projections']['run_summaries']['max_missing_run_age_ms']);
17291730
}
17301731

1732+
public function testSnapshotSurfacesBackendSeverityRollupAsErrorWhenAdmissionIsUnsupported(): void
1733+
{
1734+
config()->set('queue.default', 'sync');
1735+
config()
1736+
->set('queue.connections.sync.driver', 'sync');
1737+
1738+
$snapshot = OperatorMetrics::snapshot();
1739+
1740+
$this->assertFalse($snapshot['backend']['supported']);
1741+
$this->assertSame('error', $snapshot['backend']['severity']);
1742+
$this->assertNotEmpty($snapshot['backend']['issues']);
1743+
$this->assertContains('error', array_column($snapshot['backend']['issues'], 'severity'));
1744+
}
1745+
17311746
public function testSnapshotReportsInWorkerMatchingRoleShapeByDefault(): void
17321747
{
17331748
config()->set('workflows.v2.matching_role.queue_wake_enabled', true);

tests/Unit/V2/BackendCapabilitiesTest.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,4 +355,88 @@ public function testUnknownCodecDiagnosticIncludesMigrationGuidance(): void
355355
// the unsupported value is being ignored.
356356
$this->assertStringContainsString('falls back to "avro"', $codecIssue['message']);
357357
}
358+
359+
public function testSnapshotIncludesSeverityRollupOfOkWhenAdmissionIsClean(): void
360+
{
361+
$originalDatabaseDefault = config('database.default');
362+
363+
try {
364+
config()->set('database.default', 'pgsql');
365+
config()
366+
->set('database.connections.pgsql.driver', 'pgsql');
367+
config()
368+
->set('queue.default', 'redis');
369+
config()
370+
->set('queue.connections.redis.driver', 'redis');
371+
config()
372+
->set('cache.default', 'array');
373+
config()
374+
->set('cache.stores.array.driver', 'array');
375+
376+
$snapshot = BackendCapabilities::snapshot();
377+
378+
$this->assertSame([], $snapshot['issues']);
379+
$this->assertSame('ok', $snapshot['severity']);
380+
$this->assertTrue($snapshot['supported']);
381+
} finally {
382+
config()->set('database.default', $originalDatabaseDefault);
383+
}
384+
}
385+
386+
public function testSnapshotSeverityRollupReportsWarningWhenOnlyWarningIssuesPresent(): void
387+
{
388+
// Establish a clean baseline so the only issue surfaced by the
389+
// snapshot is the legacy-codec warning we are pinning here. The
390+
// default test environment uses the sync queue driver, which would
391+
// otherwise add an error-severity issue and shadow the rollup we
392+
// want to assert.
393+
$originalDatabaseDefault = config('database.default');
394+
395+
try {
396+
config()->set('database.default', 'pgsql');
397+
config()
398+
->set('database.connections.pgsql.driver', 'pgsql');
399+
config()
400+
->set('queue.default', 'redis');
401+
config()
402+
->set('queue.connections.redis.driver', 'redis');
403+
config()
404+
->set('cache.default', 'array');
405+
config()
406+
->set('cache.stores.array.driver', 'array');
407+
config()
408+
->set('workflows.serializer', \Workflow\Serializers\Y::class);
409+
410+
$snapshot = BackendCapabilities::snapshot();
411+
412+
$codecIssue = collect($snapshot['issues'])->firstWhere('code', 'codec_legacy_php_only');
413+
$this->assertNotNull($codecIssue);
414+
$this->assertSame('warning', $codecIssue['severity']);
415+
416+
$errorIssue = collect($snapshot['issues'])->firstWhere('severity', 'error');
417+
$this->assertNull(
418+
$errorIssue,
419+
'This test pins the warning-only path; an error-severity issue here means the codec setup unexpectedly broke another component.',
420+
);
421+
422+
$this->assertSame('warning', $snapshot['severity']);
423+
} finally {
424+
config()->set('database.default', $originalDatabaseDefault);
425+
}
426+
}
427+
428+
public function testSnapshotSeverityRollupReportsErrorWhenAnyIssueIsErrorSeverity(): void
429+
{
430+
config()->set('queue.default', 'sync');
431+
config()
432+
->set('queue.connections.sync.driver', 'sync');
433+
434+
$snapshot = BackendCapabilities::snapshot();
435+
436+
$errorIssue = collect($snapshot['issues'])->firstWhere('severity', 'error');
437+
$this->assertNotNull($errorIssue);
438+
439+
$this->assertSame('error', $snapshot['severity']);
440+
$this->assertFalse($snapshot['supported']);
441+
}
358442
}

tests/Unit/V2/RolloutSafetyDocumentationTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,17 @@ public function testContractDocumentFreezesRunSummaryProjectionMissingRunAgeRow(
422422
);
423423
}
424424

425+
public function testContractDocumentFreezesBackendSeverityRollupRow(): void
426+
{
427+
$contents = $this->documentContents();
428+
429+
$this->assertMatchesRegularExpression(
430+
'/\|\s*`backend`\s*\|[^|]*`issues`[^|]*`severity`[^|]*\|[^|]*admission check roll-up from `BackendCapabilities`/',
431+
$contents,
432+
'Rollout safety contract must pin the backend admission roll-up row so operators can read the worst per-issue severity from a single key on OperatorMetrics::snapshot()[\'backend\'] without scanning the issues list themselves.',
433+
);
434+
}
435+
425436
public function testContractDocumentFreezesHealthCheckNames(): void
426437
{
427438
$contents = $this->documentContents();

0 commit comments

Comments
 (0)