diff --git a/lib/Database/DatabaseProbe.php b/lib/Database/DatabaseProbe.php index ff87bb13..431c468d 100644 --- a/lib/Database/DatabaseProbe.php +++ b/lib/Database/DatabaseProbe.php @@ -209,9 +209,41 @@ private function snapshotMysql(Connection|IDBConnection $conn, string $flavour): $derived['slow_query_ratio'] = $q > 0 ? $slow / $q : 0.0; } + // `Slave_running` reads OFF both on a replica whose threads have + // stopped *and* on a server that was never pointed at a primary — + // the default for a standalone install. Publish a marker that only + // exists in the latter's absence, so replication rules can list it + // in `requires` and be skipped rather than firing everywhere. + if ($this->hasReplicaConfigured($conn)) { + $derived['Replica_configured'] = 1.0; + } + return new Snapshot($flavour, $version, $status, $variables, $derived); } + /** + * Whether this server is configured as a replica. + * + * The status statement returns an empty result set on a server that was + * never pointed at a primary. It also needs the REPLICATION CLIENT + * (BINLOG MONITOR on MariaDB) privilege; when the Nextcloud database user + * lacks it we report "not a replica", so replication rules are skipped + * instead of being evaluated against something we could not verify. + */ + private function hasReplicaConfigured(Connection|IDBConnection $conn): bool { + // `SHOW REPLICA STATUS` exists since MySQL 8.0.22 / MariaDB 10.5; + // older servers only know the pre-deprecation spelling. + foreach (['SHOW REPLICA STATUS', 'SHOW SLAVE STATUS'] as $sql) { + try { + return $this->all($conn, $sql) !== []; + } catch (\Throwable) { + // Unsupported syntax or missing privilege — try the next spelling. + } + } + + return false; + } + // ── PostgreSQL ────────────────────────────────────────────────── private function snapshotPostgres(Connection|IDBConnection $conn): Snapshot { diff --git a/lib/Database/RuleSet/Mysql.php b/lib/Database/RuleSet/Mysql.php index 83ce7221..2698ef44 100644 --- a/lib/Database/RuleSet/Mysql.php +++ b/lib/Database/RuleSet/Mysql.php @@ -384,7 +384,7 @@ private function build(): array { recommendation: 'Check the replica with SHOW REPLICA STATUS; resolve any IO/SQL errors and restart replication.', justification: 'Slave_running is reporting OFF.', justificationFormula: '', - requires: ['Slave_running'], + requires: ['Slave_running', 'Replica_configured'], ); // ── Security ──────────────────────────────────────────────── diff --git a/tests/lib/Database/AdvisorTest.php b/tests/lib/Database/AdvisorTest.php new file mode 100644 index 00000000..397aaca6 --- /dev/null +++ b/tests/lib/Database/AdvisorTest.php @@ -0,0 +1,106 @@ +logger = $this->createMock(LoggerInterface::class); + $this->advisor = new Advisor(new ExpressionEvaluator(), $this->logger); + } + + /** + * @param array $status + * @param array $derived + */ + private function resultFor(string $ruleId, array $status, array $derived = []): RuleResult { + // Well past Advisor::MIN_UPTIME_HOURS so the uptime gate never + // masks what a given test is actually asserting. + $status += ['Uptime' => 864000]; + $derived += [ + 'value' => 0.0, + 'Uptime_hours' => (float)$status['Uptime'] / 3600.0, + 'Uptime_days' => (float)$status['Uptime'] / 86400.0, + ]; + + $snapshot = new Snapshot(Snapshot::FLAVOUR_MARIADB, '11.8.8-MariaDB', $status, [], $derived); + + foreach ($this->advisor->run(new Mysql(), $snapshot) as $result) { + if ($result->rule->id === $ruleId) { + return $result; + } + } + + $this->fail("Rule '$ruleId' was not evaluated"); + } + + /** + * A standalone server reports Slave_running = OFF simply because no + * replication was ever set up. Without a replica the rule has nothing + * to say, so it must be skipped rather than raising an alert. + */ + public function testReplicaLagSkippedOnStandaloneServer(): void { + $result = $this->resultFor('Slave_lag', ['Slave_running' => 'OFF']); + + $this->assertSame(RuleResult::STATUS_SKIPPED, $result->status); + } + + /** + * Enabling the binary log does not make a server a replica; the rule + * must stay silent for a primary that simply writes binlogs. + */ + public function testReplicaLagSkippedOnPrimaryWithBinlogEnabled(): void { + $result = $this->resultFor('Slave_lag', [ + 'Slave_running' => 'OFF', + 'Binlog_commits' => 4242, + ]); + + $this->assertSame(RuleResult::STATUS_SKIPPED, $result->status); + } + + /** + * On an actual replica whose threads have stopped, the alert is correct + * and must still fire. + */ + public function testReplicaLagFailsOnStoppedReplica(): void { + $result = $this->resultFor( + 'Slave_lag', + ['Slave_running' => 'OFF'], + ['Replica_configured' => 1.0], + ); + + $this->assertSame(RuleResult::STATUS_FAIL, $result->status); + } + + /** + * A healthy replica must not be flagged. + */ + public function testReplicaLagPassesOnRunningReplica(): void { + $result = $this->resultFor( + 'Slave_lag', + ['Slave_running' => 'ON'], + ['Replica_configured' => 1.0], + ); + + $this->assertSame(RuleResult::STATUS_OK, $result->status); + } +}