mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-25 19:03:14 +01:00
LivenessService::tick() runs every second in MAIN's signals daemon (cron:cluster is the minute fallback) and judges the active nodes whose TELEMETRY flow is on by their silence: ok, suspect after 10 s, offline after cluster_offline_after_sec (30 s). The result, in tmp/cluster/health.json, is read through Core\Cluster\ClusterHealth: ServerRepository::getAll() takes server_online from it for those nodes (instead of the 90 s last_check_ago rule) and getCapacity() doubles a suspect node's weight. Each transition rewrites the servers cache and is audited. When over half of the judged nodes (at least two) fall silent together, the fleet silence guard holds every node at its last state instead of marking any offline, audits it and alerts on the Cluster Nodes page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
115 lines
5.2 KiB
PHP
115 lines
5.2 KiB
PHP
<?php
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use XcVm\Core\Cluster\ClusterHealth;
|
|
use XcVm\Domain\Cluster\ClusterClock;
|
|
use XcVm\Domain\Cluster\LivenessService;
|
|
use XcVm\Domain\Cluster\NodeRegistry;
|
|
use XcVm\Infrastructure\Database\DatabaseFactory;
|
|
|
|
/**
|
|
* Phase 3 liveness: nodes whose agent reports telemetry are judged by their
|
|
* silence every second (suspect after 10 s, offline after 30 s), published
|
|
* for routing, and held by the fleet silence guard when most go quiet at once.
|
|
*/
|
|
final class ClusterLivenessTest extends TestCase {
|
|
private TestDb $rDb;
|
|
|
|
private int $rT0 = 1800000000000;
|
|
|
|
private string $rHealth;
|
|
|
|
protected function setUp(): void {
|
|
$this->rDb = new TestDb();
|
|
$this->rDb->exec((string) preg_replace(['/^--.*$/m', '/,\s*(UNIQUE )?KEY `\w+` \([^)]*\)/', '/ unsigned| COLLATE \w+/', '/\) ENGINE=[^;]*;/'], ['', '', '', ');'], (string) file_get_contents(dirname(__DIR__, 2) . '/src/migrations/database/up/029_create_cluster_nodes.sql')));
|
|
$this->rDb->exec('CREATE TABLE `cluster_audit` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `time` int, `server_id` int, `actor` varchar(64), `event` varchar(64), `detail` text, `ip` varchar(64))');
|
|
DatabaseFactory::set($this->rDb);
|
|
ClusterClock::fix($this->rT0);
|
|
\XcVm\Domain\Cluster\ClusterMeta::set('ready_at', (string) ($this->rT0 - 600000));
|
|
foreach ([5, 6, 7] as $rID) {
|
|
NodeRegistry::startEnrolment($rID, sprintf('00000000-0000-4000-a000-%012d', $rID), str_repeat("\1", 32), str_repeat("\2", 32), 1);
|
|
NodeRegistry::update($rID, ['state' => 'active', 'flows' => NodeRegistry::FLOW_TELEMETRY, 'last_seen_at' => $this->rT0]);
|
|
}
|
|
$this->rHealth = sys_get_temp_dir() . '/health_' . bin2hex(random_bytes(4)) . '.json';
|
|
ClusterHealth::usePath($this->rHealth);
|
|
}
|
|
|
|
protected function tearDown(): void {
|
|
ClusterClock::fix(null);
|
|
DatabaseFactory::reset();
|
|
@unlink($this->rHealth);
|
|
ClusterHealth::usePath(null);
|
|
}
|
|
|
|
private function at(int $rMs): array {
|
|
ClusterClock::fix($this->rT0 + $rMs);
|
|
return LivenessService::tick(30);
|
|
}
|
|
|
|
private function audit(string $rEvent): int {
|
|
$this->rDb->query('SELECT COUNT(*) AS `n` FROM `cluster_audit` WHERE `event` = ?', $rEvent);
|
|
return (int) $this->rDb->get_row()['n'];
|
|
}
|
|
|
|
public function testSilenceMakesANodeSuspectThenOffline(): void {
|
|
$this->assertSame([5 => [null, 'ok'], 6 => [null, 'ok'], 7 => [null, 'ok']], $this->at(0));
|
|
$this->assertSame([], $this->at(1000), 'no transition, no rewrite');
|
|
|
|
// 5 and 6 keep heartbeating; 7 falls silent.
|
|
$rBeat = fn(int $rMs) => $this->rDb->query('UPDATE `cluster_nodes` SET `last_seen_at` = ? WHERE `server_id` IN (5, 6)', $this->rT0 + $rMs);
|
|
$rBeat(10000);
|
|
$this->assertSame([], $this->at(10000));
|
|
$rBeat(11000);
|
|
$this->assertSame([7 => ['ok', 'suspect']], $this->at(11000));
|
|
$this->assertSame('suspect', ClusterHealth::state(7));
|
|
$this->assertSame(2.0, ClusterHealth::weight(7), 'a suspect node weighs double');
|
|
$rBeat(31000);
|
|
$this->assertSame([7 => ['suspect', 'offline']], $this->at(31000));
|
|
$this->assertSame(1.0, ClusterHealth::weight(7));
|
|
|
|
// It speaks again.
|
|
$this->rDb->query('UPDATE `cluster_nodes` SET `last_seen_at` = ? WHERE `server_id` = 7', $this->rT0 + 32000);
|
|
$rBeat(32000);
|
|
$this->assertSame([7 => ['offline', 'ok']], $this->at(32000));
|
|
$this->assertSame(3, $this->audit('node.health'), 'ok→suspect, suspect→offline, offline→ok; the first publication is not a transition');
|
|
}
|
|
|
|
public function testFleetSilenceHoldsNodesInsteadOfMarkingThemOffline(): void {
|
|
$this->at(0);
|
|
// 6 and 7 of 3 fall silent together: MAIN suspects itself.
|
|
$this->rDb->query('UPDATE `cluster_nodes` SET `last_seen_at` = ? WHERE `server_id` = 5', $this->rT0 + 40000);
|
|
$this->assertSame([], $this->at(40000), 'held at ok, not marked offline');
|
|
$this->assertTrue(ClusterHealth::read()['guard']);
|
|
$this->assertSame(1, $this->audit('cluster.fleet_silence'));
|
|
$this->assertSame('ok', ClusterHealth::state(7));
|
|
|
|
// They come back: the guard clears.
|
|
$this->rDb->query('UPDATE `cluster_nodes` SET `last_seen_at` = ? WHERE `server_id` IN (5, 6, 7)', $this->rT0 + 45000);
|
|
$this->at(45000);
|
|
$this->assertFalse(ClusterHealth::read()['guard']);
|
|
$this->assertSame(1, $this->audit('cluster.fleet_silence_clear'));
|
|
}
|
|
|
|
public function testOnlyNodesWithTheFlowAreJudged(): void {
|
|
NodeRegistry::update(6, ['flows' => 0]);
|
|
NodeRegistry::update(7, ['state' => 'quarantined']);
|
|
$this->assertSame([5 => [null, 'ok']], $this->at(0));
|
|
$this->assertNull(ClusterHealth::state(6), 'legacy rule for a node without the flow');
|
|
|
|
// Turning the flow off drops the node from the published states.
|
|
NodeRegistry::update(5, ['flows' => 0]);
|
|
$this->assertSame([5 => ['ok', null]], $this->at(1000));
|
|
$this->assertNull(ClusterHealth::state(5));
|
|
}
|
|
|
|
public function testANodeNeverHeardIsOfflineOnceTheLoopHasWaited(): void {
|
|
$this->rDb->query('UPDATE `cluster_nodes` SET `last_seen_at` = NULL WHERE `server_id` = 5');
|
|
$this->at(0);
|
|
$this->assertSame('offline', ClusterHealth::state(5));
|
|
$this->rDb->query("UPDATE `cluster_meta` SET `value` = ? WHERE `name` = 'ready_at'", (string) ($this->rT0 + 5000));
|
|
$this->rDb->query("UPDATE `cluster_nodes` SET `last_seen_at` = ? WHERE `server_id` IN (6, 7)", $this->rT0 + 10000);
|
|
$this->at(10000);
|
|
$this->assertSame('suspect', ClusterHealth::state(5), 'MAIN just restarted: not yet');
|
|
}
|
|
}
|