Files
cacti/tests/HandOff/BoostDataHandOffTest.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

157 lines
7.6 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDTool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
require_once dirname(__DIR__) . '/Helpers/PhpSource.php';
if (!function_exists('boostSource')) {
function boostSource(string $path): string {
$source = file_get_contents(CACTI_PATH_BASE . '/' . $path);
if ($source === false) {
throw new RuntimeException("Unable to read Boost source file: $path");
}
return $source;
}
}
test('Boost cache paths normalize either trailing separator style', function () {
$boost = boostSource('lib/boost.php');
expect($boost)->toContain("rtrim(\$cache_directory, '/\\\\')");
});
test('Boost hand-off helper tolerates repeated test-file loading', function () {
$source = file_get_contents(__FILE__);
expect($source)->not->toBeFalse();
$source = (string) $source;
$declaration = strpos($source, 'function ' . 'boostSource');
expect($declaration)->not->toBeFalse();
expect(substr($source, max(0, $declaration - 80), 80))->toContain("if (!function_exists('boostSource')) {");
});
test('Boost hand-off batches expose database acknowledgement', function () {
$boost = boostSource('lib/boost.php');
expect($boost)->toContain('function boost_flush_output_batch(array $value_tuples, mixed $conn = false) : bool');
expect($boost)->toContain('$acknowledged = db_execute($sql_prefix . $out_buffer, true, $conn) !== false;');
expect($boost)->toContain('$acknowledged = boost_flush_output_batch($value_tuples, $conn);');
expect($boost)->toContain('$return_value = !$acknowledged;');
});
test('Recovery deletes only the exact rows acknowledged by the main collector', function () {
$recovery = boostSource('poller_recovery.php');
expect($recovery)->toContain('function recovery_delete_acknowledged_rows(array $rows, mixed $conn) : bool');
expect($recovery)->toContain('function recovery_transfer_rows(array $rows, mixed $remote_conn, mixed $local_conn) : int|false');
expect($recovery)->toContain('(local_data_id = ? AND rrd_name = ? AND time = ?)');
expect($recovery)->toContain('if (!boost_flush_output_batch($sql_array, $remote_conn))');
expect($recovery)->toContain('array_slice($rows, $offset, $transfer_chunk_size)');
expect($recovery)->toContain('recovery_transfer_rows($chunk, $remote_db_cnn_id, $local_db_cnn_id)');
expect($recovery)->toContain("ORDER BY time ASC, local_data_id ASC\n\t\t\t\tLIMIT %d', (int) \$record_limit)");
expect($recovery)->not->toContain('DELETE FROM poller_output_boost WHERE time <=');
});
test('Recovery status SQL remains valid when ANSI_QUOTES is enabled', function () {
$recovery = boostSource('poller_recovery.php');
expect($recovery)->toContain('SET status=2')
->and($recovery)->toContain('SET status = 5')
->and($recovery)->toContain("VALUES (\\'recovery_pid\\', ?)")
->and($recovery)->not->toContain("if (!\$transfer_failed) {\n\t\t// let the console know you are in online mode")
->and($recovery)->not->toMatch('/(?:status\s*=|VALUES\s*\()\s*"/');
});
test('Scheduled Boost retains shard ownership after an RRD update failure', function () {
$poller = boostSource('poller_boost.php');
expect($poller)->toContainPhp('function boost_process_output(int $local_data_id, array $outarray, string $rrd_path, array $rrd_tmplp, mixed $rrdtool_pipe) : bool');
expect($poller)->toContain('if ($updates_ok && $results !== false)');
expect($poller)->toContain('return $updates_ok && $results !== false ? cacti_sizeof($results) : -1;');
expect($poller)->toContain('if ($pass_rows < 0)');
expect($poller)->toContain('if ($total_rows == 0) {');
expect($poller)->toContain('return 0;');
expect($poller)->not->toContain('elseif ($rrd_updates == -1)');
expect(boostSource('lib/boost.php'))->toContain("return is_string(\$result) && \$result !== '' ? \$result : 'ERROR: RRDtool did not acknowledge the update';");
});
test('Boost completion-table upgrades recover when the legacy table is missing', function () {
$upgrade = boostSource('install/upgrades/1_3_0.php');
expect($upgrade)->toContain("if (!db_table_exists('poller_output_boost_processes'))")
->and($upgrade)->toContain('CREATE TABLE `poller_output_boost_processes`')
->and($upgrade)->toContain('UNIQUE KEY `run_child` (`run_id`, `child_id`)');
});
test('Boost child completion and archive deletion are scoped to a parent run', function () {
$poller = boostSource('poller_boost.php');
$schema = boostSource('cacti.sql');
expect($poller)->toContain('bin2hex(random_bytes(16));');
expect($poller)->toContain("'--run-id=' . \$run_id");
expect($poller)->toContain('function boost_completed_children(string $run_id) : int');
expect($poller)->toContain('function boost_failed_children(string $run_id) : int');
expect($poller)->toContain('if ($rrd_updates > 0 && $failed_children === 0)');
expect($schema)->toContain('UNIQUE KEY `run_child` (`run_id`,`child_id`)');
expect($schema)->toContain('`child_id` int(10) unsigned NOT NULL default 0');
});
test('direct Boost redirection never re-stages collector output from the poller', function () {
$boost = boostSource('lib/boost.php');
expect($boost)->toContain("if (read_config_option('boost_redirect') == 'on') {")
->and($boost)->not->toContain('$track_staging')
->and($boost)->toContain('spine/cmd.php already staged these rows');
});
test('Archive discovery validates every dynamic table identifier', function () {
$boost = boostSource('lib/boost.php');
expect($boost)->toContain("preg_match('/^poller_output_boost_arch_\\d+$/D', \$table)");
expect($boost)->toContain("array_filter(\$tableNames, 'boost_is_valid_archive_table')");
});
test('Remote tuples are quoted by their destination connection', function () {
$cmd = boostSource('cmd.php');
$recovery = boostSource('poller_recovery.php');
expect($cmd)->toContainPhp("db_qstr(\$item['rrd_name'], \$poller_db_cnn_id)");
expect($cmd)->toContainPhp('db_qstr($output, $poller_db_cnn_id)');
expect($cmd)->toContainPhp("db_qstr('U', \$poller_db_cnn_id)");
expect($cmd)->toContain("IFNULL(s.disabled, \\'\\') != \\'on\\'");
expect($recovery)->toContainPhp("db_qstr(\$row['rrd_name'], \$remote_conn)");
});
test('Graph cache names are opaque and writes are atomically published', function () {
$boost = boostSource('lib/boost.php');
expect($boost)->toContain("hash_hmac('sha256', \$cache_key, \$secret) . '.png'");
expect($boost)->toContain("tempnam(dirname(\$cache_file), '.boost-')");
expect($boost)->toContain("PHP_OS_FAMILY === 'Windows'");
expect($boost)->toContain('boost_replace_cache_file_on_windows($temp_file, $cache_file)');
expect($boost)->toContain('if (is_string($output) && strlen($output) > 10)');
expect($boost)->toContain('if (!$flushed || !chmod($temp_file, 0644))');
expect($boost)->toContain("read_config_option('boost_png_cache_secret', true)");
expect($boost)->not->toContain("get_selected_theme() . '_lgi_'");
});
test('Boost restores both the caller error handler and reporting level', function () {
$boost = boostSource('lib/boost.php');
expect(substr_count($boost, '$previous_error_reporting = error_reporting();'))->toBeGreaterThanOrEqual(4);
expect(substr_count($boost, 'error_reporting($previous_error_reporting);'))->toBeGreaterThanOrEqual(8);
});