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); } });