O(1) round-trip * reduction the issue describes, not just asserting it algebraically. * * See UpdateDataQueryCacheBatchingStructureTest.php for the companion test * that proves update_data_query_cache()'s actual loop body calls things in * this per-row-vs-batched shape in the first place. */ /* poller_update_poller_cache_from_buffer() records a last-changed timestamp on every flush. That used to be stubbed out behind function_exists(), but the stub only applied when nothing had already loaded lib/functions.php, and the unit suite always has by the time this file runs. The real function then ran instead, dereferenced CACTI_PATH_LIBRARY and fataled, and its extra query changed the counts below. Load the real one deliberately and give it what it needs, so this file behaves the same whatever ran before it. */ /* Guarded defines rather than require_once of include/global_constants.php: that file defines unconditionally, and sibling tests (Helpers/UnitStubs.php, CmdPhpPollerItemColumnCollisionIntegrationTest) already define a subset of the same names, so requiring it here emits "already defined" warnings on any ordering where one of those ran first. Guarded defines emit none either way. */ foreach ([ 'CACTI_WEB' => false, 'POLLER_ID' => 1, 'CACTI_PATH_BASE' => dirname(__DIR__, 2), 'CACTI_PATH_LIBRARY' => dirname(__DIR__, 2) . '/lib', 'OPTIONS_CLI' => 'config_options_array', 'OPTIONS_WEB' => 'sess_config_array', ] as $name => $value) { if (!defined($name)) { define($name, $value); } elseif (constant($name) !== $value) { throw new RuntimeException("$name has an incompatible value for the batching integration test"); } } // An existing suite bootstrap may point logging at another valid directory. if (!defined('CACTI_PATH_LOG')) { define('CACTI_PATH_LOG', sys_get_temp_dir()); } require_once dirname(__DIR__, 2) . '/lib/functions.php'; /* after lib/functions.php: UnitStubs guards with function_exists(), so loading it first would make the real definitions a redeclaration fatal */ require_once dirname(__DIR__) . '/Helpers/UnitStubs.php'; require_once dirname(__DIR__) . '/Helpers/FakeMySQLPDO.php'; require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php'; require_once dirname(__DIR__, 2) . '/lib/database.php'; require_once dirname(__DIR__, 2) . '/lib/utility.php'; function batching_seed_poller_item_table() : FakeMySQLPDO { $conn = new FakeMySQLPDO(); // set_config_option() writes the last-changed timestamp here $conn->exec('CREATE TABLE settings (name TEXT PRIMARY KEY, value TEXT)'); $conn->exec('CREATE TABLE poller_item ( local_data_id INTEGER PRIMARY KEY, poller_id INTEGER, host_id INTEGER, action INTEGER, hostname TEXT, snmp_community TEXT, snmp_version INTEGER, snmp_timeout INTEGER, snmp_retries INTEGER, snmp_username TEXT, snmp_password TEXT, snmp_auth_protocol TEXT, snmp_priv_passphrase TEXT, snmp_priv_protocol TEXT, snmp_context TEXT, snmp_engine_id TEXT, snmp_port INTEGER, rrd_name TEXT, rrd_path TEXT, rrd_num INTEGER, rrd_step INTEGER, rrd_next_step INTEGER, arg1 TEXT, arg2 TEXT, arg3 TEXT, present INTEGER )'); return $conn; } beforeEach(function () { global $database_sessions, $database_hostname, $database_port, $database_default; $this->db_globals = [$database_sessions, $database_hostname, $database_port, $database_default]; }); // 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 batching_wire_default_connection(PDO $conn) : void { $GLOBALS['database_hostname'] = 'unittest'; $GLOBALS['database_port'] = 0; $GLOBALS['database_default'] = 'unittest'; $GLOBALS['config'] = $GLOBALS['config'] ?? []; $GLOBALS['database_total_queries'] = 0; $GLOBALS['database_sessions'] = ['unittest:0:unittest' => $conn]; } function batching_tuple(int $id) : string { // matches poller_update_poller_cache_from_buffer()'s fixed 26-column // INSERT list: local_data_id, poller_id, host_id, action, hostname, // snmp_community, snmp_version, snmp_timeout, snmp_retries, // snmp_username, snmp_password, snmp_auth_protocol, snmp_priv_passphrase, // snmp_priv_protocol, snmp_context, snmp_engine_id, snmp_port, rrd_name, // rrd_path, rrd_num, rrd_step, rrd_next_step, arg1, arg2, arg3, present return "($id, 1, 10, 0, 'host', 'community', 2, 500, 3, '', '', '', '', '', '', '', 161, 'ds$id', '', 1, 300, 0, '', '', '', 1)"; } test('batching 3 changed rows into a single poller_update_poller_cache_from_buffer() call issues fewer SQL statements than 3 separate per-row calls', function () { // old behavior: update_poller_cache($data_source, true) flushed // immediately, once per changed row $conn = batching_seed_poller_item_table(); batching_wire_default_connection($conn); foreach ([1, 2, 3] as $id) { $idsArg = [$id]; $itemsArg = [batching_tuple($id)]; poller_update_poller_cache_from_buffer($idsArg, $itemsArg, 1); } $perRowQueries = $GLOBALS['database_total_queries']; // new behavior: accumulate across the loop, flush once $conn = batching_seed_poller_item_table(); batching_wire_default_connection($conn); $ids = [1, 2, 3]; $tuples = array_map('batching_tuple', $ids); poller_update_poller_cache_from_buffer($ids, $tuples, 1); $batchedQueries = $GLOBALS['database_total_queries']; expect($batchedQueries)->toBeLessThan($perRowQueries); /* each flush does a present=0 UPDATE, an INSERT, a stale-row DELETE, the set_config_option() last-changed write, and a SELECT that scans the flushed rows for SNMPv3 items to gate the shared credential-cache version bump (#8166), so 3 separate flushes cost 15 statements and 1 batched flush costs 5 */ expect($perRowQueries)->toBe(15) ->and($batchedQueries)->toBe(5); }); test('the batched flush leaves the same end-state as 3 per-row flushes would', function () { $conn = batching_seed_poller_item_table(); batching_wire_default_connection($conn); $ids = [1, 2, 3]; $tuples = array_map('batching_tuple', $ids); poller_update_poller_cache_from_buffer($ids, $tuples, 1); $rows = $conn->query('SELECT local_data_id, present FROM poller_item ORDER BY local_data_id')->fetchAll(PDO::FETCH_ASSOC); expect($rows)->toBe([ ['local_data_id' => 1, 'present' => 1], ['local_data_id' => 2, 'present' => 1], ['local_data_id' => 3, 'present' => 1], ]); });