Files
cacti/tests/integration/BoostFlushOutputBatchTest.php
TheWitnessandCopilot 76a916cf03 fix(boost): never re-stage poller output when boost_redirect is on (#8374)
* fix(boost): never re-stage poller output when boost_redirect is on

#8332 gated the boost_redirect bypass in boost_poller_on_demand() on the
caller not passing $staged. process_poller_output() always passes it, so
the bypass never fired and the main poller re-inserted every row spine or
cmd.php had already written to poller_output_boost, flooding the log with
"Boost staging ignored N of N duplicate sample keys" warnings.

When boost_redirect is enabled the poller now never inserts into
poller_output_boost and reports the rows as staged so they are cleared
from poller_output. poller_recovery.php writes recovered remote rows via
boost_flush_output_batch() directly and is unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: TheWitness <thewitness@cacti.net>

* chore(changelog): use changelog.d fragment for #8374

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: TheWitness <thewitness@cacti.net>

* fix(boost): log failed poller_output_boost staging as an ERROR

boost_flush_output_batch() returned false silently when the database did
not acknowledge a chunk. cmd.php's boost_redirect staging ignores that
return, so a failed collector-side write left no trace. Log a BOOST ERROR
with the sample count from the helper so every caller's failure is visible.

Adds in-process coverage for the failure log and for boost_poller_on_demand()
never re-staging rows when boost_redirect is on.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: TheWitness <thewitness@cacti.net>

---------

Signed-off-by: TheWitness <thewitness@cacti.net>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-14 21:13:43 -04:00

346 lines
12 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/*
* Integration coverage for boost_flush_output_batch() (issue#7536).
*
* The poller_output_boost insert used to be hand-written four times: three
* in cmd.php using INSERT IGNORE, one in boost_poller_on_demand() (lib/boost.php)
* using ON DUPLICATE KEY UPDATE -- a real data-integrity inconsistency
* (silently-drop vs last-write-wins) between two paths writing the same
* shape of data. All four call sites now share this one helper, which
* standardizes on INSERT IGNORE (first write wins): a genuine collision on
* (local_data_id, rrd_name, time) represents a retry of an already-collected
* sample, not two independently meaningful readings, since RRD only stores
* one value per timestamp regardless.
*
* Runs against a real (SQLite, in-memory) PDO connection wired in as the
* default db_* connection via $database_sessions, through FakeMySQLPDO,
* which translates INSERT IGNORE -> INSERT OR IGNORE for the SQLite driver.
*/
require_once dirname(__DIR__) . '/Helpers/UnitStubs.php';
require_once dirname(__DIR__) . '/Helpers/FakeMySQLPDO.php';
require_once CACTI_PATH_INCLUDE . '/vendor/autoload.php';
require_once CACTI_PATH_LIBRARY . '/database.php';
require_once CACTI_PATH_LIBRARY . '/boost.php';
class BoostBatchPDO extends FakeMySQLPDO {
public int $insert_attempts = 0;
public function prepare(string $query, array $options = []): PDOStatement|false {
if (stripos(ltrim($query), 'INSERT IGNORE INTO poller_output_boost') === 0) {
$this->insert_attempts++;
}
return parent::prepare($query, $options);
}
}
beforeEach(function () {
global $database_sessions, $database_hostname, $database_port, $database_default;
$this->db_globals = [$database_sessions, $database_hostname, $database_port, $database_default];
$conn = new BoostBatchPDO();
$conn->exec('CREATE TABLE poller_output_boost (
local_data_id INTEGER NOT NULL,
rrd_name TEXT NOT NULL,
time TEXT NOT NULL,
output TEXT NOT NULL,
PRIMARY KEY (local_data_id, rrd_name, time)
)');
$conn->exec('CREATE TABLE settings (name TEXT PRIMARY KEY, value TEXT)');
$database_hostname = 'unit_test_host';
$database_port = '0';
$database_default = 'unit_test_db';
$database_sessions["$database_hostname:$database_port:$database_default"] = $conn;
$this->conn = $conn;
});
// Put the default db_* connection back. Left in place, the fake handle answers
// every later read_config_option() in the run and throws on Cacti's MySQL SQL,
// aborting the suite.
afterEach(function () {
global $database_sessions, $database_hostname, $database_port, $database_default;
[$database_sessions, $database_hostname, $database_port, $database_default] = $this->db_globals;
});
function boost_test_fetch_output(PDO $conn, int $local_data_id, string $rrd_name, string $time): ?string {
$stmt = $conn->prepare('SELECT output FROM poller_output_boost WHERE local_data_id = ? AND rrd_name = ? AND time = ?');
$stmt->execute([$local_data_id, $rrd_name, $time]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? $row['output'] : null;
}
test('writes a fresh row when no key collision exists', function () {
expect(boost_flush_output_batch(["(1,'ds','2024-01-01 00:00:00','100')"], $this->conn))->toBeTrue();
$stmt = $this->conn->query('SELECT local_data_id, rrd_name, output FROM poller_output_boost');
$row = $stmt->fetch(PDO::FETCH_ASSOC);
expect($row)->not->toBeFalse();
expect($row['local_data_id'])->toBe(1);
expect($row['rrd_name'])->toBe('ds');
expect($row['output'])->toBe('100');
});
test('a genuine duplicate-key collision keeps the first write, ignoring the second (issue#7536 chosen strategy)', function () {
// Two separate flush calls targeting the identical (local_data_id, rrd_name, time)
// key -- e.g. a retried batch, or two writers racing on the same rounded
// second -- with different output values so the winner is unambiguous.
boost_flush_output_batch(["(1,'ds','2024-01-01 00:00:00','100')"], $this->conn);
boost_flush_output_batch(["(1,'ds','2024-01-01 00:00:00','200')"], $this->conn);
$output = boost_test_fetch_output($this->conn, 1, 'ds', '2024-01-01 00:00:00');
// INSERT IGNORE: first write wins, the second is silently dropped.
expect($output)->toBe('100');
$count = (int) $this->conn->query('SELECT COUNT(*) AS c FROM poller_output_boost')->fetch(PDO::FETCH_ASSOC)['c'];
expect($count)->toBe(1);
});
test('non-colliding rows in the same batch are all written', function () {
boost_flush_output_batch([
"(1,'ds1','2024-01-01 00:00:00','10')",
"(2,'ds2','2024-01-01 00:00:00','20')",
"(3,'ds3','2024-01-01 00:00:00','30')",
], $this->conn);
$count = (int) $this->conn->query('SELECT COUNT(*) AS c FROM poller_output_boost')->fetch(PDO::FETCH_ASSOC)['c'];
expect($count)->toBe(3);
expect(boost_test_fetch_output($this->conn, 2, 'ds2', '2024-01-01 00:00:00'))->toBe('20');
});
test('splits a batch before the database packet limit without losing rows', function () {
$large_output = str_repeat('x', 2200000);
expect(boost_flush_output_batch([
"(11,'large_1','2024-01-01 00:00:00','{$large_output}')",
"(12,'large_2','2024-01-01 00:00:00','{$large_output}')",
], $this->conn))->toBeTrue();
$count = (int) $this->conn->query('SELECT COUNT(*) FROM poller_output_boost')->fetchColumn();
expect($count)->toBe(2)
->and($this->conn->insert_attempts)->toBe(2)
->and(strlen(boost_test_fetch_output($this->conn, 11, 'large_1', '2024-01-01 00:00:00')))->toBe(2200000)
->and(strlen(boost_test_fetch_output($this->conn, 12, 'large_2', '2024-01-01 00:00:00')))->toBe(2200000);
});
test('an empty tuple list is a no-op', function () {
expect(boost_flush_output_batch([], $this->conn))->toBeTrue();
$count = (int) $this->conn->query('SELECT COUNT(*) AS c FROM poller_output_boost')->fetch(PDO::FETCH_ASSOC)['c'];
expect($count)->toBe(0);
});
test('reports a failed database acknowledgement to its caller', function () {
$this->conn->exec('DROP TABLE poller_output_boost');
expect(boost_flush_output_batch(["(1,'ds','2024-01-01 00:00:00','100')"], $this->conn))->toBeFalse();
});
/**
* Run $callback with cacti_log() pointed at a temporary file and return
* what was logged, restoring the cached log options afterwards.
*/
function boost_test_capture_log(callable $callback) : string {
global $config;
$log_file = tempnam(sys_get_temp_dir(), 'cacti-boost-log-');
$saved = [];
foreach (['path_cactilog' => $log_file, 'log_destination' => 1, 'log_verbosity' => POLLER_VERBOSITY_LOW, 'selective_debug' => '', 'selective_plugin_debug' => ''] as $name => $value) {
$saved[$name] = $config[OPTIONS_CLI][$name] ?? null;
$config[OPTIONS_CLI][$name] = $value;
}
try {
$callback();
return (string) file_get_contents($log_file);
} finally {
foreach ($saved as $name => $value) {
if ($value === null) {
unset($config[OPTIONS_CLI][$name]);
} else {
$config[OPTIONS_CLI][$name] = $value;
}
}
@unlink($log_file);
}
}
test('a failed staging write is logged as a BOOST ERROR rather than failing silently', function () {
$this->conn->exec('DROP TABLE poller_output_boost');
$log = boost_test_capture_log(function () {
expect(boost_flush_output_batch([
"(1,'ds','2024-01-01 00:00:00','100')",
"(2,'ds','2024-01-01 00:00:00','200')",
], $this->conn))->toBeFalse();
});
expect($log)->toContain('ERROR: Boost staging failed to write 2 sample(s) to poller_output_boost');
});
test('the poller never re-stages collector output when boost_redirect is on', function () {
global $config;
$saved = [];
foreach (['boost_rrd_update_enable' => 'on', 'boost_rrd_update_system_enable' => 'on', 'boost_redirect' => 'on'] as $name => $value) {
$saved[$name] = $config[OPTIONS_CLI][$name] ?? null;
$config[OPTIONS_CLI][$name] = $value;
}
try {
$results = [['local_data_id' => 1, 'rrd_name' => 'ds', 'time' => '2024-01-01 00:00:00', 'output' => '100']];
$staged = false;
expect(boost_poller_on_demand($results, $staged))->toBeFalse()
->and($staged)->toBeTrue()
->and($this->conn->insert_attempts)->toBe(0)
->and((int) $this->conn->query('SELECT COUNT(*) FROM poller_output_boost')->fetchColumn())->toBe(0);
} finally {
foreach ($saved as $name => $value) {
if ($value === null) {
unset($config[OPTIONS_CLI][$name]);
} else {
$config[OPTIONS_CLI][$name] = $value;
}
}
}
});
test('publishes a multi-chunk graph cache object without leaving a temporary file', function () {
$directory = sys_get_temp_dir() . '/cacti-boost-cache-' . bin2hex(random_bytes(8));
mkdir($directory, 0700);
$cache_file = $directory . '/cache.png';
$output = str_repeat('png-data', 393217);
try {
expect(boost_atomic_write_cache($cache_file, $output))->toBeTrue();
expect(file_get_contents($cache_file))->toBe($output);
expect(fileperms($cache_file) & 0777)->toBe(0644);
expect(glob($directory . '/.boost-*'))->toBe([]);
} finally {
@unlink($cache_file);
@rmdir($directory);
}
});
test('the Windows cache replacement fallback replaces an existing object', function () {
$directory = sys_get_temp_dir() . '/cacti-boost-cache-' . bin2hex(random_bytes(8));
$cache_file = $directory . '/cache.png';
$temp_file = $directory . '/.boost-replacement';
mkdir($directory, 0700);
file_put_contents($cache_file, 'old');
file_put_contents($temp_file, 'new');
try {
expect(boost_replace_cache_file_on_windows($temp_file, $cache_file))->toBeTrue();
expect(file_get_contents($cache_file))->toBe('new');
expect(file_exists($temp_file))->toBeFalse();
} finally {
@unlink($temp_file);
@unlink($cache_file);
@rmdir($directory);
}
});
test('atomic cache publication replaces an existing object on the native platform', function () {
$directory = sys_get_temp_dir() . '/cacti-boost-cache-' . bin2hex(random_bytes(8));
$cache_file = $directory . '/cache.png';
mkdir($directory, 0700);
file_put_contents($cache_file, 'old');
try {
expect(boost_atomic_write_cache($cache_file, 'new'))->toBeTrue();
expect(file_get_contents($cache_file))->toBe('new');
expect(glob($directory . '/.boost-*'))->toBe([]);
} finally {
@unlink($cache_file);
@rmdir($directory);
}
});
test('atomic cache publication supports an empty object', function () {
$directory = sys_get_temp_dir() . '/cacti-boost-cache-' . bin2hex(random_bytes(8));
$cache_file = $directory . '/cache.png';
mkdir($directory, 0700);
try {
expect(boost_atomic_write_cache($cache_file, ''))->toBeTrue();
expect(file_get_contents($cache_file))->toBe('');
} finally {
@unlink($cache_file);
@rmdir($directory);
}
});
test('a failed cache publication removes its temporary object', function () {
$directory = sys_get_temp_dir() . '/cacti-boost-cache-' . bin2hex(random_bytes(8));
$cache_file = $directory . '/occupied';
mkdir($directory, 0700);
mkdir($cache_file, 0700);
try {
expect(boost_atomic_write_cache($cache_file, 'new'))->toBeFalse();
expect(is_dir($cache_file))->toBeTrue();
expect(glob($directory . '/.boost-*'))->toBe([]);
} finally {
@rmdir($cache_file);
@rmdir($directory);
}
});
test('the Windows replacement helper fails closed when no destination exists', function () {
$directory = sys_get_temp_dir() . '/cacti-boost-cache-' . bin2hex(random_bytes(8));
$cache_file = $directory . '/missing.png';
$temp_file = $directory . '/.boost-replacement';
mkdir($directory, 0700);
file_put_contents($temp_file, 'new');
try {
expect(boost_replace_cache_file_on_windows($temp_file, $cache_file))->toBeFalse();
expect(file_get_contents($temp_file))->toBe('new');
expect(file_exists($cache_file))->toBeFalse();
} finally {
@unlink($temp_file);
@rmdir($directory);
}
});
test('the Windows replacement helper preserves the existing object when publication fails', function () {
$directory = sys_get_temp_dir() . '/cacti-boost-cache-' . bin2hex(random_bytes(8));
$cache_file = $directory . '/cache.png';
$temp_file = $directory . '/missing-replacement';
mkdir($directory, 0700);
file_put_contents($cache_file, 'old');
try {
expect(boost_replace_cache_file_on_windows($temp_file, $cache_file))->toBeFalse();
expect(file_get_contents($cache_file))->toBe('old');
} finally {
@unlink($cache_file);
@rmdir($directory);
}
});