Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions lib/Database/DatabaseProbe.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion lib/Database/RuleSet/Mysql.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────
Expand Down
106 changes: 106 additions & 0 deletions tests/lib/Database/AdvisorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ServerInfo\Tests\Database;

use OCA\ServerInfo\Database\Advisor;
use OCA\ServerInfo\Database\ExpressionEvaluator;
use OCA\ServerInfo\Database\RuleResult;
use OCA\ServerInfo\Database\RuleSet\Mysql;
use OCA\ServerInfo\Database\Snapshot;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;

class AdvisorTest extends \Test\TestCase {
private LoggerInterface&MockObject $logger;
private Advisor $advisor;

protected function setUp(): void {
parent::setUp();

$this->logger = $this->createMock(LoggerInterface::class);
$this->advisor = new Advisor(new ExpressionEvaluator(), $this->logger);
}

/**
* @param array<string, scalar|null> $status
* @param array<string, scalar|null> $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);
}
}
Loading