Files
XC_VM/tests/Unit/ConnectionTouchTest.php
T
obscuremindandClaude Opus 5.5 2151440ea2 test: make the "store is down" Redis client unreachable on phpredis 6
Three tests stand a bare `new \Redis()` in for a store that cannot be
read. That holds on phpredis 5, where every command of such an object
throws. Since phpredis 6 the object dials 127.0.0.1:6379 on its first
command, so the tests passed only while nothing answered there. The
PHPUnit job installs a Redis on that port, and ConnectionTouchTest
failed once on a pull request that does not touch the cluster code
(#363).

RedisServer::unreachable() returns a client whose connect() to a
missing socket failed: it dials nothing on either version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AneTzAHPPkYWPQjwyZ2td9
2026-10-11 17:26:07 +00:00

318 lines
16 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Core\Config\SettingsManager;
use XcVm\Domain\Cluster\ClusterBus;
use XcVm\Domain\Cluster\ClusterClock;
use XcVm\Domain\Cluster\ConnectionIngest;
use XcVm\Domain\Cluster\EventIngest;
use XcVm\Domain\Cluster\NodeRegistry;
use XcVm\Infrastructure\Database\DatabaseFactory;
use XcVm\Infrastructure\Redis\RedisManager;
use XcVm\Tests\Support\RedisServer;
/**
* Phase 6, the P2 lane and `conn.touch` (plan, sections 7 and 8): when a
* node's viewers last asked for their playlist, the newest value per viewer
* by the event's time, with no cursor and never a gap. A node whose agent
* ends its own idle HLS viewers keeps it on the cluster bus only; without
* the bus, or for a node that does not reap, it goes into MAIN's store.
*/
final class ConnectionTouchTest extends TestCase {
private const FLOWS = NodeRegistry::FLOW_CONNECTIONS | NodeRegistry::FLOW_COMMANDS | NodeRegistry::FLOW_STREAMS;
private const T = 1800000000000;
private static ?string $rRedisDir = null;
private static int $rRedisPort = 0;
/** @var resource|null */
private static $rRedisProc = null;
private TestDb $rDb;
public static function setUpBeforeClass(): void {
if (!class_exists(\Redis::class) || !function_exists('igbinary_serialize') || trim((string) shell_exec('command -v redis-server')) === '') {
return;
}
self::$rRedisDir = sys_get_temp_dir() . '/xcvm-touch-' . bin2hex(random_bytes(4));
mkdir(self::$rRedisDir);
// One server: MAIN's store on TCP, the cluster bus on its unix socket.
$rSocket = self::$rRedisDir . '/cluster.sock';
[self::$rRedisProc, self::$rRedisPort] = RedisServer::start(self::$rRedisDir, ['--unixsocket', $rSocket, '--unixsocketperm', '700'], $rSocket) ?? [null, 0];
}
public static function tearDownAfterClass(): void {
if (self::$rRedisProc !== null) {
proc_terminate(self::$rRedisProc);
proc_close(self::$rRedisProc);
self::$rRedisProc = null;
}
if (self::$rRedisDir !== null) {
exec('rm -rf ' . escapeshellarg(self::$rRedisDir));
}
}
protected function setUp(): void {
$this->rDb = new TestDb();
$this->rDb->exec((string) (string) file_get_contents(MAIN_HOME . 'migrations/database/up/029_create_cluster_nodes.sql'));
$this->rDb->exec('ALTER TABLE `cluster_nodes` ADD COLUMN `features` varchar(255) DEFAULT NULL');
$rSql = (string) file_get_contents(MAIN_HOME . 'bin/install/database.sql');
preg_match('/CREATE TABLE IF NOT EXISTS `lines_live` \(.*?\) ENGINE=[^;]*;/s', $rSql, $rM);
$this->rDb->exec((string) $rM[0]);
DatabaseFactory::set($this->rDb);
ClusterClock::fix(self::T);
NodeRegistry::startEnrolment(5, '11111111-1111-4111-a111-111111111111', random_bytes(32), random_bytes(32), 1);
NodeRegistry::update(5, ['state' => 'active', 'mode' => 1, 'flows' => self::FLOWS]);
SettingsManager::set(['redis_handler' => 0]);
ClusterBus::useSocket(sys_get_temp_dir() . '/no-such-bus-' . bin2hex(random_bytes(4)) . '.sock');
}
protected function tearDown(): void {
SettingsManager::set([]);
ClusterClock::fix(null);
ClusterBus::useSocket(null);
DatabaseFactory::reset();
(new \ReflectionProperty(RedisManager::class, 'instance'))->setValue(null, null);
}
private function node(): array {
return NodeRegistry::byServer(5);
}
private function touch(string $rUUID, int $rT, mixed $rLastRead): array {
return ['type' => 'conn.touch', 't' => $rT, 'd' => ['uuid' => $rUUID, 'hls_last_read' => $rLastRead]];
}
private function live(string $rUUID, int $rServerID, int $rLastRead, int $rEnded = 0): void {
$this->rDb->query('INSERT INTO `lines_live` (`uuid`, `server_id`, `user_id`, `stream_id`, `container`, `hls_last_read`, `hls_end`) VALUES (?, ?, 7, 100, ?, ?, ?)', $rUUID, $rServerID, 'hls', $rLastRead, $rEnded);
}
/** @return array{0: int, 1: int} hls_last_read, hls_end of a lines_live row */
private function row(string $rUUID): array {
$this->rDb->query('SELECT `hls_last_read`, `hls_end` FROM `lines_live` WHERE `uuid` = ?', $rUUID);
$rRow = $this->rDb->get_row();
return [(int) $rRow['hls_last_read'], (int) $rRow['hls_end']];
}
private function bus(): \Redis {
if (self::$rRedisProc === null) {
$this->markTestSkipped('redis-server, phpredis or igbinary not available');
}
ClusterBus::useSocket(self::$rRedisDir . '/cluster.sock');
$rRedis = ClusterBus::client();
$this->assertInstanceOf(\Redis::class, $rRedis);
$rRedis->flushAll();
return $rRedis;
}
private function store(): \Redis {
if (self::$rRedisProc === null) {
$this->markTestSkipped('redis-server, phpredis or igbinary not available');
}
$rRedis = new \Redis();
$rRedis->connect('127.0.0.1', self::$rRedisPort, 2.0, null, 0, 2.0);
$rRedis->flushAll();
(new \ReflectionProperty(RedisManager::class, 'instance'))->setValue(null, $rRedis);
(new \ReflectionProperty(RedisManager::class, 'lastPingCheck'))->setValue(null, time());
SettingsManager::set(['redis_handler' => 1]);
return $rRedis;
}
// ── The lane ─────────────────────────────────────────────────────────
public function testP2HasNoCursorAndNeverAGap(): void {
$this->live('aaaa', 5, 1000);
foreach ([0, 1, 999, 1] as $i => $rFirst) {
$rOut = EventIngest::ingest($this->node(), 'p2', $rFirst, [$this->touch('aaaa', self::T + $i, 1001 + $i)]);
$this->assertSame(['ok' => true, 'useq' => 0, 'applied' => 1, 'dropped' => 0], $rOut, 'whatever number it comes with');
}
$this->assertSame([1004, 0], $this->row('aaaa'));
$this->assertSame([0, 0], [(int) $this->node()['useq_p0'], (int) $this->node()['useq_p1']], 'no cursor moves');
$this->assertSame(['ok' => true, 'useq' => 0, 'applied' => 0, 'dropped' => 0], EventIngest::ingest($this->node(), 'p2', 0, []));
}
public function testWithinABatchTheLatestTouchPerViewerWins(): void {
$this->live('aaaa', 5, 100);
$this->live('bbbb', 5, 100);
$rOut = EventIngest::ingest($this->node(), 'p2', 0, [
$this->touch('aaaa', self::T + 2000, 150),
$this->touch('aaaa', self::T + 1000, 900), // an older event, sent later
$this->touch('aaaa', self::T + 2000, 160), // the same time: the later one
$this->touch('bbbb', self::T, 120),
]);
$this->assertSame([4, 0], [$rOut['applied'], $rOut['dropped']]);
$this->assertSame([160, 0], $this->row('aaaa'), 'by the event\'s time, not by the value');
$this->assertSame([120, 0], $this->row('bbbb'));
}
public function testMalformedAndMisplacedTouchesAreDropped(): void {
$this->live('aaaa', 5, 100);
$rOut = EventIngest::ingest($this->node(), 'p2', 0, [
['type' => 'conn.touch', 'd' => ['uuid' => 'aaaa', 'hls_last_read' => 150]], // no time
['type' => 'conn.touch', 't' => '1', 'd' => ['uuid' => 'aaaa', 'hls_last_read' => 150]], // a string time
$this->touch('bad uuid;', self::T, 150),
$this->touch('aaaa', self::T, '150'),
$this->touch('aaaa', self::T, -1),
['type' => 'conn.upsert', 't' => self::T, 'd' => ['record' => ['uuid' => 'aaaa']]], // a P0 type
['type' => 'stream.progress', 't' => self::T, 'd' => []],
'nope',
]);
$this->assertSame([0, 8], [$rOut['applied'], $rOut['dropped']]);
$this->assertSame(0, EventIngest::ingest($this->node(), 'p1', 1, [$this->touch('aaaa', self::T, 150)])['applied'], 'P2 only');
$this->assertSame(0, EventIngest::ingest($this->node(), 'p0', 1, [$this->touch('aaaa', self::T, 150)])['applied']);
NodeRegistry::update(5, ['flows' => NodeRegistry::FLOW_COMMANDS | NodeRegistry::FLOW_STREAMS]);
$this->assertSame(1, EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T, 150)])['dropped'], 'CONNECTIONS off');
$this->assertSame([100, 0], $this->row('aaaa'));
}
// ── Without the bus: MAIN's store ────────────────────────────────────
public function testWithoutTheBusTheNodesOwnRowsAreTouchedInTheTable(): void {
$this->live('aaaa', 5, 100);
$this->live('cccc', 6, 100); // node 6's
$this->live('eeee', 5, 100, 1); // ended
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T, 150), $this->touch('cccc', self::T, 150), $this->touch('eeee', self::T, 150), $this->touch('zzzz', self::T, 150)]);
$this->assertSame([150, 0], $this->row('aaaa'));
$this->assertSame([100, 0], $this->row('cccc'), 'another node\'s viewer');
$this->assertSame([150, 1], $this->row('eeee'), 'a touch never re-opens');
$this->rDb->query('SELECT COUNT(*) AS `n` FROM `lines_live` WHERE `uuid` = ?', 'zzzz');
$this->assertSame(0, (int) $this->rDb->get_row()['n'], 'nor creates');
// The store keeps the latest read: a smaller value never goes back.
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T + 5000, 140)]);
$this->assertSame([150, 0], $this->row('aaaa'));
}
public function testWithoutTheBusOnRedisTheNodesOwnRecordsAreTouched(): void {
$rRedis = $this->store();
$rRec = ['user_id' => 7, 'stream_id' => 100, 'user_ip' => '10.0.0.9', 'container' => 'hls', 'pid' => null, 'date_start' => 1800000000, 'hls_last_read' => 100, 'hls_end' => 0];
$this->assertTrue(ConnectionIngest::upsert(5, ['uuid' => 'aaaa'] + $rRec));
$this->assertTrue(ConnectionIngest::upsert(6, ['uuid' => 'cccc'] + $rRec));
$this->assertTrue(ConnectionIngest::upsert(5, ['uuid' => 'eeee', 'hls_end' => 1] + $rRec));
$rEndedLive = $rRedis->zScore('LIVE', 'eeee');
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T, 150), $this->touch('cccc', self::T, 150), $this->touch('eeee', self::T, 150), $this->touch('zzzz', self::T, 150)]);
$rGet = static fn(string $rUUID): array => igbinary_unserialize($rRedis->get($rUUID));
$this->assertSame([150, 0], [$rGet('aaaa')['hls_last_read'], $rGet('aaaa')['hls_end']]);
$this->assertNotFalse($rRedis->zScore('LIVE', 'aaaa'));
$this->assertSame(100, $rGet('cccc')['hls_last_read'], 'another node\'s viewer');
$this->assertSame([150, 1], [$rGet('eeee')['hls_last_read'], $rGet('eeee')['hls_end']], 'a touch never re-opens');
$this->assertSame($rEndedLive, $rRedis->zScore('LIVE', 'eeee'), 'nor moves it between sets');
$this->assertFalse($rRedis->get('zzzz'), 'nor creates');
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T + 5000, 140)]);
$this->assertSame(150, $rGet('aaaa')['hls_last_read'], 'never back');
}
// ── The bus ──────────────────────────────────────────────────────────
public function testAReapingNodesTouchesStayOnTheBus(): void {
$rBus = $this->bus();
NodeRegistry::update(5, ['features' => 'fanout_events,hls_reaper']);
$this->live('aaaa', 5, 100);
$this->assertSame(1, EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T + 2000, 150)])['applied']);
$this->assertSame([100, 0], $this->row('aaaa'), 'MAIN\'s store is not written');
$this->assertSame(['aaaa' => 150], ClusterBus::lastReads(5, ['aaaa', 'none']));
$rTtl = $rBus->pttl('touch:5:aaaa');
$this->assertGreaterThan(0, $rTtl);
$this->assertLessThanOrEqual(ClusterBus::TOUCH_TTL_MS, $rTtl);
$this->assertSame([], ClusterBus::lastReads(6, ['aaaa']), 'another node\'s key');
// Across batches too, the latest by time wins: a late older event
// changes nothing, a newer one replaces even a larger value.
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T + 1000, 999)]);
$this->assertSame(['aaaa' => 150], ClusterBus::lastReads(5, ['aaaa']));
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T + 3000, 140)]);
$this->assertSame(['aaaa' => 140], ClusterBus::lastReads(5, ['aaaa']));
}
public function testANodeThatDoesNotReapStillTouchesTheStore(): void {
$this->bus();
$this->live('aaaa', 5, 100);
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T, 150)]);
$this->assertSame([150, 0], $this->row('aaaa'), 'MAIN\'s 30 s rule reads it');
$this->assertSame([], ClusterBus::lastReads(5, ['aaaa']));
}
public function testWithTheBusDownAReapingNodeTouchesTheStore(): void {
NodeRegistry::update(5, ['features' => 'hls_reaper']);
$this->live('aaaa', 5, 100);
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T, 150)]);
$this->assertSame([150, 0], $this->row('aaaa'));
$this->assertNull(ClusterBus::lastReads(5, ['aaaa']), 'no bus');
}
public function testOnlyTheViewersTheStoreHoldsForTheNodeReachTheBus(): void {
$rBus = $this->bus();
NodeRegistry::update(5, ['features' => 'hls_reaper']);
$this->live('aaaa', 5, 100);
$this->live('cccc', 6, 100); // node 6's
$rOut = EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T, 150), $this->touch('cccc', self::T, 150), $this->touch('zzzz', self::T, 150)]);
$this->assertSame([3, 0], [$rOut['applied'], $rOut['dropped']], 'taken, as by the store');
$this->assertSame(['aaaa' => 150], ClusterBus::lastReads(5, ['aaaa', 'cccc', 'zzzz']), 'no key for another node\'s viewer, nor for a made-up one');
$this->assertSame(['touch:5:aaaa'], $rBus->keys('touch:*'));
$this->assertSame([100, 0], $this->row('cccc'));
}
public function testAModeZeroNodeDoesNotReapSoItsTouchesGoToTheStore(): void {
$this->bus();
NodeRegistry::update(5, ['mode' => 0, 'features' => 'hls_reaper']);
$this->live('aaaa', 5, 100);
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T, 150)]);
$this->assertSame([150, 0], $this->row('aaaa'));
$this->assertSame([], ClusterBus::lastReads(5, ['aaaa']));
}
public function testPastItsShareOfTheBussMemoryTouchesGoToTheStore(): void {
$rBus = $this->bus();
NodeRegistry::update(5, ['features' => 'hls_reaper']);
$this->live('aaaa', 5, 100);
$rUsed = (int) $rBus->info('memory')['used_memory'];
$rBus->config('SET', 'maxmemory', (string) intdiv($rUsed * 3, 2));
try {
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T, 150)]);
} finally {
$rBus->config('SET', 'maxmemory', '0');
}
$this->assertSame([150, 0], $this->row('aaaa'), 'the reservations and wake-ups keep the bus');
$this->assertSame([], ClusterBus::lastReads(5, ['aaaa']));
}
public function testAStoreThatCannotBeReadFailsTheBatchForTheNodeToResend(): void {
$this->bus();
SettingsManager::set(['redis_handler' => 1]);
(new \ReflectionProperty(RedisManager::class, 'instance'))->setValue(null, RedisServer::unreachable());
(new \ReflectionProperty(RedisManager::class, 'lastPingCheck'))->setValue(null, time());
foreach (['a node that does not reap' => null, 'a reaping node' => 'hls_reaper'] as $rCase => $rFeatures) {
NodeRegistry::update(5, ['features' => $rFeatures]);
try {
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T, 150)]);
$this->fail($rCase . ': the batch must fail (503 DB), not be answered as applied');
} catch (\Throwable $rE) {
$this->assertNotInstanceOf(\PHPUnit\Framework\AssertionFailedError::class, $rE, $rCase);
}
}
}
public function testWithoutRedisTheTouchesAreNotAnsweredAsApplied(): void {
SettingsManager::set(['redis_handler' => 1]);
RedisManager::closeInstance();
RedisManager::useConnector(static fn() => null); // Redis unreachable
$this->expectExceptionMessage('redis unavailable');
try {
EventIngest::ingest($this->node(), 'p2', 0, [$this->touch('aaaa', self::T, 150)]);
} finally {
RedisManager::useConnector(null);
}
}
public function testAnOlderAgentsTouchAsAP0UpsertStillLands(): void {
$rRec = ['user_id' => 7, 'stream_id' => 100, 'user_ip' => '10.0.0.9', 'container' => 'hls', 'pid' => null, 'uuid' => 'aaaa', 'date_start' => 1800000000, 'hls_last_read' => 100, 'hls_end' => 0];
EventIngest::ingest($this->node(), 'p0', 1, [['type' => 'conn.upsert', 'd' => ['record' => $rRec]]]);
EventIngest::ingest($this->node(), 'p0', 2, [['type' => 'conn.upsert', 'd' => ['record' => ['hls_last_read' => 110] + $rRec]]]);
$this->assertSame([110, 0], $this->row('aaaa'));
}
}