mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 11:36:53 +09:00
* 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>
281 lines
11 KiB
PHP
281 lines
11 KiB
PHP
<?php
|
|
// Copyright (C) 2026 The Cacti Group. Licensed under GPL-2.0-or-later.
|
|
// Isolated behavioral probe: production parsing, batching, staging and update bodies.
|
|
if (PHP_SAPI !== 'cli') {
|
|
exit(1);
|
|
}
|
|
$root = getenv('CACTI_POLLER_SOURCE_ROOT') ?: dirname(__DIR__, 2);
|
|
require dirname(__DIR__, 2) . '/include/vendor/autoload.php';
|
|
define('DATA_INPUT_TYPE_SCRIPT', 1);
|
|
define('DATA_INPUT_TYPE_PHP_SCRIPT_SERVER', 5);
|
|
$scenario = $argv[1] ?? 'partial';
|
|
$state = ['rows' => [], 'queries' => 0, 'mapping_queries' => 0, 'commands' => [], 'rrd_calls' => 0, 'boost_calls' => 0, 'delete_calls' => 0, 'arrived' => false];
|
|
function probe_load(string $path, string $name) : void {
|
|
$tokens = token_get_all(file_get_contents($path));
|
|
$code = '';
|
|
$capture = false;
|
|
$depth = 0;
|
|
$started = false;
|
|
|
|
foreach ($tokens as $i => $token) {
|
|
if (!$capture && is_array($token) && $token[0] === T_FUNCTION) {
|
|
$j = $i + 1;
|
|
|
|
while (is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
|
|
$j++;
|
|
}
|
|
$capture = is_array($tokens[$j]) && $tokens[$j][1] === $name;
|
|
}
|
|
|
|
if (!$capture) {
|
|
continue;
|
|
}
|
|
$code .= is_array($token) ? $token[1] : $token;
|
|
|
|
if ($token === '{' || (is_array($token) && in_array($token[0], [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true))) {
|
|
$depth++;
|
|
$started = true;
|
|
}
|
|
|
|
if ($token === '}' && --$depth === 0 && $started) {
|
|
eval($code);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw new RuntimeException('Missing production function: ' . $name);
|
|
}
|
|
function probe_row(int $id, int $time, string $field, string $value, int $fields = 1) : array {
|
|
return ['local_data_id' => $id, 'unix_time' => $time, 'time' => gmdate('Y-m-d H:i:s', $time), 'rrd_name' => $field, 'output' => $value, 'rrd_num' => $fields, 'rrd_path' => 'source-' . $id, 'data_template_id' => 1];
|
|
}
|
|
function db_fetch_assoc_prepared($sql, $params = []) : array|false {
|
|
global $state, $scenario;
|
|
|
|
if (str_contains($sql, 'FROM poller_output AS po')) {
|
|
$state['queries']++;
|
|
|
|
if ($state['queries'] > 8) {
|
|
throw new RuntimeException('Poller repeated a backlog without progress');
|
|
}
|
|
$rows = array_values(array_filter($state['rows'], static fn ($row) => $params === [] || $row['local_data_id'] > $params[0] || ($row['local_data_id'] == $params[1] && $row['time'] > $params[2])));
|
|
usort($rows, static fn ($a, $b) => [$a['local_data_id'], $a['time'], $a['rrd_name']] <=> [$b['local_data_id'], $b['time'], $b['rrd_name']]);
|
|
|
|
return str_contains($sql, 'LIMIT 40001') ? array_slice($rows, 0, 40001) : $rows;
|
|
}
|
|
|
|
if (str_contains($sql, 'poller_data_template_field_mappings')) {
|
|
$state['mapping_queries']++;
|
|
|
|
if ($scenario === 'metadata' && $state['mapping_queries'] === 1) {
|
|
return false;
|
|
}
|
|
|
|
if ($scenario === 'orphan-query-failure') {
|
|
return [['keyname' => '1_value', 'data_source_name' => 'in']];
|
|
}
|
|
|
|
return $scenario === 'fanout' || $scenario === 'fanout-orphan' ? [['keyname' => '1_value', 'data_source_name' => 'in,out']] : [['keyname' => '1_in', 'data_source_name' => 'in'], ['keyname' => '1_out', 'data_source_name' => 'out']];
|
|
}
|
|
|
|
if (str_contains($sql, 'FROM data_template_rrd') || str_contains($sql, 'FROM data_input AS di')) {
|
|
$state['source_queries'] = ($state['source_queries'] ?? 0) + 1;
|
|
|
|
if (in_array($scenario, ['manual-query-failure', 'orphan-query-failure'], true) && $state['source_queries'] === 1) {
|
|
return false;
|
|
}
|
|
|
|
if ($scenario === 'manual-query-failure') {
|
|
return [['data_name' => 'value', 'data_source_name' => 'in']];
|
|
}
|
|
}
|
|
|
|
return $scenario === 'fanout-orphan' ? [['local_data_id' => 1, 'data_source_name' => 'out']] : [];
|
|
}
|
|
function db_fetch_assoc($sql) : array|false {
|
|
return str_contains($sql, 'FROM poller_output AS po') ? db_fetch_assoc_prepared($sql) : [];
|
|
}
|
|
function db_fetch_cell($sql) : int {
|
|
return str_contains($sql, 'poller_time') ? 0 : count($GLOBALS['state']['rows']);
|
|
}
|
|
function db_execute_prepared($sql, $params = []) : bool {
|
|
global $state, $scenario;
|
|
$state['delete_calls']++;
|
|
|
|
if ($scenario === 'delete-failure' || ($scenario === 'delete-group' && $state['delete_calls'] === 2)) {
|
|
return false;
|
|
}
|
|
$keys = [];
|
|
|
|
for ($i = 0; $i < count($params); $i += 3) {
|
|
$keys[$params[$i] . '|' . $params[$i + 1] . '|' . $params[$i + 2]] = true;
|
|
}
|
|
$state['rows'] = array_values(array_filter($state['rows'], static fn ($row) => !isset($keys[$row['local_data_id'] . '|' . $row['rrd_name'] . '|' . $row['time']])));
|
|
|
|
return true;
|
|
}
|
|
function db_execute($sql, ...$args) : bool {
|
|
global $state, $scenario;
|
|
|
|
if (str_contains($sql, 'INSERT IGNORE INTO poller_output_boost')) {
|
|
$state['boost_calls']++;
|
|
|
|
return $scenario !== 'boost-failure';
|
|
}
|
|
|
|
if (preg_match('/local_data_id IN\s*\(([^)]+)\)/', $sql, $matches)) {
|
|
$ids = array_flip(explode(',', $matches[1]));
|
|
$state['rows'] = array_values(array_filter($state['rows'], static fn ($row) => !isset($ids[$row['local_data_id']])));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
function db_in_clause($column, $ids) : string {
|
|
return $column . ' IN (' . implode(',', $ids) . ')';
|
|
}
|
|
function db_fetch_row(...$args) : array {
|
|
return ['Value' => 1048576];
|
|
}
|
|
function db_affected_rows(...$args) : int {
|
|
return 100000;
|
|
}
|
|
function db_qstr($s, ...$args) : string {
|
|
return "'" . str_replace("'", "''", $s) . "'";
|
|
}
|
|
function cacti_log(...$args) : void {
|
|
}
|
|
function dsstats_poller_output(...$args) : void {
|
|
}
|
|
function dsdebug_poller_output(...$args) : void {
|
|
}
|
|
function api_plugin_hook_function(...$args) : void {
|
|
}
|
|
function read_config_option($key) : string {
|
|
if ($key === 'boost_rrd_update_enable') {
|
|
return str_starts_with($GLOBALS['scenario'], 'boost-') ? 'on' : '';
|
|
}
|
|
|
|
return $key === 'boost_redirect' && $GLOBALS['scenario'] === 'boost-redirect' ? 'on' : '';
|
|
}
|
|
function set_config_option(...$args) : void {
|
|
}
|
|
function boost_check_correct_enabled() : bool {
|
|
return true;
|
|
}
|
|
function boost_error_handler(...$args) : bool {
|
|
return false;
|
|
}
|
|
function boost_log_ignored_sample_keys(...$args) : void {
|
|
}
|
|
function boost_log_failed_staging(...$args) : void {
|
|
}
|
|
function rrdtool_file_exists(...$args) : bool {
|
|
return true;
|
|
}
|
|
function get_rrdtool_version() : string {
|
|
return '1.8';
|
|
}
|
|
function cacti_version_compare($a, $b, $op) : bool {
|
|
return version_compare($a, $b, $op);
|
|
}
|
|
function cacti_escapeshellarg($s) : string {
|
|
return escapeshellarg($s);
|
|
}
|
|
function rrdtool_command_log_context($s) : string {
|
|
return $s;
|
|
}
|
|
function rrdtool_execute($command, ...$args) : string|false {
|
|
global $state, $scenario;
|
|
$state['rrd_calls']++;
|
|
|
|
if ($command instanceof \Cacti\Rrd\RrdCommand) {
|
|
$command = [$command->operation, ...$command->arguments];
|
|
}
|
|
$command = is_array($command) ? implode(' ', $command) : $command;
|
|
|
|
if (count($state['commands']) < 5) {
|
|
$state['commands'][] = $command;
|
|
}
|
|
|
|
if ($scenario === 'arrival' && !$state['arrived']) {
|
|
$state['rows'][] = probe_row(1, 1700000060, 'in', '43');
|
|
$state['arrived'] = true;
|
|
}
|
|
|
|
if (($scenario === 'retry-failed' && $state['rrd_calls'] === 1) || ($scenario === 'boundary-failure' && str_contains($command, 'source-40000') && str_contains($command, '1700000000:')) || in_array($scenario, ['failed', 'boost-failure'], true) || ($scenario === 'mixed' && str_contains($command, 'source-1'))) {
|
|
return false;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
foreach (['SQL_NO_CACHE' => '', 'POLLER_VERBOSITY_HIGH' => 4, 'POLLER_VERBOSITY_NONE' => 0, 'POLLER_ID' => 1, 'CACTI_CONNECTION' => 'online', 'RRDTOOL_OUTPUT_STDOUT' => 1, 'CACTI_PATH_LIBRARY' => dirname(__DIR__) . '/performance/fixtures'] as $name => $value) {
|
|
define($name, $value);
|
|
}
|
|
|
|
foreach (['cacti_sizeof', 'array_rekey', 'is_hexadecimal'] as $name) {
|
|
probe_load($root . '/lib/functions.php', $name);
|
|
}
|
|
probe_load($root . '/lib/rrd.php', 'rrdtool_function_update');
|
|
|
|
foreach (['boost_flush_output_batch', 'boost_poller_on_demand'] as $name) {
|
|
probe_load($root . '/lib/boost.php', $name);
|
|
}
|
|
require $root . '/lib/poller.php';
|
|
$debug = false;
|
|
$time = 1700000000;
|
|
|
|
if ($scenario === 'partial') {
|
|
$state['rows'] = [probe_row(1, $time, 'in', '42', 2), probe_row(1, $time, 'out', '43', 2), probe_row(1, $time + 60, 'in', '44', 2)];
|
|
} elseif ($scenario === 'early-partial') {
|
|
$state['rows'] = [probe_row(1, $time, 'in', '42', 2), probe_row(1, $time + 60, 'in', '43', 2), probe_row(1, $time + 60, 'out', '44', 2)];
|
|
} elseif ($scenario === 'delete-group') {
|
|
for ($i = 1; $i <= 999; $i++) {
|
|
$state['rows'][] = probe_row($i, $time, 'in', '42');
|
|
}
|
|
$state['rows'][] = probe_row(1000, $time, 'in', '42', 2);
|
|
$state['rows'][] = probe_row(1000, $time, 'out', '43', 2);
|
|
$state['rows'][] = probe_row(1001, $time, 'in', '44');
|
|
} elseif ($scenario === 'mixed') {
|
|
$state['rows'] = [probe_row(1, $time, 'in', '42'), probe_row(1, $time + 60, 'in', '43'), probe_row(2, $time, 'in', '44')];
|
|
} elseif (str_starts_with($scenario, 'fanout')) {
|
|
$state['rows'] = [probe_row(1, $time, '', 'value:42', $scenario === 'fanout' ? 2 : 1)];
|
|
} elseif ($scenario === 'boundary' || $scenario === 'boundary-partial' || $scenario === 'boundary-failure' || $scenario === 'backlog') {
|
|
for ($i = 1; $i <= ($scenario !== 'backlog' ? 39999 : 40001); $i++) {
|
|
$state['rows'][] = probe_row($i, $time, 'in', '42', $scenario === 'backlog' ? 2 : 1);
|
|
}
|
|
|
|
if ($scenario === 'boundary-failure') {
|
|
$state['rows'][] = probe_row(40000, $time, 'in', '42');
|
|
$state['rows'][] = probe_row(40000, $time + 60, 'in', '43');
|
|
} elseif ($scenario === 'boundary-partial') {
|
|
$state['rows'][] = probe_row(40000, $time, 'in', '42', 2);
|
|
$state['rows'][] = probe_row(40000, $time + 60, 'in', '43', 2);
|
|
$state['rows'][] = probe_row(40000, $time + 60, 'out', '44', 2);
|
|
} elseif ($scenario === 'boundary') {
|
|
$state['rows'][] = probe_row(40000, $time, 'in', '42', 2);
|
|
$state['rows'][] = probe_row(40000, $time, 'out', '43', 2);
|
|
}
|
|
} else {
|
|
$state['rows'] = [probe_row(1, $time, 'in', '42')];
|
|
}
|
|
|
|
if (in_array($scenario, ['manual-query-failure', 'orphan-query-failure'], true)) {
|
|
$state['rows'] = [probe_row(1, $time, '', 'value:42')];
|
|
$state['rows'][0]['data_template_id'] = $scenario === 'manual-query-failure' ? 0 : 1;
|
|
}
|
|
$pipe = null;
|
|
$first = process_poller_output($pipe);
|
|
$second = null;
|
|
|
|
if (in_array($scenario, ['boundary', 'boundary-partial', 'boundary-failure', 'backlog', 'metadata', 'retry-failed', 'manual-query-failure', 'orphan-query-failure'], true)) {
|
|
$second = process_poller_output($pipe);
|
|
}
|
|
|
|
if ($scenario === 'retry') {
|
|
// Requeue a new timestamp after the first was stored.
|
|
$state['rows'][] = probe_row(1, $time + 60, 'in', '43');
|
|
$second = process_poller_output($pipe, 1);
|
|
}
|
|
print json_encode(['source_queries' => ($state['source_queries'] ?? 0), 'first' => $first, 'second' => $second, 'remaining' => count($state['rows']), 'rows' => array_slice($state['rows'], 0, 3), 'queries' => $state['queries'], 'mapping_queries' => $state['mapping_queries'], 'rrd_calls' => $state['rrd_calls'], 'commands' => $state['commands'], 'boost_calls' => $state['boost_calls'], 'delete_calls' => $state['delete_calls']], JSON_THROW_ON_ERROR) . "\n";
|