mirror of
https://github.com/Cacti/cacti.git
synced 2026-11-03 02:27:13 +09:00
* fix(boost): forward still-open-round archive rows instead of dropping them boost_process_poller_output() correctly excluded rows from an in-progress poll round when building the RRD write, but the archive-table cleanup right after deleted every row for the local_data_id anyway, with no time filter. Those rows now get INSERT IGNORE'd into the live poller_output_boost table before the archive table is cleared, so a still-open round's data survives. Also fixes the duplicate-record guard in the same function: $last_item was initialized once before the results loop and never reassigned, so the comparison against it could never be true. It is now updated after each processed row. Fixes #7519 Fixes #7523 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): gate archive-table drop on shard completeness poller_boost.php decided whether to drop a run's archive tables using SELECT SUM(status) FROM poller_output_boost_processes, an aggregate over whatever completion rows landed. If one child crashed before writing its completion row, the sum from the surviving shards was still positive and every archive table for the run got dropped, including the crashed shard's unprocessed data. The drain loop already computes boost_completed_children() vs $expected_children to detect a crashed child; the drop now uses that same completeness check instead, and leaves the archive tables in place with a warning when it is not met. Fixes #7520 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(dsstats,rrdcheck): apply Boost's startup barrier to launch loops dsstats_launch_children()/rrdcheck_launch_children() fork children via exec_background() (non-blocking) and the callers then wait with a bare while ($running = ..._processes_running($type)) { sleep(2); }. If the parent evaluates that before any child has registered in the processes table, $running reads 0 and the loop body never runs, treating "not started yet" as "already done" -- the same race Boost's boost_all_children_registered() barrier was added to close. dsstats_launch_children()/rrdcheck_launch_children() now return the launched count, and dsstats_wait_for_children()/ rrdcheck_wait_for_children() wait for the running count to reach it (bounded by a deadline) before trusting the drain loop's running-count poll. Fixes #7526 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * refactor(poller): consolidate poller_item deletes behind a shared helper Extract poller_item_delete_for_data_source() and poller_item_delete_for_host() in lib/poller.php and migrate the existing local+remote delete pairs in lib/api_device.php and lib/api_data_source.php onto them, so the local-delete-plus-remote-mirror pattern lives in one place instead of eight. Refs #7535 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(poller): mirror poller_item deletes to the remote poller on handoff api_data_source_change_host(), data_query_remove_disabled_items(), and data_source_to_data_template() each moved or removed a data source's poller_item row locally but never on the remote Data Collector that still owned it, so that Collector kept polling an item it no longer should. All three now resolve the owning remote poller before the local change and purge the row there too via the shared helper. Fixes #7521 Fixes #7524 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(poller): correct poller_item join semantics and cron reset in cmd.php poller_item and host share ~13 identically-named snmp_*/hostname columns; SELECT * across the join let PDO FETCH_ASSOC silently collapse them to host's live values instead of poller_item's cached copies. Select pi.* plus explicitly aliased host/site disabled columns instead. The now already-fetched $poller_items also makes the separate script-server-call COUNT(*) re-join redundant, replaced with an in-PHP count. Also apply the same cron_interval-aware rrd_next_step reset used by the ranged branch to the --allhost branch, which previously ignored it. Fixes #7522, #7525, #7528. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(poller): thread known poller_id through update_poller_cache() callers update_poller_cache() re-derived poller_id via a host/data_local JOIN on every call, even when the caller already had it: repopulate_poller_cache() and push_out_data_input_method() both already select h.poller_id and discarded it, push_out_host() has the host row cached per data source, and api_device_enable_devices()/update_poller_cache_from_query() compute it once outside their loops. Add an optional $poller_id argument (and honor an already-present 'poller_id' array key) so these callers skip the redundant query. Fixes #7527. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(poller): scope global aggregate scans and make process-leveling sargable Three poller.php queries (total_snmp_ports and the mutually-exclusive active_profiles pair) had no poller_id filter, unlike the rest of the file's central-only blocks, so every remote poller redundantly recomputed and overwrote the same global config options; the snmp_port scan is also an unindexed full table scan. Gate them behind poller_id == 1, with remote pollers reading back the value the central poller already stored. Also rewrite the process-leveling $sql_where's rrd_next_step predicate from the non-sargable `rrd_next_step - $poller_interval <= 0` to the algebraically equivalent `rrd_next_step <= $poller_interval`, restoring use of the poller_id_rrd_next_step index. Fixes #7529, #7530. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(poller): batch update_data_query_cache()'s poller cache writes Each changed data source called update_poller_cache($data_source, true), which flushes immediately via poller_update_poller_cache_from_buffer() -- its own present=0 UPDATE, INSERT ON DUPLICATE KEY UPDATE, and stale-row DELETE -- on every iteration. Re-indexing a host where every port changed issued up to 3x as many statements as ports instead of one batched flush. Accumulate changed rows across the loop and call update_poller_cache() with commit=false, then flush once via poller_update_poller_cache_from_buffer() after the loop, matching the batched pattern already used by push_out_data_input_method(). Fixes #7531. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): add USING BTREE to poller_output_boost_local_data_ids indexes MEMORY-engine indexes default to HASH unless USING BTREE is specified, unlike the sibling poller_output/poller_output_boost tables in the same schema file. boost_output_rrd_data()'s ORDER BY/LIMIT query and boost_process_local_data_ids()'s ranged DELETE both need to scan local_data_id in order, which a HASH index can't serve, forcing a filesort on every boost worker pass. Verified against a real MariaDB 11 instance: EXPLAIN on the ORDER BY query shows "Using filesort" with the old HASH schema and no filesort with USING BTREE, using the primary key for ordering instead. Also aligns boost_prepare_process_table()'s dead CREATE TABLE IF NOT EXISTS fallback with the corrected schema, and adds an upgrade step that rebuilds both indexes as BTREE on existing installs (checked via information_schema.STATISTICS, since db_install_add_key() only compares index columns, not algorithm, and would otherwise skip an existing HASH index with the right column list). Closes #7532 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): skip the UNION ALL wrapper for the single-archive-table case boost_process_local_data_ids() always wrapped its flush query in "SELECT * FROM (... UNION ALL ...) t ORDER BY ...", even when there was only one archive table -- the steady-state case, since old archive tables are dropped at the end of each cycle. UNION ALL results wrapped in a derived table are one of the documented cases MySQL/MariaDB's derived-table merging doesn't apply to, so every pass paid for a filesort over an indexless derived table regardless of how well-ordered the underlying archive table's rows already were. Extracts the per-table SELECT into boost_archive_select_sql() and uses it directly, without the wrapper, when there's exactly one archive table. The UNION ALL/derived-table shape is kept as the fallback for the genuinely rare case where a prior crashed run left more than one archive table behind. Verified against a real MariaDB 11 instance and via a SQLite-backed integration test that both shapes return correctly filtered and sorted rows. Closes #7533 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): batch-prefetch RRD field name lookups instead of per-row queries Two N+1 patterns in the Boost hot paths: boost_process_local_data_ids() (poller_boost.php) re-ran the unused_data_source_names/nt_rrd_field_names lookups on every local_data_id boundary in the sorted flush result set. process_poller_output() (lib/poller.php) had the equivalent lookup, but without even a boundary check -- it re-ran per matching row whenever the output contained a ':', including consecutive rows for the same data source. Adds poller_prefetch_rrd_field_names() (lib/poller.php, shared by both callers since poller_boost.php already requires lib/poller.php) to batch-fetch these mappings once per invocation via IN()-batched queries keyed by the full set of local_data_ids in the batch, chunked at 1000 ids to match the array_chunk(..., 1000) convention used elsewhere (lib/api_data_source.php, lib/api_graph.php). Both call sites now look up the prefetched maps in-memory inside their loops. While consolidating the lookups, fixes a latent bug the old process_poller_output() code had in its non-templated fallback branch: a query referencing gti.task_item_id without joining graph_templates_item, which throws an unknown-column error whenever that path is hit. The batched query always joins correctly. Integration test proves both correctness (against a real SQLite-backed schema) and the query-count reduction: 5 local_data_ids that would previously cost up to 10 queries for these two lookups now cost exactly 3, regardless of how many ids are in the batch. Closes #7534 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * refactor(boost): consolidate poller_output_boost inserts into one helper The INSERT INTO poller_output_boost (...) VALUES ... statement was hand-written four times: three in cmd.php (behind the same boost_redirect == 'on' && boost_rrd_update_enable == 'on' guard) using INSERT IGNORE, and once in boost_poller_on_demand() (lib/boost.php) using ON DUPLICATE KEY UPDATE. Beyond the duplication, that was a real data-integrity inconsistency between two paths writing the same shape of data: silently-drop vs last-write-wins on the same key. Since boost_redirect gates cmd.php's writes and boost_poller_on_demand() returns early when boost_redirect is on, the two paths never run concurrently for the same install; they're alternate write paths selected by that one setting, not competing writers. A genuine collision on (local_data_id, rrd_name, time) in either path represents a retry of an already-collected sample -- a batch resent after a partial failure, or two writers racing on the same rounded second -- not two independently meaningful readings, since RRD only stores one value per timestamp regardless. Standardizes on INSERT IGNORE (first write wins), matching the sibling poller_output insert this table shadows, which has always used INSERT IGNORE, and 3 of the 4 existing call sites. Extracts boost_flush_output_batch() (lib/boost.php) as the one shared helper, handling both the batched INSERT and the max_allowed_packet-safe chunking cmd.php's three sites previously didn't do at all (they only chunked by item count). Integration test exercises a genuine duplicate-key collision against a real (SQLite-backed) table and asserts the chosen first-write-wins behavior. Closes #7536 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(tests): resolve cross-branch test interactions found by full-suite verification Two test-only bugs surfaced only when all four fix branches for the poller_item/Boost hardening pass ran together, not when each ran in isolation: - BoostArchiveHandoffIntegrationTest extracted the archive-forward INSERT IGNORE by searching lib/boost.php for the first occurrence of "INSERT IGNORE INTO poller_output_boost". The Boost SQL-optimization branch adds a second, earlier occurrence (the new boost_flush_output_batch() helper's single-line prefix), so the naive search now grabbed the wrong snippet. Anchor on the multi-line "...poller_output_boost\n\tSELECT *" shape that only the archive-forward statement has. - PollerItemRemotePurgeHandOffTest calls push_out_host() (via api_data_source_change_host()), which needs CACTI_PATH_LIBRARY. The test never requires include/global.php (which normally defines it), and was relying on some other file in a full multi-thousand-file suite run having defined it first as a process-wide side effect. Define it directly instead of depending on unguaranteed load order. No production code changed. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * style(poller): satisfy php-cs-fixer strict_param in the script-server count Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): order the single-archive-table flush by the table's PK columns Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): key the max_allowed_packet cache per connection Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(tests): restore the default db_* session after swapping it for a fake handle Left swapped, the fake handle answered every later read_config_option() in the run; on PHP 8.4 the extra deprecation traffic routed through CactiErrorHandler turned that into an uncaught PDOException and killed the pest process. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> Co-authored-by: TheWitness <thewitness@cacti.net>
362 lines
18 KiB
PHP
362 lines
18 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 |
|
|
| of the License, or (at your option) any later version. |
|
|
+-------------------------------------------------------------------------+
|
|
| Cacti: The Complete RRDtool-based Graphing Solution |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
/*
|
|
* Hand-off tests for #7521/#7524: a data source's ownership moving from one
|
|
* remote Data Collector to another (or being removed/converted) is a
|
|
* hand-off between two physically separate poller databases. The bug in
|
|
* all three fixed sites was the same shape: the "new" side got written but
|
|
* the "old" side's poller_item row was left behind, so the old Data
|
|
* Collector kept polling an item that no longer belonged to it.
|
|
*
|
|
* These tests exercise the real, unmodified production functions --
|
|
* api_data_source_change_host(), data_query_remove_disabled_items(), and
|
|
* data_source_to_data_template() -- against two live database connections
|
|
* standing in for the central database and a remote Data Collector's
|
|
* database, per the project's established two-database stand-in pattern
|
|
* (see tests/Unit/DbExecutePreparedTest.php and
|
|
* tests/Unit/DbReplaceSqlSaveTest.php for the same style applied to
|
|
* lower-level db_* functions).
|
|
*
|
|
* poller_push_to_remote_db_connect()/poller_connect_to_remote() are the
|
|
* real, unmodified functions, exercised two different ways here:
|
|
*
|
|
* - poller_connect_to_remote() looks up the `poller` row via
|
|
* db_fetch_row_prepared(), which gates its result on
|
|
* PDOStatement::rowCount() (lib/database.php:910). The PDO sqlite driver
|
|
* always reports 0 for a SELECT's rowCount(), so that lookup silently
|
|
* returns [] against sqlite regardless of how the fixture is seeded --
|
|
* the same limitation already documented in
|
|
* tests/Unit/DbExecutePreparedTest.php's skipped case. The central/
|
|
* default connection therefore has to speak real MySQL protocol; this
|
|
* file connects to a MariaDB container (see "Docker fixture" below) and
|
|
* skips itself cleanly if one isn't reachable, the same graceful
|
|
* fall-back tests/HandOff/CactiSettingsHandOffTest.php uses when its
|
|
* target feature isn't present on the branch.
|
|
* - Reaching the "old poller" branch without a live TCP connection to a
|
|
* second MySQL server exploits db_connect_real()'s own connection cache
|
|
* (lib/database.php:55 - `$database_sessions["$device:$port:$db_name"]`):
|
|
* pre-seeding that cache with the "remote" PDO handle, keyed by the
|
|
* exact host/port/dbname the fixture's `poller` row carries, makes
|
|
* db_connect_real() return the fake connection on the first cache
|
|
* lookup -- before it ever builds a real DSN. DELETE statements don't
|
|
* depend on rowCount(), so a plain sqlite::memory: handle is fine for
|
|
* this side.
|
|
*
|
|
* Neither of these depends on POLLER_ID, which the shared Pest process may
|
|
* already have pinned to 1 via a prior test file's UnitStubs.php include
|
|
* (HandOff runs after Unit per phpunit.xml's testsuite order, so it always
|
|
* has been in a full-suite run).
|
|
*
|
|
* Docker fixture: set CACTI_HANDOFF_MYSQL_HOST/_PORT/_USER/_PASS/_DB to
|
|
* point at an already-running MySQL/MariaDB instance. With no env set, the
|
|
* fixture tries 127.0.0.1:33071 (root/root, db "handoff_central") -- start
|
|
* one locally with:
|
|
* docker run -d --rm --name cacti-handoff-poller-test \
|
|
* -e MARIADB_ROOT_PASSWORD=root -e MARIADB_DATABASE=handoff_central \
|
|
* -p 33071:3306 mariadb:11
|
|
*/
|
|
|
|
require_once dirname(__DIR__) . '/Helpers/UnitStubs.php';
|
|
require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php';
|
|
require_once dirname(__DIR__, 2) . '/lib/database.php';
|
|
|
|
// push_out_host() (exercised via api_data_source_change_host()) needs
|
|
// CACTI_PATH_LIBRARY to include_once() a sibling lib/ file. This file never
|
|
// requires include/global.php (which normally defines it via
|
|
// include/global_path.php), so define it directly rather than depending on
|
|
// some other test file in the suite having bootstrapped it first as a
|
|
// process-wide side effect -- that ordering isn't guaranteed when this file
|
|
// runs standalone or alongside a different subset of tests.
|
|
if (!defined('CACTI_PATH_LIBRARY')) {
|
|
define('CACTI_PATH_LIBRARY', dirname(__DIR__, 2) . '/lib');
|
|
}
|
|
|
|
function handoffMysqlDsnParts(): array {
|
|
return [
|
|
'host' => getenv('CACTI_HANDOFF_MYSQL_HOST') ?: '127.0.0.1',
|
|
'port' => (int) (getenv('CACTI_HANDOFF_MYSQL_PORT') ?: 33071),
|
|
'user' => getenv('CACTI_HANDOFF_MYSQL_USER') ?: 'root',
|
|
'pass' => getenv('CACTI_HANDOFF_MYSQL_PASS') ?: 'root',
|
|
'db' => getenv('CACTI_HANDOFF_MYSQL_DB') ?: 'handoff_central',
|
|
];
|
|
}
|
|
|
|
function handoffTryConnectMysql(): ?PDO {
|
|
$parts = handoffMysqlDsnParts();
|
|
|
|
try {
|
|
return new PDO(
|
|
"mysql:host={$parts['host']};port={$parts['port']};dbname={$parts['db']};charset=utf8",
|
|
$parts['user'],
|
|
$parts['pass'],
|
|
[PDO::ATTR_TIMEOUT => 2, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
|
);
|
|
} catch (Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (handoffTryConnectMysql() === null) {
|
|
$parts = handoffMysqlDsnParts();
|
|
|
|
test("Hand-off: {$parts['host']}:{$parts['port']} not reachable, skipping poller_item remote-purge hand-off tests")
|
|
->skip('no MySQL/MariaDB reachable for the central-connection fixture -- see file header for how to start one');
|
|
|
|
return;
|
|
}
|
|
|
|
if (!defined('MESSAGE_LEVEL_WARN')) {
|
|
define('MESSAGE_LEVEL_WARN', 2);
|
|
}
|
|
|
|
require_once dirname(__DIR__, 2) . '/lib/poller.php';
|
|
|
|
// api_data_source_change_host()/data_source_to_data_template() call these;
|
|
// neither touches poller_item or anything this test asserts on, so they are
|
|
// stubbed to no-ops rather than pulling in lib/variables.php + lib/utility.php
|
|
// and their own dependency trees.
|
|
if (!function_exists('push_out_host')) {
|
|
function push_out_host(int $host_id, int $local_data_id = 0, int $data_template_id = 0) : void {
|
|
}
|
|
}
|
|
|
|
if (!function_exists('update_data_source_title_cache')) {
|
|
function update_data_source_title_cache(int $local_data_id) : void {
|
|
}
|
|
}
|
|
|
|
// data_source_to_data_template() calls get_hash_data_template(), which
|
|
// lives in lib/functions.php alongside hundreds of unrelated functions
|
|
// (several of which collide with tests/Helpers/UnitStubs.php's stubs, e.g.
|
|
// read_user_setting()). Stub the one function this test path needs instead
|
|
// of pulling in that whole file.
|
|
if (!function_exists('get_hash_data_template')) {
|
|
function get_hash_data_template(int $data_template_id, string $sub_type = 'data_template') : string {
|
|
return md5($sub_type . ':' . $data_template_id . ':' . random_int(0, PHP_INT_MAX));
|
|
}
|
|
}
|
|
|
|
// data_query_remove_disabled_items() calls array_rekey(); also lives in
|
|
// lib/functions.php, same collision concern as get_hash_data_template()
|
|
// above.
|
|
if (!function_exists('array_rekey')) {
|
|
function array_rekey(mixed $array, string $key, mixed $key_value) : array {
|
|
$ret_array = [];
|
|
|
|
if (is_array($array)) {
|
|
foreach ($array as $item) {
|
|
$ret_array[$item[$key]] = $item[$key_value];
|
|
}
|
|
}
|
|
|
|
return $ret_array;
|
|
}
|
|
}
|
|
|
|
require_once dirname(__DIR__, 2) . '/lib/api_data_source.php';
|
|
require_once dirname(__DIR__, 2) . '/lib/data_query.php';
|
|
require_once dirname(__DIR__, 2) . '/lib/template.php';
|
|
|
|
const HANDOFF_OLD_POLLER_ID = 7;
|
|
const HANDOFF_NEW_POLLER_ID = 1; // central; keeps the (unrelated, already-correct) "push to new" side inert
|
|
|
|
// Fake connection-string parts for the old remote poller. Must not be
|
|
// 'localhost'/'127.0.0.1' -- poller_connect_to_remote() treats those as a
|
|
// self-referential loop and bails out with false before ever calling
|
|
// db_connect_real().
|
|
const HANDOFF_OLD_POLLER_DBHOST = 'remote-poller-a.fixture';
|
|
const HANDOFF_OLD_POLLER_DBPORT = 3306;
|
|
const HANDOFF_OLD_POLLER_DBNAME = 'remote_a';
|
|
|
|
/*
|
|
* Builds the central database on the real MySQL/MariaDB fixture: host,
|
|
* data_local, poller, and poller_item tables with just the columns the
|
|
* fixed functions actually read. Dropped and recreated per test so state
|
|
* never leaks between cases.
|
|
*/
|
|
function handoffCentralConn(): PDO {
|
|
$conn = handoffTryConnectMysql();
|
|
|
|
foreach (['poller_item', 'poller', 'data_template_rrd', 'data_template', 'data_template_data', 'data_local', 'host', 'poller_output_boost'] as $table) {
|
|
$conn->exec("DROP TABLE IF EXISTS $table");
|
|
}
|
|
|
|
$conn->exec('CREATE TABLE host (id INTEGER PRIMARY KEY, poller_id INTEGER)');
|
|
$conn->exec('CREATE TABLE data_local (id INTEGER PRIMARY KEY, host_id INTEGER)');
|
|
$conn->exec('CREATE TABLE data_template_data (id INTEGER PRIMARY KEY AUTO_INCREMENT, local_data_id INTEGER, local_data_template_data_id INTEGER, data_template_id INTEGER, name VARCHAR(255), active VARCHAR(3))');
|
|
$conn->exec('CREATE TABLE data_template (id INTEGER PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255), hash VARCHAR(32))');
|
|
$conn->exec('CREATE TABLE data_template_rrd (id INTEGER PRIMARY KEY AUTO_INCREMENT, local_data_id INTEGER, local_data_template_rrd_id INTEGER, data_template_id INTEGER, hash VARCHAR(32))');
|
|
$conn->exec('CREATE TABLE poller (
|
|
id INTEGER PRIMARY KEY, name VARCHAR(255), dbhost VARCHAR(255), dbuser VARCHAR(64), dbpass VARCHAR(64), dbdefault VARCHAR(64),
|
|
dbport INTEGER, dbretries INTEGER, dbssl TINYINT, dbsslkey VARCHAR(255), dbsslcert VARCHAR(255), dbsslca VARCHAR(255),
|
|
dbsslcapath VARCHAR(255), dbsslverifyservercert TINYINT, last_status DATETIME, disabled VARCHAR(3))');
|
|
$conn->exec('CREATE TABLE poller_item (local_data_id INTEGER, host_id INTEGER, poller_id INTEGER)');
|
|
$conn->exec('CREATE TABLE poller_output_boost (local_data_id INTEGER)');
|
|
|
|
// The row poller_connect_to_remote() looks up for HANDOFF_OLD_POLLER_ID.
|
|
// remote_poller_up() additionally needs a recent heartbeat + disabled=''.
|
|
// tests/Helpers/UnitStubs.php stubs read_config_option() to always
|
|
// return false, so remote_poller_up()'s $gone_time (poller_interval * 2)
|
|
// is always 0 -- "UNIX_TIMESTAMP() - UNIX_TIMESTAMP(last_status) < 0"
|
|
// can then only be true if last_status is in the future. NOW() + 60s
|
|
// keeps the check satisfied without needing to touch that shared stub.
|
|
$stmt = $conn->prepare('INSERT INTO poller
|
|
(id, name, dbhost, dbuser, dbpass, dbdefault, dbport, dbretries, dbssl,
|
|
dbsslkey, dbsslcert, dbsslca, dbsslcapath, dbsslverifyservercert, last_status, disabled)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW() + INTERVAL 60 SECOND, \'\')');
|
|
$stmt->execute([
|
|
HANDOFF_OLD_POLLER_ID, 'Remote Poller A', HANDOFF_OLD_POLLER_DBHOST, 'user', 'pass',
|
|
HANDOFF_OLD_POLLER_DBNAME, HANDOFF_OLD_POLLER_DBPORT, 0, 0, '', '', '', '', 0,
|
|
]);
|
|
|
|
return $conn;
|
|
}
|
|
|
|
function handoffRemoteConn(): PDO {
|
|
$conn = new PDO('sqlite::memory:');
|
|
$conn->exec('CREATE TABLE poller_item (local_data_id INTEGER, host_id INTEGER, poller_id INTEGER)');
|
|
// mirrors the subset of central's schema that the (pre-existing,
|
|
// unmodified) "push to new device" side writes to when the new device
|
|
// also happens to live on this same poller.
|
|
$conn->exec('CREATE TABLE data_local (id INTEGER PRIMARY KEY, host_id INTEGER)');
|
|
|
|
return $conn;
|
|
}
|
|
|
|
function handoffPollerItemCount(PDO $conn, string $where = '1=1'): int {
|
|
return (int) $conn->query("SELECT COUNT(*) FROM poller_item WHERE $where")->fetchColumn();
|
|
}
|
|
|
|
beforeEach(function () {
|
|
global $database_hostname, $database_port, $database_default, $database_sessions;
|
|
|
|
$this->db_globals = [$database_sessions, $database_hostname, $database_port, $database_default];
|
|
|
|
$parts = handoffMysqlDsnParts();
|
|
|
|
$database_hostname = $parts['host'];
|
|
$database_port = $parts['port'];
|
|
$database_default = $parts['db'];
|
|
|
|
$this->central = handoffCentralConn();
|
|
$this->oldRemote = handoffRemoteConn();
|
|
|
|
// db_connect_real() checks this cache by "$device:$port:$db_name" before
|
|
// building a DSN or opening a socket -- see file header.
|
|
$database_sessions = [
|
|
"$database_hostname:$database_port:$database_default" => $this->central,
|
|
HANDOFF_OLD_POLLER_DBHOST . ':' . HANDOFF_OLD_POLLER_DBPORT . ':' . HANDOFF_OLD_POLLER_DBNAME => $this->oldRemote,
|
|
];
|
|
});
|
|
|
|
// Put the default db_* connection back. Left in place, the test handles answer
|
|
// every later read_config_option() in the run and throw 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;
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// #7521: api_data_source_change_host() moving a data source off a
|
|
// remote-managed device must purge the old remote poller's row.
|
|
// ---------------------------------------------------------------------
|
|
|
|
it('purges the stale poller_item row from the old remote poller when a data source changes host (#7521 fix)', function () {
|
|
// old device is managed by the remote poller; new device is central
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (200, ' . HANDOFF_NEW_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO data_local (id, host_id) VALUES (500, 100)');
|
|
|
|
// the row as it exists on the old poller today, mid-flight
|
|
$this->oldRemote->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
|
|
api_data_source_change_host([500], 200);
|
|
|
|
expect(handoffPollerItemCount($this->oldRemote, 'local_data_id = 500'))->toBe(0)
|
|
->and((int) $this->central->query('SELECT host_id FROM data_local WHERE id = 500')->fetchColumn())->toBe(200);
|
|
});
|
|
|
|
it('demonstrates the pre-#7521 bug: the unfixed logic leaves the old poller row behind', function () {
|
|
// Faithful reproduction of api_data_source_change_host() as filed in
|
|
// #7521 -- pushes to the new device's remote poller but never purges
|
|
// the old one. Kept local to this test file (not sourced from
|
|
// lib/api_data_source.php) purely as a regression pin: if this
|
|
// "legacy" shape ever again matched the real function, this test
|
|
// would start failing for the wrong reason and flag the drift.
|
|
$legacyChangeHost = function (array $data_sources, int $device_id) {
|
|
foreach ($data_sources as $data_source) {
|
|
db_execute_prepared('UPDATE data_local SET host_id = ? WHERE id = ?', [$device_id, $data_source]);
|
|
|
|
if (($rcnn_id = poller_push_to_remote_db_connect($device_id)) !== false) {
|
|
db_execute_prepared('UPDATE data_local SET host_id = ? WHERE id = ?', [$device_id, $data_source], true, $rcnn_id);
|
|
}
|
|
}
|
|
};
|
|
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (200, ' . HANDOFF_NEW_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO data_local (id, host_id) VALUES (500, 100)');
|
|
$this->oldRemote->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
|
|
$legacyChangeHost([500], 200);
|
|
|
|
// this is the bug: the old Data Collector keeps polling item 500 forever
|
|
expect(handoffPollerItemCount($this->oldRemote, 'local_data_id = 500'))->toBe(1);
|
|
});
|
|
|
|
it('leaves the remote row alone when the data source stays on the same poller', function () {
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (101, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO data_local (id, host_id) VALUES (500, 100)');
|
|
$this->oldRemote->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
|
|
// reassigned to a different device that is on the SAME remote poller
|
|
api_data_source_change_host([500], 101);
|
|
|
|
expect(handoffPollerItemCount($this->oldRemote, 'local_data_id = 500'))->toBe(1);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// #7524a: data_query_remove_disabled_items() orphan removal.
|
|
// ---------------------------------------------------------------------
|
|
|
|
it('purges orphaned poller_item rows from the remote poller during data query re-index (#7524 fix)', function () {
|
|
$this->central->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->oldRemote->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
|
|
data_query_remove_disabled_items([500]);
|
|
|
|
expect(handoffPollerItemCount($this->central, 'local_data_id = 500'))->toBe(0)
|
|
->and(handoffPollerItemCount($this->oldRemote, 'local_data_id = 500'))->toBe(0);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// #7524b: data_source_to_data_template() conversion delete.
|
|
// ---------------------------------------------------------------------
|
|
|
|
it('purges the remote poller_item row when converting a data source to a template (#7524 fix)', function () {
|
|
$this->central->exec('INSERT INTO host (id, poller_id) VALUES (100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
$this->central->exec('INSERT INTO data_local (id, host_id) VALUES (500, 100)');
|
|
$this->central->exec("INSERT INTO data_template_data (id, local_data_id, name, active) VALUES (1, 500, 'ds', 'on')");
|
|
$this->oldRemote->exec('INSERT INTO poller_item (local_data_id, host_id, poller_id) VALUES (500, 100, ' . HANDOFF_OLD_POLLER_ID . ')');
|
|
|
|
data_source_to_data_template(500, '<ds_title>');
|
|
|
|
expect((int) $this->central->query('SELECT COUNT(*) FROM data_local WHERE id = 500')->fetchColumn())->toBe(0)
|
|
->and(handoffPollerItemCount($this->oldRemote, 'local_data_id = 500'))->toBe(0);
|
|
});
|