Files
cacti/tests/HandOff/PollerItemRemotePurgeHandOffTest.php
Thomas VincentandTheWitness db39178735 fix: harden the poller_item cache and Boost RRD staging subsystems (#7538)
* 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>
2026-08-05 14:07:21 -07:00

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