mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
Harden Boost data handoffs and graph cache writes (#7729)
* fix(boost): harden data handoffs and cache writes Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * Define $updates_ok when there is nothing to flush PHPStan level 6 also flags the poller ownership check, which is the POLLER_ID constant case the file already annotates above. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * Use a line comment to match the fixer Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * Record the boost process columns in the audit schema Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: resolve includes through Cacti path constants Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * Fix Boost handoff review concerns Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: seed settings in Boost database fixture Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: refresh SQL interpolation baseline Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * docs: record Boost handoff hardening Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix: stop boost discarding a cycle and wedging a shard Three defects in the handoff hardening. A rejected ownership handoff returned false, which the poller reads as boost having consumed the data, so it skipped the RRD write. The poller_output rows were already deleted by then, so a stale poller_item copy on a remote poller silently lost the whole cycle. Return true instead and let the poller write directly, matching the database failure path. Any non-OK RRDtool response retained the shard, and the retry then replayed rows already written, which RRDtool rejects as an illegal update time. That failed again on every pass, so the archive tables accumulated and boost never drained. Treat an at-or-behind timestamp as applied, since the sample is in the RRD; genuine errors still retain the shard. The two new poller_output_boost_processes columns passed 'null' where db_install_update_table reads 'NULL', so NOT NULL was never emitted and upgraded installs diverged from cacti.sql. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix: spell the NULL column key so upgrades emit NOT NULL db_add_column() reads $column['NULL'] and PHP array keys are case sensitive, so the thirteen definitions in the 1.3.0 upgrade that passed a lowercase 'null' key had the flag discarded and created nullable columns. Fresh installs built from cacti.sql got NOT NULL, upgraded installs did not, and Cacti's own schema audit reports the difference. The same file already spells the key in upper case 144 times, and the wrapper's own docblock example uses the upper case form. Guard the class with a test over every upgrade file rather than only the one that was wrong. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: assert the NULL key plainly toContain() takes needles rather than a failure message. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): close remaining handoff review gaps Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): guard nullable graph output Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): keep in-flight completion rows through the 1.3.0 upgrade The truncate only existed so the run_child UNIQUE key could build over rows that predate run_id; deleting just those rows leaves a running boost its completion records. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * refactor(boost): drop the poller ownership gate Its boost_poller_on_demand() call sits behind the boost_redirect early return that poller.php forces on for every multi-collector install, and its recovery call turns a device deletion on main into a recovery that never brings the collector back online. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): bound atomic cache write allocations Guard upgrade source reads so failures report clearly, write graph cache data in fixed-size chunks, and exercise a multi-chunk payload. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): support Windows cache replacement Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test(boost): expand handoff failure coverage Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test(boost): support ANSI_QUOTES upgrade checks Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): stop staging after chunk failure Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(recovery): use numeric poller status Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(sql): support ANSI_QUOTES in recovery paths Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): preserve cache on replacement failure Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): retain readable cache permissions Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): repair completion schema at runtime Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(recovery): bound timestamp batch fetches Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): satisfy runtime schema analysis Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(recovery): restore status after transfer failure Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test(boost): rely on unit bootstrap helpers Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): refresh generated cache secret Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(boost): normalize cache path separators Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test(boost): fail clearly on unreadable fixtures * fix(boost): name ignored duplicate sample keys The staging warning only said rows were dropped, so a retry could not be told from a colliding writer. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * Lack of pre-commit hook execution allowed lint failure --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> Co-authored-by: TheWitness <thewitness@cacti.net>
This commit is contained in:
1 parent
428b56e04d
commit
3c20f4ce7e
22 files changed
+1664
-275
No files matched your search
@@ -2573,8 +2573,11 @@ CREATE TABLE `poller_output_boost_local_data_ids` (
|
||||
|
||||
CREATE TABLE `poller_output_boost_processes` (
|
||||
`sock_int_value` bigint(20) unsigned NOT NULL auto_increment,
|
||||
`run_id` char(32) NOT NULL default '',
|
||||
`child_id` int(10) unsigned NOT NULL default 0,
|
||||
`status` varchar(255) default NULL,
|
||||
PRIMARY KEY (`sock_int_value`)
|
||||
PRIMARY KEY (`sock_int_value`),
|
||||
UNIQUE KEY `run_child` (`run_id`,`child_id`)
|
||||
) ENGINE=MEMORY;
|
||||
|
||||
--
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Harden Boost data handoffs and graph cache writes
|
||||
@@ -246,7 +246,7 @@ input_validate_input_number($first, 'first');
|
||||
input_validate_input_number($last, 'last');
|
||||
|
||||
if (db_column_exists('sites', 'disabled')) {
|
||||
$sql_where = 'AND IFNULL(s.disabled, "") != "on"';
|
||||
$sql_where = 'AND IFNULL(s.disabled, \'\') != \'on\'';
|
||||
} else {
|
||||
$sql_where = '';
|
||||
}
|
||||
@@ -449,10 +449,10 @@ if (cacti_sizeof($poller_items) && read_config_option('poller_enabled') == 'on')
|
||||
|
||||
if ($set_spike_kill && !substr_count($output, ':')) {
|
||||
// insert a U in place of the actual value if the snmp agent restarts
|
||||
$output_array[] = sprintf('(%d, %s, CURRENT_TIMESTAMP(), "U")', $item['local_data_id'], db_qstr($item['rrd_name']));
|
||||
$output_array[] = sprintf('(%d, %s, CURRENT_TIMESTAMP(), %s)', $item['local_data_id'], db_qstr($item['rrd_name'], $poller_db_cnn_id), db_qstr('U', $poller_db_cnn_id));
|
||||
} else {
|
||||
// otherwise, just insert the value received from the poller
|
||||
$output_array[] = sprintf('(%d, %s, CURRENT_TIMESTAMP(), %s)', $item['local_data_id'], db_qstr($item['rrd_name']), db_qstr($output));
|
||||
$output_array[] = sprintf('(%d, %s, CURRENT_TIMESTAMP(), %s)', $item['local_data_id'], db_qstr($item['rrd_name'], $poller_db_cnn_id), db_qstr($output, $poller_db_cnn_id));
|
||||
}
|
||||
|
||||
if ($output_count > 2000) {
|
||||
|
||||
@@ -960,7 +960,9 @@ INSERT INTO `table_columns` VALUES ('poller_output_boost',5,'last_updated','time
|
||||
INSERT INTO `table_columns` VALUES ('poller_output_boost_local_data_ids',1,'local_data_id','int(10) unsigned','NO','PRI','0','');
|
||||
INSERT INTO `table_columns` VALUES ('poller_output_boost_local_data_ids',2,'process_handler','int(10) unsigned','YES','MUL','0','');
|
||||
INSERT INTO `table_columns` VALUES ('poller_output_boost_processes',1,'sock_int_value','bigint(20) unsigned','NO','PRI',NULL,'auto_increment');
|
||||
INSERT INTO `table_columns` VALUES ('poller_output_boost_processes',2,'status','varchar(255)','YES','',NULL,'');
|
||||
INSERT INTO `table_columns` VALUES ('poller_output_boost_processes',2,'run_id','char(32)','NO','MUL','','');
|
||||
INSERT INTO `table_columns` VALUES ('poller_output_boost_processes',3,'child_id','int(10) unsigned','NO','','0','');
|
||||
INSERT INTO `table_columns` VALUES ('poller_output_boost_processes',4,'status','varchar(255)','YES','',NULL,'');
|
||||
INSERT INTO `table_columns` VALUES ('poller_output_realtime',1,'local_data_id','int(10) unsigned','NO','PRI','0','');
|
||||
INSERT INTO `table_columns` VALUES ('poller_output_realtime',2,'rrd_name','varchar(19)','NO','PRI','','');
|
||||
INSERT INTO `table_columns` VALUES ('poller_output_realtime',3,'time','timestamp','NO','PRI','0000-00-00 00:00:00','');
|
||||
@@ -1621,6 +1623,8 @@ INSERT INTO `table_indexes` VALUES ('poller_output_boost',1,'time',1,'time','A',
|
||||
INSERT INTO `table_indexes` VALUES ('poller_output_boost_local_data_ids',0,'PRIMARY',1,'local_data_id',NULL,0,NULL,NULL,'','HASH','');
|
||||
INSERT INTO `table_indexes` VALUES ('poller_output_boost_local_data_ids',1,'process_handler',1,'process_handler',NULL,0,NULL,NULL,'YES','HASH','');
|
||||
INSERT INTO `table_indexes` VALUES ('poller_output_boost_processes',0,'PRIMARY',1,'sock_int_value',NULL,0,NULL,NULL,'','HASH','');
|
||||
INSERT INTO `table_indexes` VALUES ('poller_output_boost_processes',0,'run_child',1,'run_id',NULL,0,NULL,NULL,'','HASH','');
|
||||
INSERT INTO `table_indexes` VALUES ('poller_output_boost_processes',0,'run_child',2,'child_id',NULL,0,NULL,NULL,'','HASH','');
|
||||
INSERT INTO `table_indexes` VALUES ('poller_output_realtime',1,'poller_id',1,'poller_id','A',0,'191',NULL,'','BTREE','');
|
||||
INSERT INTO `table_indexes` VALUES ('poller_output_realtime',0,'PRIMARY',1,'local_data_id','A',0,NULL,NULL,'','BTREE','');
|
||||
INSERT INTO `table_indexes` VALUES ('poller_output_realtime',0,'PRIMARY',2,'rrd_name','A',0,NULL,NULL,'','BTREE','');
|
||||
|
||||
+49
-13
@@ -30,12 +30,12 @@ function upgrade_to_1_3_0() : void {
|
||||
PRIMARY KEY (key_id)
|
||||
) ENGINE=InnoDB COMMENT='Serializes Cacti Process Registry Mutations'");
|
||||
|
||||
db_install_change_column('version', ['name' => 'cacti', 'type' => 'char(30)', 'null' => false, 'default' => '']);
|
||||
db_install_change_column('version', ['name' => 'cacti', 'type' => 'char(30)', 'NULL' => false, 'default' => '']);
|
||||
|
||||
db_install_add_column('user_auth', ['name' => 'tfa_enabled', 'type' => 'char(3)', 'null' => false, 'default' => '']);
|
||||
db_install_add_column('user_auth', ['name' => 'tfa_secret', 'type' => 'char(50)', 'null' => false, 'default' => '']);
|
||||
db_install_add_column('user_auth', ['name' => 'tfa_enabled', 'type' => 'char(3)', 'NULL' => false, 'default' => '']);
|
||||
db_install_add_column('user_auth', ['name' => 'tfa_secret', 'type' => 'char(50)', 'NULL' => false, 'default' => '']);
|
||||
|
||||
db_install_add_column('poller', ['name' => 'log_level', 'type' => 'int', 'null' => false, 'default' => '-1', 'after' => 'status']);
|
||||
db_install_add_column('poller', ['name' => 'log_level', 'type' => 'int', 'NULL' => false, 'default' => '-1', 'after' => 'status']);
|
||||
db_install_add_column('poller', ['name' => 'dbsslkey', 'type' => 'varchar(255)', 'after' => 'dbssl']);
|
||||
db_install_add_column('poller', ['name' => 'dbsslcert', 'type' => 'varchar(255)', 'after' => 'dbsslkey']);
|
||||
db_install_add_column('poller', ['name' => 'dbsslca', 'type' => 'varchar(255)', 'after' => 'dbsslcert']);
|
||||
@@ -60,10 +60,10 @@ function upgrade_to_1_3_0() : void {
|
||||
db_install_add_column('graph_templates_item', ['name' => 'alpha2', 'type' => 'char(2)', 'default' => 'FF', 'after' => 'color2_id']);
|
||||
db_install_add_column('graph_templates_item', ['name' => 'gradheight', 'type' => 'tinyint(4)', 'default' => '50', 'after' => 'alpha2']);
|
||||
|
||||
db_install_add_column('sites', ['name' => 'disabled', 'type' => 'char(2)', 'null' => false, 'default' => '', 'after' => 'name']);
|
||||
db_install_add_column('sites', ['name' => 'region', 'type' => 'varchar(30)', 'null' => false, 'default' => '', 'after' => 'country']);
|
||||
db_install_add_column('sites', ['name' => 'disabled', 'type' => 'char(2)', 'NULL' => false, 'default' => '', 'after' => 'name']);
|
||||
db_install_add_column('sites', ['name' => 'region', 'type' => 'varchar(30)', 'NULL' => false, 'default' => '', 'after' => 'country']);
|
||||
|
||||
db_install_add_column('user_domains_ldap', ['name' => 'tls_certificate', 'type' => 'tinyint(3)', 'unsigned' => true, 'null' => false, 'default' => '3']);
|
||||
db_install_add_column('user_domains_ldap', ['name' => 'tls_certificate', 'type' => 'tinyint(3)', 'unsigned' => true, 'NULL' => false, 'default' => '3']);
|
||||
|
||||
db_install_add_column('graph_templates_graph', ['name' => 't_left_axis_format', 'type' => 'char(2)', 'default' => '', 'after' => 'right_axis_formatter']);
|
||||
db_install_add_column('graph_templates_graph', ['name' => 'left_axis_format', 'type' => 'mediumint(8)', 'NULL' => true, 'after' => 't_left_axis_format']);
|
||||
@@ -85,9 +85,9 @@ function upgrade_to_1_3_0() : void {
|
||||
db_install_add_column('data_input_data', ['name' => 'local_data_id', 'type' => 'int', 'unsigned' => true, 'NULL' => false, 'default' => '0', 'after' => 'data_template_id']);
|
||||
db_install_add_column('data_input_data', ['name' => 'host_id', 'type' => 'int', 'unsigned' => true, 'NULL' => false, 'default' => '0', 'after' => 'local_data_id']);
|
||||
|
||||
db_install_add_column('graph_templates', ['name' => 'last_updated', 'type' => 'timestamp', 'null' => false, 'default' => 'CURRENT_TIMESTAMP']);
|
||||
db_install_add_column('data_template', ['name' => 'last_updated', 'type' => 'timestamp', 'null' => false, 'default' => 'CURRENT_TIMESTAMP']);
|
||||
db_install_add_column('snmp_query', ['name' => 'last_updated', 'type' => 'timestamp', 'null' => false, 'default' => 'CURRENT_TIMESTAMP']);
|
||||
db_install_add_column('graph_templates', ['name' => 'last_updated', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']);
|
||||
db_install_add_column('data_template', ['name' => 'last_updated', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']);
|
||||
db_install_add_column('snmp_query', ['name' => 'last_updated', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']);
|
||||
|
||||
db_install_add_key('data_input_data', 'INDEX', 'data_template_id', ['data_template_id']);
|
||||
db_install_add_key('data_input_data', 'INDEX', 'local_data_id', ['local_data_id']);
|
||||
@@ -98,6 +98,8 @@ function upgrade_to_1_3_0() : void {
|
||||
db_install_add_key('sessions', 'INDEX', 'user_id', ['user_id']);
|
||||
db_install_add_key('sessions', 'INDEX', 'access', ['access']);
|
||||
|
||||
upgrade_boost_process_table();
|
||||
|
||||
// poller_output_boost_local_data_ids is a MEMORY table; its indexes default
|
||||
// to HASH unless USING BTREE is specified. Range/ORDER BY access against
|
||||
// local_data_id (boost_output_rrd_data(), boost_process_local_data_ids())
|
||||
@@ -133,8 +135,8 @@ function upgrade_to_1_3_0() : void {
|
||||
}
|
||||
}
|
||||
|
||||
db_install_add_column('host_snmp_query', ['name' => 'reindex_last_runtime', 'type' => 'timestamp', 'null' => false, 'default' => 'CURRENT_TIMESTAMP']);
|
||||
db_install_add_column('host_snmp_query', ['name' => 'reindex_last_duration', 'type' => 'double', 'unsigned' => true, 'null' => false, 'default' => '0']);
|
||||
db_install_add_column('host_snmp_query', ['name' => 'reindex_last_runtime', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']);
|
||||
db_install_add_column('host_snmp_query', ['name' => 'reindex_last_duration', 'type' => 'double', 'unsigned' => true, 'NULL' => false, 'default' => '0']);
|
||||
|
||||
db_install_execute('UPDATE data_input_data AS did
|
||||
INNER JOIN data_template_data AS dtd
|
||||
@@ -199,7 +201,7 @@ function upgrade_to_1_3_0() : void {
|
||||
db_install_update_table('plugin_archive', $data);
|
||||
|
||||
// Not sure why we were adding this...
|
||||
// db_install_add_column('user_domains', array('name' => 'tls_verify', 'type' => 'int', 'null' => false, 'default' => '0'));
|
||||
// db_install_add_column('user_domains', array('name' => 'tls_verify', 'type' => 'int', 'NULL' => false, 'default' => '0'));
|
||||
|
||||
db_install_execute('UPDATE host AS h
|
||||
LEFT JOIN sites AS s
|
||||
@@ -669,6 +671,40 @@ function upgrade_to_1_3_0() : void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring poller_output_boost_processes up to the 1.3.0 layout.
|
||||
*
|
||||
* Nothing stops cron from starting poller_boost.php while the installer runs,
|
||||
* so boost can be mid-run here. Emptying the table would discard the
|
||||
* completion rows the parent counts to decide whether every child finished,
|
||||
* leaving it to drain early, warn about a crashed child and hold the run's
|
||||
* archive tables. Only the rows written before run_id existed have to go:
|
||||
* the two ALTERs default all of them to ('', 0), which the new UNIQUE key
|
||||
* cannot accept. A child of an in-flight run records a 32 character run_id
|
||||
* and keeps its row.
|
||||
*/
|
||||
function upgrade_boost_process_table() : void {
|
||||
if (!db_table_exists('poller_output_boost_processes')) {
|
||||
db_install_execute('CREATE TABLE `poller_output_boost_processes` (
|
||||
`sock_int_value` bigint(20) unsigned NOT NULL auto_increment,
|
||||
`run_id` char(32) NOT NULL default \'\',
|
||||
`child_id` int(10) unsigned NOT NULL default 0,
|
||||
`status` varchar(255) default NULL,
|
||||
PRIMARY KEY (`sock_int_value`),
|
||||
UNIQUE KEY `run_child` (`run_id`, `child_id`)
|
||||
) ENGINE=MEMORY');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
db_install_add_column('poller_output_boost_processes', ['name' => 'run_id', 'type' => 'char(32)', 'NULL' => false, 'default' => '', 'after' => 'sock_int_value']);
|
||||
db_install_add_column('poller_output_boost_processes', ['name' => 'child_id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'default' => '0', 'after' => 'run_id']);
|
||||
|
||||
db_install_execute('DELETE FROM poller_output_boost_processes WHERE run_id = ?', ['']);
|
||||
|
||||
db_install_add_key('poller_output_boost_processes', 'UNIQUE', 'run_child', ['run_id', 'child_id']);
|
||||
}
|
||||
|
||||
function upgrade_reports() : void {
|
||||
require_once(CACTI_PATH_BASE . '/lib/api_scheduler.php');
|
||||
|
||||
|
||||
+627
-110
@@ -22,6 +22,67 @@
|
||||
+-------------------------------------------------------------------------+
|
||||
*/
|
||||
|
||||
/** Ensure Boost children can record one completion row per run and shard. */
|
||||
function boost_ensure_process_table(bool $repair_key = false) : bool {
|
||||
if (!db_table_exists('poller_output_boost_processes')) {
|
||||
$created = db_execute("CREATE TABLE `poller_output_boost_processes` (
|
||||
`sock_int_value` bigint(20) unsigned NOT NULL auto_increment,
|
||||
`run_id` char(32) NOT NULL default '',
|
||||
`child_id` int(10) unsigned NOT NULL default 0,
|
||||
`status` varchar(255) default NULL,
|
||||
PRIMARY KEY (`sock_int_value`),
|
||||
UNIQUE KEY `run_child` (`run_id`, `child_id`)
|
||||
) ENGINE=MEMORY");
|
||||
|
||||
if ($created === false) {
|
||||
cacti_log('ERROR: Unable to create poller_output_boost_processes', true, 'BOOST');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!db_column_exists('poller_output_boost_processes', 'run_id')) {
|
||||
$added = db_execute("ALTER TABLE poller_output_boost_processes
|
||||
ADD `run_id` char(32) NOT NULL default '' AFTER `sock_int_value`");
|
||||
|
||||
if ($added === false) {
|
||||
cacti_log('ERROR: Unable to add run_id to poller_output_boost_processes', true, 'BOOST');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!db_column_exists('poller_output_boost_processes', 'child_id')) {
|
||||
$added = db_execute('ALTER TABLE poller_output_boost_processes
|
||||
ADD `child_id` int(10) unsigned NOT NULL default 0 AFTER `run_id`');
|
||||
|
||||
if ($added === false) {
|
||||
cacti_log('ERROR: Unable to add child_id to poller_output_boost_processes', true, 'BOOST');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($repair_key && !db_index_exists('poller_output_boost_processes', 'run_child')) {
|
||||
if (db_execute_prepared('DELETE FROM poller_output_boost_processes WHERE run_id = ?', ['']) === false) {
|
||||
cacti_log('ERROR: Unable to remove legacy Boost completion rows', true, 'BOOST');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$added = db_execute('ALTER TABLE poller_output_boost_processes
|
||||
ADD UNIQUE KEY `run_child` (`run_id`, `child_id`)');
|
||||
|
||||
if ($added === false) {
|
||||
cacti_log('ERROR: Unable to add run_child key to poller_output_boost_processes', true, 'BOOST');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts a multi-dimensional array by one or more fields.
|
||||
*
|
||||
@@ -172,8 +233,8 @@ function boost_error_handler(int $errno, string $errmsg, string $filename, int $
|
||||
* If `boost_rrd_update_enable` is enabled but `boost_rrd_update_system_enable`
|
||||
* is not, it updates the database to enable the system-level updates.
|
||||
*
|
||||
* If neither of the options is enabled, the function restores the default
|
||||
* error handler and returns false.
|
||||
* If neither option is enabled, the function returns false. Error-handler
|
||||
* ownership remains with the caller that installed it.
|
||||
*
|
||||
* @return bool Returns true if the Boost RRD update system is correctly enabled,
|
||||
* otherwise returns false.
|
||||
@@ -187,14 +248,271 @@ function boost_check_correct_enabled() : bool {
|
||||
VALUES ('boost_rrd_update_system_enable','on')");
|
||||
}
|
||||
} else {
|
||||
restore_error_handler();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one SQL VALUES token: a quoted string, a bare number, or FUNC().
|
||||
*
|
||||
* @param string $sql Tuple text
|
||||
* @param int $offset Byte offset; advanced past the token
|
||||
*
|
||||
* @return string|null The token value, or null when the tuple is malformed
|
||||
*/
|
||||
function boost_parse_sql_value(string $sql, int &$offset) : ?string {
|
||||
$length = strlen($sql);
|
||||
|
||||
while ($offset < $length && ctype_space($sql[$offset])) {
|
||||
$offset++;
|
||||
}
|
||||
|
||||
if ($offset >= $length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($sql[$offset] === "'") {
|
||||
$offset++;
|
||||
$value = '';
|
||||
|
||||
while ($offset < $length) {
|
||||
$ch = $sql[$offset];
|
||||
|
||||
if ($ch === '\\' && $offset + 1 < $length) {
|
||||
$value .= $sql[$offset + 1];
|
||||
$offset += 2;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === "'") {
|
||||
if ($offset + 1 < $length && $sql[$offset + 1] === "'") {
|
||||
$value .= "'";
|
||||
$offset += 2;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$offset++;
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
$value .= $ch;
|
||||
$offset++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\G[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)/', $sql, $matches, 0, $offset) === 1) {
|
||||
$offset += strlen($matches[0]);
|
||||
|
||||
return preg_replace('/\s+/', '', $matches[0]) ?? $matches[0];
|
||||
}
|
||||
|
||||
if (preg_match('/\G-?\d+(?:\.\d+)?/', $sql, $matches, 0, $offset) === 1) {
|
||||
$offset += strlen($matches[0]);
|
||||
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull local_data_id, rrd_name, and time from a poller_output_boost VALUES tuple.
|
||||
*
|
||||
* @param string $tuple A "(id, 'name', 'time', 'output')" or CURRENT_TIMESTAMP() tuple
|
||||
*
|
||||
* @return array{local_data_id:int, rrd_name:string, time:string}|null
|
||||
*/
|
||||
function boost_parse_output_tuple(string $tuple) : ?array {
|
||||
$tuple = trim($tuple);
|
||||
$offset = 0;
|
||||
|
||||
if ($tuple === '' || $tuple[0] !== '(') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$offset = 1;
|
||||
$id = boost_parse_sql_value($tuple, $offset);
|
||||
|
||||
if ($id === null || !ctype_digit($id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
while ($offset < strlen($tuple) && ctype_space($tuple[$offset])) {
|
||||
$offset++;
|
||||
}
|
||||
|
||||
if ($offset >= strlen($tuple) || $tuple[$offset] !== ',') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$offset++;
|
||||
$rrd_name = boost_parse_sql_value($tuple, $offset);
|
||||
|
||||
if ($rrd_name === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
while ($offset < strlen($tuple) && ctype_space($tuple[$offset])) {
|
||||
$offset++;
|
||||
}
|
||||
|
||||
if ($offset >= strlen($tuple) || $tuple[$offset] !== ',') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$offset++;
|
||||
$time = boost_parse_sql_value($tuple, $offset);
|
||||
|
||||
if ($time === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'local_data_id' => (int) $id,
|
||||
'rrd_name' => $rrd_name,
|
||||
'time' => $time
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip a diagnostic fragment so one huge rrd_name cannot flood the log.
|
||||
*/
|
||||
function boost_clip_log_fragment(string $value, int $max = 64) : string {
|
||||
$value = str_replace(["\r", "\n", "\0"], ' ', $value);
|
||||
|
||||
if (strlen($value) <= $max) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return substr($value, 0, $max - 3) . '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the BOOST warning for an INSERT IGNORE that dropped rows.
|
||||
*
|
||||
* @param array $tuples The VALUES tuples from the statement that was ignored in part
|
||||
* @param int $attempted Rows in that statement
|
||||
* @param int $inserted Rows the server reported as written
|
||||
*
|
||||
* @return string Empty when nothing was dropped
|
||||
*/
|
||||
function boost_describe_ignored_sample_keys(array $tuples, int $attempted, int $inserted) : string {
|
||||
$dropped = $attempted - $inserted;
|
||||
|
||||
if ($dropped < 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$by_key = [];
|
||||
$ids = [];
|
||||
|
||||
foreach ($tuples as $tuple) {
|
||||
$parsed = boost_parse_output_tuple((string) $tuple);
|
||||
|
||||
if ($parsed === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ids[$parsed['local_data_id']] = true;
|
||||
|
||||
$key = $parsed['local_data_id'] . "\0" . $parsed['rrd_name'] . "\0" . $parsed['time'];
|
||||
|
||||
if (!isset($by_key[$key])) {
|
||||
$parsed['count'] = 0;
|
||||
$by_key[$key] = $parsed;
|
||||
}
|
||||
|
||||
$by_key[$key]['count']++;
|
||||
}
|
||||
|
||||
$in_batch = 0;
|
||||
$examples = [];
|
||||
|
||||
foreach ($by_key as $row) {
|
||||
if ($row['count'] < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$in_batch += $row['count'] - 1;
|
||||
|
||||
if (cacti_count($examples) < 8) {
|
||||
$examples[] = sprintf(
|
||||
'DS[%d] %s @ %s (x%d)',
|
||||
$row['local_data_id'],
|
||||
boost_clip_log_fragment($row['rrd_name']),
|
||||
boost_clip_log_fragment($row['time']),
|
||||
$row['count']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($in_batch === 0) {
|
||||
foreach ($by_key as $row) {
|
||||
if (cacti_count($examples) >= 8) {
|
||||
break;
|
||||
}
|
||||
|
||||
$examples[] = sprintf(
|
||||
'DS[%d] %s @ %s',
|
||||
$row['local_data_id'],
|
||||
boost_clip_log_fragment($row['rrd_name']),
|
||||
boost_clip_log_fragment($row['time'])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$id_keys = array_keys($ids);
|
||||
$id_list = implode(',', array_slice($id_keys, 0, 20));
|
||||
|
||||
if (cacti_count($id_keys) > 20) {
|
||||
$id_list .= ',...';
|
||||
}
|
||||
|
||||
$vs_existing = $dropped - $in_batch;
|
||||
|
||||
if ($vs_existing < 0) {
|
||||
$vs_existing = 0;
|
||||
}
|
||||
|
||||
if ($in_batch > 0 && $vs_existing > 0) {
|
||||
$reason = 'duplicate keys inside this INSERT and vs existing poller_output_boost rows';
|
||||
} elseif ($in_batch > 0) {
|
||||
$reason = 'duplicate keys inside this INSERT';
|
||||
} else {
|
||||
$reason = 'keys already in poller_output_boost';
|
||||
}
|
||||
|
||||
$poller = defined('POLLER_ID') ? (int) POLLER_ID : 0;
|
||||
|
||||
return sprintf(
|
||||
'WARNING: Boost staging ignored %d of %d duplicate sample keys (INSERT IGNORE, first write wins, poller=%d, %s). local_data_id=%s%s',
|
||||
$dropped,
|
||||
$attempted,
|
||||
$poller,
|
||||
$reason,
|
||||
$id_list !== '' ? $id_list : 'unknown',
|
||||
$examples !== [] ? '; ' . implode('; ', $examples) : ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log INSERT IGNORE collisions with the keys an operator can grep.
|
||||
*/
|
||||
function boost_log_ignored_sample_keys(array $tuples, int $attempted, int $inserted) : void {
|
||||
$message = boost_describe_ignored_sample_keys($tuples, $attempted, $inserted);
|
||||
|
||||
if ($message !== '') {
|
||||
cacti_log($message, false, 'BOOST');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* boost_flush_output_batch - writes a batch of pre-built poller_output_boost
|
||||
* VALUE tuples, chunking on max_allowed_packet so a single INSERT statement
|
||||
@@ -215,11 +533,11 @@ function boost_check_correct_enabled() : bool {
|
||||
* VALUES tuples, e.g. "(1,'ds',NOW(),'1.23')"
|
||||
* @param mixed $conn DB connection to use, or false for the default
|
||||
*
|
||||
* @return void
|
||||
* @return bool True only when every chunk was accepted by the database.
|
||||
*/
|
||||
function boost_flush_output_batch(array $value_tuples, mixed $conn = false) : void {
|
||||
function boost_flush_output_batch(array $value_tuples, mixed $conn = false) : bool {
|
||||
if (!cacti_sizeof($value_tuples)) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// max_allowed_packet is a per-server setting and this helper is called with
|
||||
@@ -245,24 +563,60 @@ function boost_flush_output_batch(array $value_tuples, mixed $conn = false) : vo
|
||||
$overhead = strlen($sql_prefix) + 1;
|
||||
$out_buffer = '';
|
||||
$out_length = 0;
|
||||
$chunk = [];
|
||||
$rows = 0;
|
||||
|
||||
foreach ($value_tuples as $tuple) {
|
||||
$tuple_length = strlen($tuple);
|
||||
|
||||
if ($out_length > 0 && ($out_length + $overhead + $tuple_length) > $max_allowed_packet) {
|
||||
db_execute($sql_prefix . $out_buffer, true, $conn);
|
||||
try {
|
||||
$acknowledged = db_execute($sql_prefix . $out_buffer, true, $conn) !== false;
|
||||
} catch (Throwable) {
|
||||
$acknowledged = false;
|
||||
}
|
||||
|
||||
if (!$acknowledged) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$inserted = (int) db_affected_rows($conn);
|
||||
|
||||
if ($inserted < $rows) {
|
||||
boost_log_ignored_sample_keys($chunk, $rows, $inserted);
|
||||
}
|
||||
|
||||
$out_buffer = $tuple;
|
||||
$out_length = $tuple_length;
|
||||
$chunk = [$tuple];
|
||||
$rows = 1;
|
||||
} else {
|
||||
$out_buffer .= ($out_buffer != '' ? ',' : '') . $tuple;
|
||||
$out_length += $tuple_length + ($out_length > 0 ? 1 : 0);
|
||||
$chunk[] = $tuple;
|
||||
$rows++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($out_buffer != '') {
|
||||
db_execute($sql_prefix . $out_buffer, true, $conn);
|
||||
try {
|
||||
$acknowledged = db_execute($sql_prefix . $out_buffer, true, $conn) !== false;
|
||||
} catch (Throwable) {
|
||||
$acknowledged = false;
|
||||
}
|
||||
|
||||
if (!$acknowledged) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$inserted = (int) db_affected_rows($conn);
|
||||
|
||||
if ($inserted < $rows) {
|
||||
boost_log_ignored_sample_keys($chunk, $rows, $inserted);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,6 +647,7 @@ function boost_poller_on_demand(array &$results) : bool {
|
||||
}
|
||||
|
||||
if (read_config_option('boost_rrd_update_enable') == 'on' || POLLER_ID > 1) {
|
||||
$previous_error_reporting = error_reporting();
|
||||
set_config_option('boost_rrd_update_enable', 'on');
|
||||
|
||||
// suppress warnings
|
||||
@@ -306,9 +661,10 @@ function boost_poller_on_demand(array &$results) : bool {
|
||||
set_error_handler('boost_error_handler');
|
||||
|
||||
if (boost_check_correct_enabled()) {
|
||||
// if boost redirect is on, rows are being inserted directly
|
||||
// cmd.php already staged these rows when direct redirection is active.
|
||||
if (read_config_option('boost_redirect') == 'on') {
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -326,16 +682,17 @@ function boost_poller_on_demand(array &$results) : bool {
|
||||
')';
|
||||
}
|
||||
|
||||
boost_flush_output_batch($value_tuples, $conn);
|
||||
$return_value = !boost_flush_output_batch($value_tuples, $conn);
|
||||
} else {
|
||||
$return_value = false;
|
||||
}
|
||||
|
||||
$return_value = false;
|
||||
} else {
|
||||
$return_value = true;
|
||||
}
|
||||
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return $return_value;
|
||||
} else {
|
||||
@@ -397,6 +754,8 @@ function boost_fetch_cache_check(int $local_data_id, mixed $rrdtool_pipe = null)
|
||||
return false;
|
||||
}
|
||||
|
||||
$previous_error_reporting = error_reporting();
|
||||
|
||||
// suppress warnings
|
||||
if (defined('E_DEPRECATED')) {
|
||||
error_reporting(E_ALL ^ E_DEPRECATED);
|
||||
@@ -422,6 +781,7 @@ function boost_fetch_cache_check(int $local_data_id, mixed $rrdtool_pipe = null)
|
||||
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
// close rrdtool
|
||||
if ($close_pipe) {
|
||||
@@ -469,6 +829,132 @@ function boost_return_cached_image(&$graph_data_array) : bool {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an opaque cache name so graph identifiers and dimensions are not
|
||||
* enumerable when an administrator places the cache below a web root.
|
||||
*/
|
||||
function boost_graph_cache_filename(string $cache_directory, int $local_graph_id, mixed $rra_id, int $timespan, string $business_hours_index, array $graph_data_array) : string {
|
||||
static $secret = null;
|
||||
|
||||
if ($secret === null) {
|
||||
$secret = read_config_option('boost_png_cache_secret');
|
||||
|
||||
if (!is_string($secret) || !preg_match('/^[a-f0-9]{64}$/D', $secret)) {
|
||||
$candidate = bin2hex(random_bytes(32));
|
||||
db_execute_prepared('INSERT IGNORE INTO settings (name, value) VALUES (?, ?)', ['boost_png_cache_secret', $candidate]);
|
||||
$secret = read_config_option('boost_png_cache_secret', true);
|
||||
|
||||
if (!is_string($secret) || !preg_match('/^[a-f0-9]{64}$/D', $secret)) {
|
||||
$secret = $candidate;
|
||||
set_config_option('boost_png_cache_secret', $secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cache_key = serialize([
|
||||
'theme' => get_selected_theme(),
|
||||
'local_graph_id' => $local_graph_id,
|
||||
'rra_id' => (int) $rra_id,
|
||||
'timespan' => $timespan,
|
||||
'business_hours' => $business_hours_index,
|
||||
'height' => $graph_data_array['graph_height'] ?? null,
|
||||
'width' => $graph_data_array['graph_width'] ?? null,
|
||||
'nolegend' => isset($graph_data_array['graph_nolegend']),
|
||||
]);
|
||||
|
||||
return rtrim($cache_directory, '/\\') . DIRECTORY_SEPARATOR . hash_hmac('sha256', $cache_key, $secret) . '.png';
|
||||
}
|
||||
|
||||
/** Replace an existing cache object after Windows rejects rename-over-existing. */
|
||||
function boost_replace_cache_file_on_windows(string $temp_file, string $cache_file) : bool {
|
||||
if (!is_file($temp_file) || !is_file($cache_file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$backup_file = tempnam(dirname($cache_file), '.boost-old-');
|
||||
|
||||
if ($backup_file === false || !@unlink($backup_file) || !@rename($cache_file, $backup_file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (@rename($temp_file, $cache_file)) {
|
||||
@unlink($backup_file);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keep the previously published object when the replacement cannot be
|
||||
// installed. If the rename remains blocked, retain the backup as the
|
||||
// recoverable copy rather than deleting it.
|
||||
@rename($backup_file, $cache_file);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Write a complete cache object and publish it with a same-directory rename. */
|
||||
function boost_atomic_write_cache(string $cache_file, string $output) : bool {
|
||||
$temp_file = tempnam(dirname($cache_file), '.boost-');
|
||||
|
||||
if ($temp_file === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fileptr = fopen($temp_file, 'wb');
|
||||
|
||||
if ($fileptr === false) {
|
||||
@unlink($temp_file);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$length = strlen($output);
|
||||
$written = 0;
|
||||
$chunk_size = 1024 * 1024;
|
||||
|
||||
while ($written < $length) {
|
||||
$chunk = substr($output, $written, $chunk_size);
|
||||
$chunk_length = strlen($chunk);
|
||||
$chunk_written = 0;
|
||||
|
||||
while ($chunk_written < $chunk_length) {
|
||||
$result = fwrite($fileptr, substr($chunk, $chunk_written));
|
||||
|
||||
if ($result === false || $result === 0) {
|
||||
fclose($fileptr);
|
||||
@unlink($temp_file);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$chunk_written += $result;
|
||||
$written += $result;
|
||||
}
|
||||
}
|
||||
|
||||
$flushed = fflush($fileptr);
|
||||
fclose($fileptr);
|
||||
|
||||
if (!$flushed || !chmod($temp_file, 0644)) {
|
||||
@unlink($temp_file);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$published = @rename($temp_file, $cache_file);
|
||||
|
||||
if (!$published && PHP_OS_FAMILY === 'Windows') {
|
||||
$published = boost_replace_cache_file_on_windows($temp_file, $cache_file);
|
||||
}
|
||||
|
||||
if (!$published) {
|
||||
@unlink($temp_file);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the graph cache for a given graph and returns the cached image if valid.
|
||||
* If the cache is invalid or unavailable, it falls back to Cacti's graphing functions.
|
||||
@@ -488,7 +974,9 @@ function boost_graph_cache_check(int $local_graph_id, mixed $rra_id, mixed $rrdt
|
||||
// include poller processing routines
|
||||
include_once(CACTI_PATH_LIBRARY . '/poller.php');
|
||||
|
||||
// suppressnwarnings
|
||||
$previous_error_reporting = error_reporting();
|
||||
|
||||
// suppress warnings
|
||||
if (defined('E_DEPRECATED')) {
|
||||
error_reporting(E_ALL ^ E_DEPRECATED);
|
||||
} else {
|
||||
@@ -500,6 +988,9 @@ function boost_graph_cache_check(int $local_graph_id, mixed $rra_id, mixed $rrdt
|
||||
|
||||
// check to see if boost can do its job
|
||||
if (!boost_poller_id_check()) {
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -507,6 +998,7 @@ function boost_graph_cache_check(int $local_graph_id, mixed $rra_id, mixed $rrdt
|
||||
if (isset($graph_data_array['export_realtime'])) {
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -515,6 +1007,7 @@ function boost_graph_cache_check(int $local_graph_id, mixed $rra_id, mixed $rrdt
|
||||
if (isset($graph_data_array['print_source'])) {
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -524,6 +1017,7 @@ function boost_graph_cache_check(int $local_graph_id, mixed $rra_id, mixed $rrdt
|
||||
($graph_data_array['output_flag'] == RRDTOOL_OUTPUT_STDERR)) {
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -549,6 +1043,7 @@ function boost_graph_cache_check(int $local_graph_id, mixed $rra_id, mixed $rrdt
|
||||
if ($updates) {
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -579,25 +1074,7 @@ function boost_graph_cache_check(int $local_graph_id, mixed $rra_id, mixed $rrdt
|
||||
if ($cache_directory != '') {
|
||||
if (is_dir($cache_directory)) {
|
||||
if (is_writable($cache_directory)) {
|
||||
if ($rra_id > 0) {
|
||||
$cache_file = $cache_directory . '/' . get_selected_theme() . '_lgi_' . $local_graph_id . '_rrai_' . $rra_id . $bh_index;
|
||||
} else {
|
||||
$cache_file = $cache_directory . '/' . get_selected_theme() . '_lgi_' . $local_graph_id . '_rrai_' . $rra_id . $bh_index . '_tsi_' . $timespan;
|
||||
}
|
||||
|
||||
if (isset($graph_data_array['graph_height'])) {
|
||||
$cache_file .= '_height_' . $graph_data_array['graph_height'];
|
||||
}
|
||||
|
||||
if (isset($graph_data_array['graph_width'])) {
|
||||
$cache_file .= '_width_' . $graph_data_array['graph_width'];
|
||||
}
|
||||
|
||||
if (isset($graph_data_array['graph_nolegend'])) {
|
||||
$cache_file .= '_thumb.png';
|
||||
} else {
|
||||
$cache_file .= '.png';
|
||||
}
|
||||
$cache_file = boost_graph_cache_filename($cache_directory, $local_graph_id, $rra_id, (int) $timespan, $bh_index, $graph_data_array);
|
||||
|
||||
if (file_exists($cache_file)) {
|
||||
$mod_time = filemtime($cache_file);
|
||||
@@ -614,6 +1091,7 @@ function boost_graph_cache_check(int $local_graph_id, mixed $rra_id, mixed $rrdt
|
||||
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
// get access to the SNMP Cache of BOOST
|
||||
$mc = new MibCache('CACTI-BOOST-MIB');
|
||||
@@ -641,6 +1119,7 @@ function boost_graph_cache_check(int $local_graph_id, mixed $rra_id, mixed $rrdt
|
||||
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -659,6 +1138,8 @@ function boost_graph_cache_check(int $local_graph_id, mixed $rra_id, mixed $rrdt
|
||||
* @return array The prepared graph data array with any necessary modifications.
|
||||
*/
|
||||
function boost_prep_graph_array(array $graph_data_array) : array {
|
||||
$previous_error_reporting = error_reporting();
|
||||
|
||||
// suppress warnings
|
||||
if (defined('E_DEPRECATED')) {
|
||||
error_reporting(E_ALL ^ E_DEPRECATED);
|
||||
@@ -679,6 +1160,7 @@ function boost_prep_graph_array(array $graph_data_array) : array {
|
||||
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return $graph_data_array;
|
||||
}
|
||||
@@ -705,6 +1187,8 @@ function boost_graph_set_file(string|null &$output, int $local_graph_id, int|nul
|
||||
// get access to the SNMP Cache of BOOST
|
||||
$mc = new MibCache('CACTI-BOOST-MIB');
|
||||
|
||||
$previous_error_reporting = error_reporting();
|
||||
|
||||
// suppress warnings
|
||||
if (defined('E_DEPRECATED')) {
|
||||
error_reporting(E_ALL ^ E_DEPRECATED);
|
||||
@@ -735,34 +1219,12 @@ function boost_graph_set_file(string|null &$output, int $local_graph_id, int|nul
|
||||
|
||||
if ($cache_directory != '') {
|
||||
if (is_dir($cache_directory)) {
|
||||
if ($rra_id > 0) {
|
||||
$cache_file = $cache_directory . '/' . get_selected_theme() . '_lgi_' . $local_graph_id . '_rrai_' . $rra_id . $bh_index;
|
||||
} else {
|
||||
$cache_file = $cache_directory . '/' . get_selected_theme() . '_lgi_' . $local_graph_id . '_rrai_' . $rra_id . $bh_index . '_tsi_' . $timespan;
|
||||
}
|
||||
|
||||
if (isset($graph_data_array['graph_height'])) {
|
||||
$cache_file .= '_height_' . $graph_data_array['graph_height'];
|
||||
}
|
||||
|
||||
if (isset($graph_data_array['graph_width'])) {
|
||||
$cache_file .= '_width_' . $graph_data_array['graph_width'];
|
||||
}
|
||||
|
||||
if (isset($graph_data_array['graph_nolegend'])) {
|
||||
$cache_file .= '_thumb.png';
|
||||
} else {
|
||||
$cache_file .= '.png';
|
||||
}
|
||||
$cache_file = boost_graph_cache_filename($cache_directory, $local_graph_id, $rra_id, (int) $timespan, $bh_index, $graph_data_array);
|
||||
|
||||
if (is_writable($cache_directory)) {
|
||||
// if the cache file was created in a prior step, save it
|
||||
if (strlen($output) > 10) {
|
||||
if ($fileptr = fopen($cache_file, 'w')) {
|
||||
fwrite($fileptr, $output, strlen($output));
|
||||
fclose($fileptr);
|
||||
chmod($cache_file, 0644);
|
||||
|
||||
if (is_string($output) && strlen($output) > 10) {
|
||||
if (boost_atomic_write_cache($cache_file, $output)) {
|
||||
// count the number of images that had to be cached
|
||||
$mc->object('boostStatsTotalsImagesCacheWrites')->count();
|
||||
$mc->object('boostStatsLastUpdate')->set(time());
|
||||
@@ -781,6 +1243,7 @@ function boost_graph_set_file(string|null &$output, int $local_graph_id, int|nul
|
||||
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -869,7 +1332,7 @@ function boost_clamp_parallel(mixed $value) : int {
|
||||
* @return bool True when the name is a well-formed boost archive table.
|
||||
*/
|
||||
function boost_is_valid_archive_table(mixed $table) : bool {
|
||||
return is_string($table) && preg_match('/^poller_output_boost_arch_\d+$/', $table) === 1;
|
||||
return is_string($table) && preg_match('/^poller_output_boost_arch_\d+$/D', $table) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -933,8 +1396,15 @@ function boost_get_arch_table_names(mixed $latest_table = '') : mixed {
|
||||
|
||||
if (cacti_sizeof($tableData)) {
|
||||
foreach ($tableData as $table) {
|
||||
$table = array_values($table);
|
||||
$tableNames[$table[0]] = $table[0];
|
||||
$table = array_values($table);
|
||||
$table_name = (string) $table[0];
|
||||
|
||||
if (boost_is_valid_archive_table($table_name)) {
|
||||
$tableNames[$table_name] = $table_name;
|
||||
} else {
|
||||
$display_name = preg_replace('/[^\x20-\x7e]/', '?', $table_name) ?? '<invalid>';
|
||||
cacti_log('WARNING: Boost ignored an unexpected archive-like table name: ' . $display_name, false, 'BOOST');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -946,6 +1416,12 @@ function boost_get_arch_table_names(mixed $latest_table = '') : mixed {
|
||||
AND TABLE_NAME LIKE 'poller_output_boost_arch_%'"),
|
||||
'name', 'name'
|
||||
);
|
||||
|
||||
if (is_array($tableNames)) {
|
||||
$tableNames = array_filter($tableNames, 'boost_is_valid_archive_table');
|
||||
} else {
|
||||
$tableNames = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!cacti_sizeof($tableNames)) {
|
||||
@@ -1019,6 +1495,8 @@ function boost_process_poller_output(int $local_data_id, mixed $rrdtool_pipe = [
|
||||
|
||||
include_once(CACTI_PATH_LIBRARY . '/rrd.php');
|
||||
|
||||
$previous_error_reporting = error_reporting();
|
||||
|
||||
// suppress warnings
|
||||
if (defined('E_DEPRECATED')) {
|
||||
error_reporting(E_ALL ^ E_DEPRECATED);
|
||||
@@ -1061,7 +1539,7 @@ function boost_process_poller_output(int $local_data_id, mixed $rrdtool_pipe = [
|
||||
db_execute("CREATE TEMPORARY TABLE `{$temp_table}` LIKE poller_output_boost");
|
||||
|
||||
foreach ($archive_tables as $table) {
|
||||
db_execute_prepared("INSERT INTO `{$temp_table}`
|
||||
db_execute_prepared("INSERT IGNORE INTO `{$temp_table}`
|
||||
SELECT *
|
||||
FROM `{$table}`
|
||||
WHERE local_data_id = ?",
|
||||
@@ -1070,7 +1548,7 @@ function boost_process_poller_output(int $local_data_id, mixed $rrdtool_pipe = [
|
||||
}
|
||||
|
||||
if ($temp_table !== false) {
|
||||
db_execute_prepared("INSERT INTO `{$temp_table}`
|
||||
db_execute_prepared("INSERT IGNORE INTO `{$temp_table}`
|
||||
SELECT *
|
||||
FROM poller_output_boost
|
||||
WHERE local_data_id = ?
|
||||
@@ -1111,50 +1589,7 @@ function boost_process_poller_output(int $local_data_id, mixed $rrdtool_pipe = [
|
||||
|
||||
cacti_log('Local Data ID: ' . $local_data_id . ', Boost Results: ' . $boost_results, false, 'BOOST', POLLER_VERBOSITY_MEDIUM);
|
||||
|
||||
// remove the entries from the table
|
||||
boost_timer('delete', BOOST_TIMER_START);
|
||||
|
||||
if (cacti_count($archive_tables)) {
|
||||
foreach ($archive_tables as $table) {
|
||||
// rows with time >= $timestamp belong to a poll round that was
|
||||
// still open when $results was built above, so they were not
|
||||
// written to RRD; carry them forward into the live table instead
|
||||
// of deleting them out of the archive table, which gets dropped
|
||||
// wholesale once this run completes (see issue #7519)
|
||||
db_execute_prepared("INSERT IGNORE INTO poller_output_boost
|
||||
SELECT *
|
||||
FROM $table
|
||||
WHERE local_data_id = ?
|
||||
AND time >= FROM_UNIXTIME(?)",
|
||||
[$local_data_id, $timestamp], false);
|
||||
|
||||
// Now that every row for this local_data_id has either been
|
||||
// written to RRD (time < $timestamp) or forwarded to the live
|
||||
// table above (time >= $timestamp), the whole archive slice for
|
||||
// this local_data_id is safe to remove. Leaving the forwarded
|
||||
// rows behind here would duplicate them into the unfiltered
|
||||
// archive-side SELECT that seeds $temp_table on the next call
|
||||
// for this local_data_id, which is not INSERT IGNORE and would
|
||||
// fail on the resulting primary-key collision.
|
||||
db_execute_prepared("DELETE IGNORE
|
||||
FROM $table
|
||||
WHERE local_data_id = ?",
|
||||
[$local_data_id], false);
|
||||
}
|
||||
}
|
||||
|
||||
if (cacti_sizeof($results)) {
|
||||
db_execute_prepared('DELETE FROM poller_output_boost
|
||||
WHERE local_data_id = ?
|
||||
AND time < FROM_UNIXTIME(?)',
|
||||
[$local_data_id, $timestamp], false);
|
||||
}
|
||||
|
||||
boost_timer('delete', BOOST_TIMER_END);
|
||||
|
||||
if (cacti_version_compare(get_rrdtool_version(), '1.5', '<')) {
|
||||
db_execute("SELECT RELEASE_LOCK('boost.single_ds.$local_data_id')");
|
||||
}
|
||||
$updates_ok = $results !== false;
|
||||
|
||||
// log memory
|
||||
if ($get_memory) {
|
||||
@@ -1261,6 +1696,7 @@ function boost_process_poller_output(int $local_data_id, mixed $rrdtool_pipe = [
|
||||
// check return status for delete operation
|
||||
if (!str_contains(trim($return_value), 'OK') && $return_value != '') {
|
||||
cacti_log("WARNING: RRD Update Warning '" . $return_value . "' for Local Data ID '$local_data_id'", false, 'BOOST');
|
||||
$updates_ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1477,6 +1913,7 @@ function boost_process_poller_output(int $local_data_id, mixed $rrdtool_pipe = [
|
||||
// check return status for delete operation
|
||||
if (!str_contains(trim($return_value), 'OK') && $return_value != '') {
|
||||
cacti_log("WARNING: RRD Update Warning '" . $return_value . "' for Local Data ID '$local_data_id'", false, 'BOOST');
|
||||
$updates_ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1487,10 +1924,75 @@ function boost_process_poller_output(int $local_data_id, mixed $rrdtool_pipe = [
|
||||
}
|
||||
}
|
||||
|
||||
// The database rows are the retry record. Remove them only after every RRD
|
||||
// update and every still-open-row forward has been acknowledged.
|
||||
boost_timer('delete', BOOST_TIMER_START);
|
||||
|
||||
if ($updates_ok && cacti_count($archive_tables)) {
|
||||
foreach ($archive_tables as $table) {
|
||||
$forward_rows = (int) db_fetch_cell_prepared("SELECT COUNT(*)
|
||||
FROM `$table`
|
||||
WHERE local_data_id = ?
|
||||
AND time >= FROM_UNIXTIME(?)",
|
||||
[$local_data_id, $timestamp]);
|
||||
|
||||
if (db_execute_prepared("INSERT IGNORE INTO poller_output_boost
|
||||
SELECT *
|
||||
FROM `$table`
|
||||
WHERE local_data_id = ?
|
||||
AND time >= FROM_UNIXTIME(?)",
|
||||
[$local_data_id, $timestamp], false) === false) {
|
||||
$updates_ok = false;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$forwarded = (int) db_affected_rows();
|
||||
|
||||
if ($forwarded < $forward_rows) {
|
||||
cacti_log(sprintf(
|
||||
'WARNING: Boost archive forwarding ignored %d of %d duplicate sample keys for Local Data ID %d from %s (already in poller_output_boost, first write wins).',
|
||||
$forward_rows - $forwarded,
|
||||
$forward_rows,
|
||||
$local_data_id,
|
||||
$table
|
||||
), false, 'BOOST');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($updates_ok && cacti_count($archive_tables)) {
|
||||
foreach ($archive_tables as $table) {
|
||||
if (db_execute_prepared("DELETE FROM `$table` WHERE local_data_id = ?", [$local_data_id], false) === false) {
|
||||
$updates_ok = false;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($updates_ok && cacti_sizeof($results)) {
|
||||
$updates_ok = db_execute_prepared('DELETE FROM poller_output_boost
|
||||
WHERE local_data_id = ?
|
||||
AND time < FROM_UNIXTIME(?)',
|
||||
[$local_data_id, $timestamp], false) !== false;
|
||||
}
|
||||
|
||||
boost_timer('delete', BOOST_TIMER_END);
|
||||
|
||||
if (!$updates_ok) {
|
||||
cacti_log("WARNING: Boost retained staged rows for Local Data ID '$local_data_id' because the handoff was not fully acknowledged.", false, 'BOOST');
|
||||
}
|
||||
|
||||
if (cacti_version_compare(get_rrdtool_version(), '1.5', '<')) {
|
||||
db_execute("SELECT RELEASE_LOCK('boost.single_ds.$local_data_id')");
|
||||
}
|
||||
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return cacti_sizeof($results);
|
||||
return $updates_ok ? cacti_sizeof($results) : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1919,11 +2421,26 @@ function boost_rrdtool_function_update(int $local_data_id, string $rrd_path, str
|
||||
if ($rrd_update_template != '') {
|
||||
boost_debug("update $rrd_path $update_options --template $rrd_update_template $rrd_update_values");
|
||||
|
||||
rrdtool_execute('update ' . cacti_escapeshellarg($rrd_path) . " $update_options --template $rrd_update_template $rrd_update_values", false, RRDTOOL_OUTPUT_STDOUT, $rrdtool_pipe, 'BOOST');
|
||||
$result = rrdtool_execute('update ' . cacti_escapeshellarg($rrd_path) . " $update_options --template $rrd_update_template $rrd_update_values", false, RRDTOOL_OUTPUT_STDOUT, $rrdtool_pipe, 'BOOST');
|
||||
} else {
|
||||
boost_debug("update $rrd_path $update_options $rrd_update_values");
|
||||
|
||||
rrdtool_execute('update ' . cacti_escapeshellarg($rrd_path) . " $update_options $rrd_update_values", false, RRDTOOL_OUTPUT_STDOUT, $rrdtool_pipe, 'BOOST');
|
||||
$result = rrdtool_execute('update ' . cacti_escapeshellarg($rrd_path) . " $update_options $rrd_update_values", false, RRDTOOL_OUTPUT_STDOUT, $rrdtool_pipe, 'BOOST');
|
||||
}
|
||||
|
||||
if ($result === false || preg_match('/(?:^|\b)(?:ERROR|Error)(?::|\b)/', trim((string) $result))) {
|
||||
// A timestamp at or behind the last update means the sample is already
|
||||
// in the RRD, which is what a retried shard looks like. Reporting that
|
||||
// as a failure would retain the shard and reprocess it forever, so the
|
||||
// same rows would fail again on every pass and boost would never drain.
|
||||
// rrdtool 1.5 and later avoid this with --skip-past-updates; older
|
||||
// releases raise it, and a retry after a partial pass raises it on any
|
||||
// version.
|
||||
if (is_string($result) && preg_match('/illegal attempt to update using time|minimum one second step/i', $result)) {
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
return is_string($result) && $result !== '' ? $result : 'ERROR: RRDtool did not acknowledge the update';
|
||||
}
|
||||
|
||||
return 'OK';
|
||||
|
||||
+4
-1
@@ -1142,8 +1142,11 @@ function poller_table_maintenance() : void {
|
||||
if (!db_table_exists('poller_output_boost_processes')) {
|
||||
db_execute('CREATE TABLE `poller_output_boost_processes` (
|
||||
`sock_int_value` bigint(20) unsigned NOT NULL auto_increment,
|
||||
`run_id` char(32) NOT NULL default \'\',
|
||||
`child_id` int(10) unsigned NOT NULL default 0,
|
||||
`status` varchar(255) default NULL,
|
||||
PRIMARY KEY (`sock_int_value`))
|
||||
PRIMARY KEY (`sock_int_value`),
|
||||
UNIQUE KEY `run_child` (`run_id`, `child_id`))
|
||||
ENGINE=MEMORY');
|
||||
}
|
||||
|
||||
|
||||
+112
-33
@@ -49,11 +49,12 @@ $debug = false;
|
||||
$forcerun = false;
|
||||
$verbose = false;
|
||||
$child = 0;
|
||||
$run_id = '';
|
||||
|
||||
// for releasing lock on SIGNAL
|
||||
$current_lock = false;
|
||||
|
||||
global $child, $next_run_time, $archive_table, $current_lock;
|
||||
global $child, $run_id, $next_run_time, $archive_table, $current_lock;
|
||||
global $boost_debug, $boost_log, $cacti_log;
|
||||
|
||||
// Archive tables that boost_prepare_process_table() assigned to this run. Only
|
||||
@@ -83,6 +84,12 @@ if (cacti_sizeof($parms)) {
|
||||
$archive_table = $value;
|
||||
}
|
||||
|
||||
break;
|
||||
case '--run-id':
|
||||
if (preg_match('/^[a-f0-9]{32}$/D', $value)) {
|
||||
$run_id = $value;
|
||||
}
|
||||
|
||||
break;
|
||||
case '-d':
|
||||
case '--debug':
|
||||
@@ -141,6 +148,7 @@ $boost_log = read_config_option('path_boost_log');
|
||||
$cacti_log = read_config_option('path_cactilog');
|
||||
|
||||
if ($child == false) {
|
||||
$run_id = bin2hex(random_bytes(16));
|
||||
$current_time = time();
|
||||
|
||||
/* find out if it's time to collect device information
|
||||
@@ -209,6 +217,13 @@ if ($child == false) {
|
||||
// Check if processes are running and kill them
|
||||
boost_kill_running_processes();
|
||||
|
||||
if (!boost_ensure_process_table(true)) {
|
||||
cacti_log('ERROR: Boost process table is not ready.', true, 'BOOST');
|
||||
unregister_process('boost', 'master', POLLER_ID, (int) getmypid());
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Truncate the rrd_update_counter table
|
||||
db_execute('TRUNCATE TABLE poller_output_boost_processes');
|
||||
|
||||
@@ -236,26 +251,26 @@ if ($child == false) {
|
||||
// under them.
|
||||
$startup_deadline = time() + 30;
|
||||
|
||||
while (!boost_all_children_registered($expected_children, boost_processes_running(), boost_completed_children()) && time() < $startup_deadline) {
|
||||
while (!boost_all_children_registered($expected_children, boost_processes_running(), boost_completed_children($run_id)) && time() < $startup_deadline) {
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
if (!boost_all_children_registered($expected_children, boost_processes_running(), boost_completed_children())) {
|
||||
cacti_log(sprintf('WARNING: Boost startup barrier timed out; %d of %d children registered before draining.', boost_processes_running() + boost_completed_children(), $expected_children), true, 'BOOST');
|
||||
if (!boost_all_children_registered($expected_children, boost_processes_running(), boost_completed_children($run_id))) {
|
||||
cacti_log(sprintf('WARNING: Boost startup barrier timed out; %d of %d children registered before draining.', boost_processes_running() + boost_completed_children($run_id), $expected_children), true, 'BOOST');
|
||||
}
|
||||
|
||||
// Drain until no child is running and every launched child has
|
||||
// recorded a completion row, not merely when none are running -- a
|
||||
// sibling may not have started yet.
|
||||
while (boost_processes_running() > 0 || boost_completed_children() < $expected_children) {
|
||||
boost_debug(sprintf('%d Processes Running, %d of %d Completed, Sleeping for 2 seconds.', boost_processes_running(), boost_completed_children(), $expected_children));
|
||||
while (boost_processes_running() > 0 || boost_completed_children($run_id) < $expected_children) {
|
||||
boost_debug(sprintf('%d Processes Running, %d of %d Completed, Sleeping for 2 seconds.', boost_processes_running(), boost_completed_children($run_id), $expected_children));
|
||||
sleep(2);
|
||||
|
||||
if (boost_processes_running() === 0 && boost_completed_children() < $expected_children) {
|
||||
if (boost_processes_running() === 0 && boost_completed_children($run_id) < $expected_children) {
|
||||
// All registered children exited but fewer completion rows than
|
||||
// expected: a child crashed before recording status. Stop waiting
|
||||
// so the parent does not spin forever.
|
||||
cacti_log(sprintf('WARNING: Boost drained with %d of %d completion rows; a child may have crashed.', boost_completed_children(), $expected_children), true, 'BOOST');
|
||||
cacti_log(sprintf('WARNING: Boost drained with %d of %d completion rows; a child may have crashed.', boost_completed_children($run_id), $expected_children), true, 'BOOST');
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -270,19 +285,25 @@ if ($child == false) {
|
||||
set_config_option('boost_last_run_time', $current_time);
|
||||
|
||||
// output all the rrd data to the rrd files
|
||||
$rrd_updates = db_fetch_cell('SELECT SUM(status) FROM poller_output_boost_processes');
|
||||
$failed_children = boost_failed_children($run_id);
|
||||
$rrd_updates = (int) db_fetch_cell_prepared('SELECT COALESCE(SUM(CAST(status AS SIGNED)), 0)
|
||||
FROM poller_output_boost_processes
|
||||
WHERE run_id = ?
|
||||
AND CAST(status AS SIGNED) >= 0',
|
||||
[$run_id]);
|
||||
|
||||
if ($rrd_updates > 0) {
|
||||
if ($failed_children > 0) {
|
||||
boost_log_statistics($rrd_updates);
|
||||
set_config_option('boost_last_run_time', $last_run_time);
|
||||
cacti_log(sprintf('WARNING: Boost retained archive tables because %d child process(es) reported an RRD update failure.', $failed_children), true, 'BOOST');
|
||||
} elseif ($rrd_updates > 0) {
|
||||
boost_log_statistics($rrd_updates);
|
||||
$next_run_time = $current_time + $seconds_offset;
|
||||
} elseif ($rrd_updates == -1) {
|
||||
boost_log_statistics(0);
|
||||
$next_run_time = $current_time + $seconds_offset;
|
||||
} else { // rollback last run time
|
||||
set_config_option('boost_last_run_time', $last_run_time);
|
||||
}
|
||||
|
||||
if ($rrd_updates > 0) {
|
||||
if ($rrd_updates > 0 && $failed_children === 0) {
|
||||
cacti_log('INFO: Boost removing archive tables ...', true, 'BOOST');
|
||||
|
||||
// $rrd_updates is a SUM() across whatever completion rows landed;
|
||||
@@ -291,7 +312,7 @@ if ($child == false) {
|
||||
// the drop on the same completeness check the drain loop above uses,
|
||||
// not on "some shard made progress" -- otherwise a crashed child's
|
||||
// entire unprocessed shard of staged RRD data is destroyed with it.
|
||||
if (boost_completed_children() >= $expected_children) {
|
||||
if (boost_completed_children($run_id) >= $expected_children) {
|
||||
// Drop only the tables this run owned. A table created by a later
|
||||
// rotation, or left by an earlier crashed run, may still hold rows
|
||||
// that have not been processed; matching on the LIKE pattern would
|
||||
@@ -308,7 +329,7 @@ if ($child == false) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cacti_log(sprintf('WARNING: Boost run only completed %d of %d shards; leaving archive tables in place for the next run to pick up.', boost_completed_children(), $expected_children), true, 'BOOST');
|
||||
cacti_log(sprintf('WARNING: Boost run only completed %d of %d shards; leaving archive tables in place for the next run to pick up.', boost_completed_children($run_id), $expected_children), true, 'BOOST');
|
||||
}
|
||||
|
||||
dsstats_boost_bottom();
|
||||
@@ -345,6 +366,18 @@ if ($child == false) {
|
||||
|
||||
exit(0);
|
||||
} else {
|
||||
if (!preg_match('/^[a-f0-9]{32}$/D', $run_id)) {
|
||||
cacti_log('ERROR: Boost child refused to run without a valid parent run identifier.', true, 'BOOST');
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!boost_ensure_process_table(true)) {
|
||||
cacti_log('ERROR: Boost child process table is not ready.', true, 'BOOST');
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
cacti_log('INFO: Boost register child process ' . $child, true, 'BOOST');
|
||||
|
||||
// we will warn if the process is taking extra long
|
||||
@@ -356,8 +389,9 @@ if ($child == false) {
|
||||
$rrd_updates = boost_output_rrd_data($child);
|
||||
|
||||
db_execute_prepared('INSERT INTO poller_output_boost_processes
|
||||
(status) VALUES (?)',
|
||||
[$rrd_updates]);
|
||||
(run_id, child_id, status) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE status = VALUES(status)',
|
||||
[$run_id, $child, $rrd_updates]);
|
||||
|
||||
boost_log_child_statistics($rrd_updates, $child);
|
||||
|
||||
@@ -432,10 +466,21 @@ function boost_processes_running() : int {
|
||||
return (int) $running;
|
||||
}
|
||||
|
||||
function boost_completed_children() : int {
|
||||
function boost_completed_children(string $run_id) : int {
|
||||
// Each child inserts one status row when it finishes, so the row count is
|
||||
// the number of children that have completed this run.
|
||||
return (int) db_fetch_cell('SELECT COUNT(*) FROM poller_output_boost_processes');
|
||||
return (int) db_fetch_cell_prepared('SELECT COUNT(*)
|
||||
FROM poller_output_boost_processes
|
||||
WHERE run_id = ?',
|
||||
[$run_id]);
|
||||
}
|
||||
|
||||
function boost_failed_children(string $run_id) : int {
|
||||
return (int) db_fetch_cell_prepared('SELECT COUNT(*)
|
||||
FROM poller_output_boost_processes
|
||||
WHERE run_id = ?
|
||||
AND CAST(status AS SIGNED) < 0',
|
||||
[$run_id]);
|
||||
}
|
||||
|
||||
function boost_prepare_process_table() : bool {
|
||||
@@ -588,7 +633,7 @@ function boost_prune_memstats() : void {
|
||||
}
|
||||
|
||||
function boost_launch_children() : int {
|
||||
global $debug, $archive_table, $boost_log, $boost_debug, $cacti_log;
|
||||
global $debug, $archive_table, $run_id, $boost_log, $boost_debug, $cacti_log;
|
||||
|
||||
if (!boost_is_valid_archive_table($archive_table)) {
|
||||
cacti_log('ERROR: Boost refusing to launch children: archive table not set or invalid', true, 'BOOST');
|
||||
@@ -627,6 +672,7 @@ function boost_launch_children() : int {
|
||||
CACTI_PATH_BASE . '/poller_boost.php',
|
||||
'--child=' . $i,
|
||||
'--archive-table=' . $archive_table,
|
||||
'--run-id=' . $run_id,
|
||||
];
|
||||
|
||||
if ($debug) {
|
||||
@@ -724,6 +770,7 @@ function boost_output_rrd_data(int $child) : mixed {
|
||||
global $start, $archive_table, $max_run_duration, $database_default, $debug, $get_memory, $memory_used;
|
||||
|
||||
$rrd_updates = 0;
|
||||
$processed_rows = 0;
|
||||
$rrdtool_pipe = rrd_init();
|
||||
$runtime_exceeded = false;
|
||||
|
||||
@@ -757,6 +804,8 @@ function boost_output_rrd_data(int $child) : mixed {
|
||||
}
|
||||
|
||||
if ($total_rows == 0) {
|
||||
rrd_close($rrdtool_pipe);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -795,7 +844,15 @@ function boost_output_rrd_data(int $child) : mixed {
|
||||
break;
|
||||
}
|
||||
|
||||
boost_process_local_data_ids($last_id, $child, $rrdtool_pipe);
|
||||
$pass_rows = boost_process_local_data_ids($last_id, $child, $rrdtool_pipe);
|
||||
|
||||
if ($pass_rows < 0) {
|
||||
rrd_close($rrdtool_pipe);
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
$processed_rows += $pass_rows;
|
||||
|
||||
$curpass++;
|
||||
|
||||
@@ -822,7 +879,7 @@ function boost_output_rrd_data(int $child) : mixed {
|
||||
|
||||
rrd_close($rrdtool_pipe);
|
||||
|
||||
return $total_rows;
|
||||
return $processed_rows;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -868,6 +925,8 @@ function boost_process_local_data_ids(int $last_id, int $child, mixed $rrdtool_p
|
||||
|
||||
require_once(CACTI_PATH_LIBRARY . '/rrd.php');
|
||||
|
||||
$previous_error_reporting = error_reporting();
|
||||
|
||||
// suppress warnings
|
||||
if (defined('E_DEPRECATED')) {
|
||||
error_reporting(E_ALL ^ E_DEPRECATED);
|
||||
@@ -913,8 +972,10 @@ function boost_process_local_data_ids(int $last_id, int $child, mixed $rrdtool_p
|
||||
boost_debug('Failed to determine archive tables');
|
||||
|
||||
cacti_log('Failed to determine archive tables', true, 'BOOST');
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!cacti_sizeof($rrd_field_names)) {
|
||||
@@ -962,6 +1023,9 @@ function boost_process_local_data_ids(int $last_id, int $child, mixed $rrdtool_p
|
||||
}
|
||||
}
|
||||
|
||||
// nothing to flush means nothing failed; the reads below run either way
|
||||
$updates_ok = true;
|
||||
|
||||
if (cacti_sizeof($results)) {
|
||||
// batch-prefetch the RRD field-name metadata the loop below needs on
|
||||
// every local_data_id boundary, rather than re-querying it per
|
||||
@@ -1060,7 +1124,9 @@ function boost_process_local_data_ids(int $last_id, int $child, mixed $rrdtool_p
|
||||
$outarray[] = $tv_tmpl;
|
||||
|
||||
// new process output function
|
||||
boost_process_output($local_data_id, $outarray, $rrd_path, $rrd_tmplp, $rrdtool_pipe);
|
||||
if (!boost_process_output($local_data_id, $outarray, $rrd_path, $rrd_tmplp, $rrdtool_pipe)) {
|
||||
$updates_ok = false;
|
||||
}
|
||||
|
||||
$buflen = 0;
|
||||
$vals_in_buffer = 0;
|
||||
@@ -1124,7 +1190,9 @@ function boost_process_local_data_ids(int $last_id, int $child, mixed $rrdtool_p
|
||||
|
||||
if ($buflen > $upd_string_len) {
|
||||
// new process output function
|
||||
boost_process_output($local_data_id, $outarray, $rrd_path, $rrd_tmplp, $rrdtool_pipe);
|
||||
if (!boost_process_output($local_data_id, $outarray, $rrd_path, $rrd_tmplp, $rrdtool_pipe)) {
|
||||
$updates_ok = false;
|
||||
}
|
||||
|
||||
$buflen = 0;
|
||||
$vals_in_buffer = 0;
|
||||
@@ -1271,7 +1339,9 @@ function boost_process_local_data_ids(int $last_id, int $child, mixed $rrdtool_p
|
||||
// place the latest update at the end of the output array
|
||||
$outarray[] = $tv_tmpl;
|
||||
|
||||
boost_process_output($local_data_id, $outarray, $rrd_path, $rrd_tmplp, $rrdtool_pipe);
|
||||
if (!boost_process_output($local_data_id, $outarray, $rrd_path, $rrd_tmplp, $rrdtool_pipe)) {
|
||||
$updates_ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
// release the last lock
|
||||
@@ -1287,20 +1357,25 @@ function boost_process_local_data_ids(int $last_id, int $child, mixed $rrdtool_p
|
||||
// remove the entries from the table
|
||||
boost_timer('delete', BOOST_TIMER_START);
|
||||
|
||||
db_execute_prepared('DELETE FROM poller_output_boost_local_data_ids
|
||||
WHERE local_data_id <= ?
|
||||
AND process_handler = ?',
|
||||
[$last_id, $child]);
|
||||
if ($updates_ok && $results !== false) {
|
||||
db_execute_prepared('DELETE FROM poller_output_boost_local_data_ids
|
||||
WHERE local_data_id <= ?
|
||||
AND process_handler = ?',
|
||||
[$last_id, $child]);
|
||||
} else {
|
||||
cacti_log(sprintf('WARNING: Boost retained shard %d through local data ID %d because one or more RRD updates failed.', $child, $last_id), true, 'BOOST');
|
||||
}
|
||||
|
||||
boost_timer('delete', BOOST_TIMER_END);
|
||||
|
||||
// restore original error handler
|
||||
restore_error_handler();
|
||||
error_reporting($previous_error_reporting);
|
||||
|
||||
return cacti_sizeof($results);
|
||||
return $updates_ok && $results !== false ? cacti_sizeof($results) : -1;
|
||||
}
|
||||
|
||||
function boost_process_output(int $local_data_id, array $outarray, string $rrd_path, array $rrd_tmplp, mixed $rrdtool_pipe) : void {
|
||||
function boost_process_output(int $local_data_id, array $outarray, string $rrd_path, array $rrd_tmplp, mixed $rrdtool_pipe) : bool {
|
||||
$outbuf = '';
|
||||
|
||||
if (cacti_sizeof($outarray)) {
|
||||
@@ -1320,7 +1395,11 @@ function boost_process_output(int $local_data_id, array $outarray, string $rrd_p
|
||||
// check return status for delete operation
|
||||
if (trim($return_value) != 'OK' && $return_value != '') {
|
||||
cacti_log("WARNING: RRD Update Warning '" . $return_value . "' for Local Data ID '$local_data_id'", true, 'BOOST');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function boost_log_statistics(int $rrd_updates) : void {
|
||||
|
||||
+79
-55
@@ -78,16 +78,65 @@ function debug(string $string) : void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete only rows which were acknowledged by the main collector.
|
||||
*/
|
||||
function recovery_delete_acknowledged_rows(array $rows, mixed $conn) : bool {
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
$clauses = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($chunk as $row) {
|
||||
$clauses[] = '(local_data_id = ? AND rrd_name = ? AND time = ?)';
|
||||
$params[] = (int) $row['local_data_id'];
|
||||
$params[] = $row['rrd_name'];
|
||||
$params[] = $row['time'];
|
||||
}
|
||||
|
||||
if (db_execute_prepared('DELETE FROM poller_output_boost WHERE ' . implode(' OR ', $clauses), $params, true, $conn) === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer and acknowledge one bounded recovery chunk.
|
||||
*
|
||||
* @return int|false Number of transferred rows, or false on any failure.
|
||||
*/
|
||||
function recovery_transfer_rows(array $rows, mixed $remote_conn, mixed $local_conn) : int|false {
|
||||
$sql_array = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$sql_array[] = '(' . (int) $row['local_data_id'] . ',' .
|
||||
db_qstr($row['rrd_name'], $remote_conn) . ',' .
|
||||
db_qstr($row['time'], $remote_conn) . ',' .
|
||||
db_qstr($row['output'], $remote_conn) . ')';
|
||||
}
|
||||
|
||||
$record_count = cacti_sizeof($sql_array);
|
||||
cacti_log('RECOVERY: Writing ' . $record_count . ' records to main.', false, 'POLLER');
|
||||
|
||||
if (!boost_flush_output_batch($sql_array, $remote_conn)) {
|
||||
cacti_log('RECOVERY ERROR: Main collector did not acknowledge the Boost batch; retaining local rows.', false, 'POLLER');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!recovery_delete_acknowledged_rows($rows, $local_conn)) {
|
||||
cacti_log('RECOVERY ERROR: Unable to remove acknowledged local Boost rows; they will be retried idempotently.', false, 'POLLER');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $record_count;
|
||||
}
|
||||
|
||||
global $local_db_cnn_id, $remote_db_cnn_id;
|
||||
|
||||
$recovery_pid = db_fetch_cell("SELECT value FROM settings WHERE name='recovery_pid'", '', true, $local_db_cnn_id);
|
||||
$packet_data = db_fetch_row("SHOW GLOBAL VARIABLES LIKE 'max_allowed_packet'", true, $remote_db_cnn_id);
|
||||
|
||||
if (isset($packet_data['Value'])) {
|
||||
$max_allowed_packet = $packet_data['Value'];
|
||||
} else {
|
||||
$max_allowed_packet = 1E6;
|
||||
}
|
||||
|
||||
// process calling arguments
|
||||
$parms = $_SERVER['argv'];
|
||||
@@ -161,11 +210,13 @@ if (function_exists('pcntl_signal')) {
|
||||
$start = microtime(true);
|
||||
|
||||
// configuration variables
|
||||
$record_limit = 150000;
|
||||
$sleep_time = 1;
|
||||
$record_limit = 150000;
|
||||
$transfer_chunk_size = 1000;
|
||||
$sleep_time = 1;
|
||||
|
||||
// global counter variables
|
||||
$records_inserted = 0;
|
||||
$transfer_failed = false;
|
||||
|
||||
debug('About to start recovery processing');
|
||||
|
||||
@@ -202,12 +253,12 @@ if ($run) {
|
||||
|
||||
db_execute_prepared('REPLACE INTO settings
|
||||
(name, value)
|
||||
VALUES ("recovery_pid", ?)',
|
||||
VALUES (\'recovery_pid\', ?)',
|
||||
[$my_pid], true, $local_db_cnn_id);
|
||||
|
||||
// let the console know you are in recovery mode
|
||||
db_execute_prepared('UPDATE poller
|
||||
SET status = "5"
|
||||
SET status = 5
|
||||
WHERE id = ?',
|
||||
[$poller_id], true, $remote_db_cnn_id);
|
||||
|
||||
@@ -231,67 +282,40 @@ if ($run) {
|
||||
} else {
|
||||
cacti_log('RECOVERY: Fetching records till time: ' . $max_time . ' from poller DB', false, 'POLLER');
|
||||
|
||||
$rows = db_fetch_assoc_prepared('SELECT *
|
||||
$rows = db_fetch_assoc_prepared(sprintf('SELECT *
|
||||
FROM poller_output_boost
|
||||
WHERE time <= ?
|
||||
ORDER BY time ASC, local_data_id ASC',
|
||||
[$max_time]);
|
||||
ORDER BY time ASC, local_data_id ASC
|
||||
LIMIT %d', (int) $record_limit),
|
||||
[$max_time], true, $local_db_cnn_id);
|
||||
|
||||
if (cacti_sizeof($rows)) {
|
||||
$packet_size = 0;
|
||||
$sql_array = [];
|
||||
$row_count = cacti_sizeof($rows);
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$sql = '(' . $r['local_data_id'] . ',' . db_qstr($r['rrd_name']) . ',' . db_qstr($r['time']) . ',' . db_qstr($r['output']) . ')';
|
||||
$sql_size = strlen($sql);
|
||||
for ($offset = 0; $offset < $row_count; $offset += $transfer_chunk_size) {
|
||||
$chunk = array_slice($rows, $offset, $transfer_chunk_size);
|
||||
$record_count = recovery_transfer_rows($chunk, $remote_db_cnn_id, $local_db_cnn_id);
|
||||
|
||||
// if adding a new row would exceed max_allowed_packet, send the current frame to the main poller and start a new frame
|
||||
if (($packet_size + $sql_size) >= $max_allowed_packet) {
|
||||
$record_count = cacti_sizeof($sql_array);
|
||||
if ($record_count === false) {
|
||||
$transfer_failed = true;
|
||||
|
||||
cacti_log('RECOVERY: Writing ' . $record_count . ' records (' . $packet_size . ' bytes) to main (partial).', false, 'POLLER');
|
||||
|
||||
db_execute('INSERT IGNORE INTO poller_output_boost
|
||||
(local_data_id, rrd_name, time, output)
|
||||
VALUES ' . implode(',', $sql_array), true, $remote_db_cnn_id);
|
||||
|
||||
$records_inserted += $record_count;
|
||||
$sql_array = [];
|
||||
$packet_size = 0;
|
||||
break 2;
|
||||
}
|
||||
|
||||
$sql_array[] = $sql;
|
||||
$packet_size += $sql_size;
|
||||
}
|
||||
|
||||
// if there is data in the last frame, send it to main poller as well and finalize
|
||||
if ($packet_size > 0) {
|
||||
$record_count = cacti_sizeof($sql_array);
|
||||
|
||||
cacti_log('RECOVERY: Writing ' . $record_count . ' records (' . $packet_size . ' bytes) to main (last slice).', false, 'POLLER');
|
||||
|
||||
db_execute('INSERT IGNORE INTO poller_output_boost
|
||||
(local_data_id, rrd_name, time, output)
|
||||
VALUES ' . implode(',', $sql_array), true, $remote_db_cnn_id);
|
||||
|
||||
$records_inserted += $record_count;
|
||||
}
|
||||
|
||||
// remove the recovery records
|
||||
if (is_object($local_db_cnn_id)) {
|
||||
db_execute_prepared('DELETE FROM poller_output_boost
|
||||
WHERE time <= ?',
|
||||
[$max_time], true, $local_db_cnn_id);
|
||||
}
|
||||
}
|
||||
|
||||
sleep($sleep_time);
|
||||
}
|
||||
}
|
||||
|
||||
// let the console know you are in online mode
|
||||
db_execute("DELETE FROM settings WHERE name='recovery_pid'", true, $local_db_cnn_id);
|
||||
|
||||
// Recovery failures are reported by the log and exit status; leaving the
|
||||
// poller in status 5 would incorrectly advertise recovery indefinitely.
|
||||
db_execute_prepared('UPDATE poller
|
||||
SET status="2"
|
||||
SET status=2
|
||||
WHERE id= ?', [$poller_id], false, $remote_db_cnn_id);
|
||||
} else {
|
||||
debug('Recovery process still running, exiting');
|
||||
@@ -304,4 +328,4 @@ $end = microtime(true);
|
||||
|
||||
cacti_log('RECOVERY STATS: Time:' . round($end - $start, 2) . ' Records:' . $records_inserted, false, 'SYSTEM');
|
||||
|
||||
exit(0);
|
||||
exit($transfer_failed ? 1 : 0);
|
||||
@@ -0,0 +1,152 @@
|
||||
<?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 |
|
||||
+-------------------------------------------------------------------------+
|
||||
| Cacti: The Complete RRDTool-based Graphing Solution |
|
||||
+-------------------------------------------------------------------------+
|
||||
*/
|
||||
|
||||
if (!function_exists('boostSource')) {
|
||||
function boostSource(string $path): string {
|
||||
$source = file_get_contents(CACTI_PATH_BASE . '/' . $path);
|
||||
|
||||
if ($source === false) {
|
||||
throw new RuntimeException("Unable to read Boost source file: $path");
|
||||
}
|
||||
|
||||
return $source;
|
||||
}
|
||||
}
|
||||
|
||||
test('Boost cache paths normalize either trailing separator style', function () {
|
||||
$boost = boostSource('lib/boost.php');
|
||||
|
||||
expect($boost)->toContain("rtrim(\$cache_directory, '/\\\\')");
|
||||
});
|
||||
|
||||
test('Boost hand-off helper tolerates repeated test-file loading', function () {
|
||||
$source = file_get_contents(__FILE__);
|
||||
|
||||
expect($source)->not->toBeFalse();
|
||||
$source = (string) $source;
|
||||
|
||||
$declaration = strpos($source, 'function ' . 'boostSource');
|
||||
|
||||
expect($declaration)->not->toBeFalse();
|
||||
expect(substr($source, max(0, $declaration - 80), 80))->toContain("if (!function_exists('boostSource')) {");
|
||||
});
|
||||
|
||||
test('Boost hand-off batches expose database acknowledgement', function () {
|
||||
$boost = boostSource('lib/boost.php');
|
||||
|
||||
expect($boost)->toContain('function boost_flush_output_batch(array $value_tuples, mixed $conn = false) : bool');
|
||||
expect($boost)->toContain('$acknowledged = db_execute($sql_prefix . $out_buffer, true, $conn) !== false;');
|
||||
expect($boost)->toContain('$return_value = !boost_flush_output_batch($value_tuples, $conn);');
|
||||
});
|
||||
|
||||
test('Recovery deletes only the exact rows acknowledged by the main collector', function () {
|
||||
$recovery = boostSource('poller_recovery.php');
|
||||
|
||||
expect($recovery)->toContain('function recovery_delete_acknowledged_rows(array $rows, mixed $conn) : bool');
|
||||
expect($recovery)->toContain('function recovery_transfer_rows(array $rows, mixed $remote_conn, mixed $local_conn) : int|false');
|
||||
expect($recovery)->toContain('(local_data_id = ? AND rrd_name = ? AND time = ?)');
|
||||
expect($recovery)->toContain('if (!boost_flush_output_batch($sql_array, $remote_conn))');
|
||||
expect($recovery)->toContain('array_slice($rows, $offset, $transfer_chunk_size)');
|
||||
expect($recovery)->toContain('recovery_transfer_rows($chunk, $remote_db_cnn_id, $local_db_cnn_id)');
|
||||
expect($recovery)->toContain("ORDER BY time ASC, local_data_id ASC\n\t\t\t\tLIMIT %d', (int) \$record_limit)");
|
||||
expect($recovery)->not->toContain('DELETE FROM poller_output_boost WHERE time <=');
|
||||
});
|
||||
|
||||
test('Recovery status SQL remains valid when ANSI_QUOTES is enabled', function () {
|
||||
$recovery = boostSource('poller_recovery.php');
|
||||
|
||||
expect($recovery)->toContain('SET status=2')
|
||||
->and($recovery)->toContain('SET status = 5')
|
||||
->and($recovery)->toContain("VALUES (\\'recovery_pid\\', ?)")
|
||||
->and($recovery)->not->toContain("if (!\$transfer_failed) {\n\t\t// let the console know you are in online mode")
|
||||
->and($recovery)->not->toMatch('/(?:status\s*=|VALUES\s*\()\s*"/');
|
||||
});
|
||||
|
||||
test('Scheduled Boost retains shard ownership after an RRD update failure', function () {
|
||||
$poller = boostSource('poller_boost.php');
|
||||
|
||||
expect($poller)->toContain('function boost_process_output(int $local_data_id, array $outarray, string $rrd_path, array $rrd_tmplp, mixed $rrdtool_pipe) : bool');
|
||||
expect($poller)->toContain('if ($updates_ok && $results !== false)');
|
||||
expect($poller)->toContain('return $updates_ok && $results !== false ? cacti_sizeof($results) : -1;');
|
||||
expect($poller)->toContain('if ($pass_rows < 0)');
|
||||
expect($poller)->toContain('if ($total_rows == 0) {');
|
||||
expect($poller)->toContain('return 0;');
|
||||
expect($poller)->not->toContain('elseif ($rrd_updates == -1)');
|
||||
expect(boostSource('lib/boost.php'))->toContain("return is_string(\$result) && \$result !== '' ? \$result : 'ERROR: RRDtool did not acknowledge the update';");
|
||||
});
|
||||
|
||||
test('Boost completion-table upgrades recover when the legacy table is missing', function () {
|
||||
$upgrade = boostSource('install/upgrades/1_3_0.php');
|
||||
|
||||
expect($upgrade)->toContain("if (!db_table_exists('poller_output_boost_processes'))")
|
||||
->and($upgrade)->toContain('CREATE TABLE `poller_output_boost_processes`')
|
||||
->and($upgrade)->toContain('UNIQUE KEY `run_child` (`run_id`, `child_id`)');
|
||||
});
|
||||
|
||||
test('Boost child completion and archive deletion are scoped to a parent run', function () {
|
||||
$poller = boostSource('poller_boost.php');
|
||||
$schema = boostSource('cacti.sql');
|
||||
|
||||
expect($poller)->toContain('bin2hex(random_bytes(16));');
|
||||
expect($poller)->toContain("'--run-id=' . \$run_id");
|
||||
expect($poller)->toContain('function boost_completed_children(string $run_id) : int');
|
||||
expect($poller)->toContain('function boost_failed_children(string $run_id) : int');
|
||||
expect($poller)->toContain('if ($rrd_updates > 0 && $failed_children === 0)');
|
||||
expect($schema)->toContain('UNIQUE KEY `run_child` (`run_id`,`child_id`)');
|
||||
expect($schema)->toContain('`child_id` int(10) unsigned NOT NULL default 0');
|
||||
});
|
||||
|
||||
test('direct Boost redirection does not stage the same rows twice', function () {
|
||||
$boost = boostSource('lib/boost.php');
|
||||
|
||||
expect($boost)->toContain("if (read_config_option('boost_redirect') == 'on')")
|
||||
->and($boost)->toContain('cmd.php already staged these rows');
|
||||
});
|
||||
|
||||
test('Archive discovery validates every dynamic table identifier', function () {
|
||||
$boost = boostSource('lib/boost.php');
|
||||
|
||||
expect($boost)->toContain("preg_match('/^poller_output_boost_arch_\\d+$/D', \$table)");
|
||||
expect($boost)->toContain("array_filter(\$tableNames, 'boost_is_valid_archive_table')");
|
||||
});
|
||||
|
||||
test('Remote tuples are quoted by their destination connection', function () {
|
||||
$cmd = boostSource('cmd.php');
|
||||
$recovery = boostSource('poller_recovery.php');
|
||||
|
||||
expect($cmd)->toContain("db_qstr(\$item['rrd_name'], \$poller_db_cnn_id)");
|
||||
expect($cmd)->toContain('db_qstr($output, $poller_db_cnn_id)');
|
||||
expect($cmd)->toContain("db_qstr('U', \$poller_db_cnn_id)");
|
||||
expect($cmd)->toContain("IFNULL(s.disabled, \\'\\') != \\'on\\'");
|
||||
expect($recovery)->toContain("db_qstr(\$row['rrd_name'], \$remote_conn)");
|
||||
});
|
||||
|
||||
test('Graph cache names are opaque and writes are atomically published', function () {
|
||||
$boost = boostSource('lib/boost.php');
|
||||
|
||||
expect($boost)->toContain("hash_hmac('sha256', \$cache_key, \$secret) . '.png'");
|
||||
expect($boost)->toContain("tempnam(dirname(\$cache_file), '.boost-')");
|
||||
expect($boost)->toContain("PHP_OS_FAMILY === 'Windows'");
|
||||
expect($boost)->toContain('boost_replace_cache_file_on_windows($temp_file, $cache_file)');
|
||||
expect($boost)->toContain('if (is_string($output) && strlen($output) > 10)');
|
||||
expect($boost)->toContain('if (!$flushed || !chmod($temp_file, 0644))');
|
||||
expect($boost)->toContain("read_config_option('boost_png_cache_secret', true)");
|
||||
expect($boost)->not->toContain("get_selected_theme() . '_lgi_'");
|
||||
});
|
||||
|
||||
test('Boost restores both the caller error handler and reporting level', function () {
|
||||
$boost = boostSource('lib/boost.php');
|
||||
|
||||
expect(substr_count($boost, '$previous_error_reporting = error_reporting();'))->toBeGreaterThanOrEqual(4);
|
||||
expect(substr_count($boost, 'error_reporting($previous_error_reporting);'))->toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
@@ -25,12 +25,12 @@
|
||||
* child; the fix wires that same completeness check into the drop decision.
|
||||
*/
|
||||
|
||||
$boostPollerPath = __DIR__ . '/../../../../poller_boost.php';
|
||||
$boostPollerPath = CACTI_PATH_BASE . '/poller_boost.php';
|
||||
|
||||
test('archive-table drop is gated on shard completeness, not the status sum', function () use ($boostPollerPath) {
|
||||
$contents = file_get_contents($boostPollerPath);
|
||||
|
||||
$rrd_updates_pos = strpos($contents, "SELECT SUM(status) FROM poller_output_boost_processes");
|
||||
$rrd_updates_pos = strpos($contents, 'COALESCE(SUM(CAST(status AS SIGNED)), 0)');
|
||||
expect($rrd_updates_pos)->not->toBeFalse();
|
||||
|
||||
$drop_pos = strpos($contents, 'DROP TABLE IF EXISTS `$table`', $rrd_updates_pos);
|
||||
@@ -40,12 +40,13 @@ test('archive-table drop is gated on shard completeness, not the status sum', fu
|
||||
|
||||
// The completeness check must appear between the SUM() read and the
|
||||
// actual DROP, gating it.
|
||||
expect($segment)->toContain('boost_completed_children() >= $expected_children');
|
||||
expect($segment)->toContain('boost_completed_children($run_id) >= $expected_children');
|
||||
expect($segment)->toContain('$failed_children === 0');
|
||||
|
||||
// A bare "if ($rrd_updates > 0) { ... DROP ... }" with nothing else
|
||||
// between them is exactly the bug: the drop must not be reachable
|
||||
// without the completeness check in between.
|
||||
$gate_pos = strpos($segment, 'boost_completed_children() >= $expected_children');
|
||||
$gate_pos = strpos($segment, 'boost_completed_children($run_id) >= $expected_children');
|
||||
expect($gate_pos)->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -68,6 +69,6 @@ test('boost_completed_children and expected_children are still computed the same
|
||||
// The drain loop's own completeness check must still be present and
|
||||
// unchanged; the drop-gate fix reuses these exact symbols rather than
|
||||
// inventing a parallel accounting mechanism that could drift from it.
|
||||
expect($contents)->toContain('boost_completed_children() < $expected_children');
|
||||
expect($contents)->toContain('boost_completed_children($run_id) < $expected_children');
|
||||
expect(substr_count($contents, '$expected_children'))->toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
@@ -42,7 +42,7 @@
|
||||
* now updated at the end of each processed iteration.
|
||||
*/
|
||||
|
||||
$boostLibPath = __DIR__ . '/../../../../lib/boost.php';
|
||||
$boostLibPath = CACTI_PATH_LIBRARY . '/boost.php';
|
||||
|
||||
if (!function_exists('boost_handoff_extract_function')) {
|
||||
function boost_handoff_extract_function(string $contents, string $signature) : string {
|
||||
@@ -79,7 +79,7 @@ test('archive-table cleanup forwards still-open-round rows before deleting', fun
|
||||
|
||||
// The archive DELETE (the second db_execute_prepared after the archive
|
||||
// foreach) must run after the forwarding INSERT.
|
||||
$delete_pos = strpos($func_body, 'DELETE IGNORE', $insert_pos);
|
||||
$delete_pos = strpos($func_body, 'DELETE FROM `$table`', $insert_pos);
|
||||
expect($delete_pos)->not->toBeFalse('archive DELETE is missing');
|
||||
expect($insert_pos)->toBeLessThan($delete_pos);
|
||||
});
|
||||
@@ -89,15 +89,15 @@ test('archive-table delete removes the whole local_data_id slice once forwarding
|
||||
$func_body = boost_handoff_extract_function($contents, 'function boost_process_poller_output(');
|
||||
|
||||
$insert_pos = strpos($func_body, 'INSERT IGNORE INTO poller_output_boost');
|
||||
$delete_pos = strpos($func_body, 'DELETE IGNORE', $insert_pos);
|
||||
$delete_pos = strpos($func_body, 'DELETE FROM `$table`', $insert_pos);
|
||||
expect($delete_pos)->not->toBeFalse();
|
||||
|
||||
$delete_segment = substr($func_body, $delete_pos, 200);
|
||||
|
||||
// A forwarded row must not be left behind in the archive table: the
|
||||
// archive-side temp-table seed a few lines above (INSERT INTO
|
||||
// archive-side temp-table seed a few lines above (INSERT IGNORE INTO
|
||||
// `{$temp_table}` SELECT * FROM `{$table}` WHERE local_data_id = ?) has
|
||||
// no time filter and is not INSERT IGNORE, so a leftover forwarded row
|
||||
// no time filter, so a leftover forwarded row
|
||||
// would collide with its own live-table copy on a later call for the
|
||||
// same local_data_id. The delete must therefore not be narrowed to
|
||||
// "time < FROM_UNIXTIME(?)" -- it must clear everything for this
|
||||
@@ -115,7 +115,7 @@ test('the archive-side temp-table seed that forwarded rows must not collide with
|
||||
// confirms the hazard this fix accounts for is still actually present
|
||||
// in the source, so the reasoning stays tied to real code rather than a
|
||||
// stale assumption.
|
||||
$seed_pos = strpos($func_body, 'INSERT INTO `{$temp_table}`');
|
||||
$seed_pos = strpos($func_body, 'INSERT IGNORE INTO `{$temp_table}`');
|
||||
expect($seed_pos)->not->toBeFalse();
|
||||
|
||||
$seed_segment = substr($func_body, $seed_pos, 200);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/*
|
||||
+-------------------------------------------------------------------------+
|
||||
| Copyright (C) 2004-2026 The Cacti Group |
|
||||
+-------------------------------------------------------------------------+
|
||||
| Cacti: The Complete RRDtool-based Graphing Solution |
|
||||
+-------------------------------------------------------------------------+
|
||||
*/
|
||||
|
||||
/*
|
||||
* The BOOST "ignored duplicate sample keys" warning used to name neither the
|
||||
* data source nor the timestamp, so an operator could not tell a retried
|
||||
* batch from a colliding writer. boost_describe_ignored_sample_keys() is the
|
||||
* message builder; flush only logs when INSERT IGNORE drops rows.
|
||||
*/
|
||||
|
||||
$root = dirname(__DIR__, 4);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/Helpers/UnitStubs.php';
|
||||
require_once CACTI_PATH_INCLUDE . '/vendor/autoload.php';
|
||||
require_once CACTI_PATH_LIBRARY . '/boost.php';
|
||||
|
||||
test('flush still logs through boost_log_ignored_sample_keys', function () use ($root) {
|
||||
$src = file_get_contents($root . '/lib/boost.php');
|
||||
|
||||
expect($src)->toContain('boost_log_ignored_sample_keys($chunk, $rows, $inserted)');
|
||||
expect($src)->not->toContain('Boost staging ignored one or more duplicate sample keys.');
|
||||
});
|
||||
|
||||
test('parser reads quoted boost_poller_on_demand tuples', function () {
|
||||
$row = boost_parse_output_tuple("(12,'traffic_in','2026-09-08 09:54:02','1.25')");
|
||||
|
||||
expect($row)->toBe([
|
||||
'local_data_id' => 12,
|
||||
'rrd_name' => 'traffic_in',
|
||||
'time' => '2026-09-08 09:54:02',
|
||||
]);
|
||||
});
|
||||
|
||||
test('parser reads cmd.php CURRENT_TIMESTAMP tuples', function () {
|
||||
$row = boost_parse_output_tuple("(7, 'ifHCInOctets', CURRENT_TIMESTAMP(), 'U')");
|
||||
|
||||
expect($row)->toBe([
|
||||
'local_data_id' => 7,
|
||||
'rrd_name' => 'ifHCInOctets',
|
||||
'time' => 'CURRENT_TIMESTAMP()',
|
||||
]);
|
||||
});
|
||||
|
||||
test('in-batch duplicates name the colliding DS, field, and time', function () {
|
||||
$message = boost_describe_ignored_sample_keys([
|
||||
"(12,'traffic_in','2026-09-08 09:54:02','1')",
|
||||
"(12,'traffic_in','2026-09-08 09:54:02','2')",
|
||||
"(12,'traffic_out','2026-09-08 09:54:02','3')",
|
||||
], 3, 2);
|
||||
|
||||
expect($message)->toContain('ignored 1 of 3 duplicate sample keys')
|
||||
->and($message)->toContain('duplicate keys inside this INSERT')
|
||||
->and($message)->toContain('local_data_id=12')
|
||||
->and($message)->toContain('DS[12] traffic_in @ 2026-09-08 09:54:02 (x2)')
|
||||
->and($message)->not->toContain('traffic_out');
|
||||
});
|
||||
|
||||
test('collisions against existing rows list the attempted keys', function () {
|
||||
$message = boost_describe_ignored_sample_keys([
|
||||
"(44,'ds','2026-09-08 09:54:06','9')",
|
||||
], 1, 0);
|
||||
|
||||
expect($message)->toContain('ignored 1 of 1 duplicate sample keys')
|
||||
->and($message)->toContain('keys already in poller_output_boost')
|
||||
->and($message)->toContain('DS[44] ds @ 2026-09-08 09:54:06');
|
||||
});
|
||||
|
||||
test('describe returns empty when every row was inserted', function () {
|
||||
expect(boost_describe_ignored_sample_keys(["(1,'ds','t','1')"], 1, 1))->toBe('');
|
||||
});
|
||||
@@ -28,11 +28,12 @@ test('lib/boost.php declares the shared boost_flush_output_batch helper', functi
|
||||
$src = file_get_contents($root . '/lib/boost.php');
|
||||
expect($src)->not->toBeFalse();
|
||||
|
||||
expect($src)->toContain('function boost_flush_output_batch(array $value_tuples, mixed $conn = false) : void');
|
||||
expect($src)->toContain('function boost_flush_output_batch(array $value_tuples, mixed $conn = false) : bool');
|
||||
expect($src)->toContain('$acknowledged = db_execute($sql_prefix . $out_buffer, true, $conn) !== false;');
|
||||
|
||||
// Standardized on IGNORE; the ON DUPLICATE KEY UPDATE variant this
|
||||
// replaced must not reappear for poller_output_boost.
|
||||
expect($src)->toContain("INSERT IGNORE INTO poller_output_boost");
|
||||
expect($src)->toContain('INSERT IGNORE INTO poller_output_boost');
|
||||
expect($src)->not->toMatch('/INSERT\s+INTO\s+poller_output_boost[^;]*ON\s+DUPLICATE\s+KEY\s+UPDATE/is');
|
||||
});
|
||||
|
||||
|
||||
@@ -23,22 +23,16 @@
|
||||
* sources of truth and the allow-list accepts Windows paths.
|
||||
*
|
||||
* The pure helpers (clamp, name validation, log-path safety, barrier predicate)
|
||||
* are exercised behaviourally. lib/boost.php is function definitions only, so it
|
||||
* is safe to require once cacti_sizeof() is stubbed. The wiring in poller_boost.php
|
||||
* and the concurrency race itself are asserted by reading the source; the full
|
||||
* multi-process race needs the integration suite.
|
||||
* are exercised behaviourally. The unit bootstrap provides core helpers before
|
||||
* lib/boost.php is loaded. The wiring in poller_boost.php and the concurrency
|
||||
* race itself are asserted by reading the source; the full multi-process race
|
||||
* needs the integration suite.
|
||||
*/
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\cacti_sizeof') && !function_exists('\\cacti_sizeof')) {
|
||||
function cacti_sizeof(mixed $array) : int {
|
||||
return is_array($array) ? count($array) : 0;
|
||||
}
|
||||
}
|
||||
require_once CACTI_PATH_LIBRARY . '/boost.php';
|
||||
|
||||
require_once __DIR__ . '/../../../../lib/boost.php';
|
||||
|
||||
$boostPollerPath = __DIR__ . '/../../../../poller_boost.php';
|
||||
$boostLibPath = __DIR__ . '/../../../../lib/boost.php';
|
||||
$boostPollerPath = CACTI_PATH_BASE . '/poller_boost.php';
|
||||
$boostLibPath = CACTI_PATH_LIBRARY . '/boost.php';
|
||||
|
||||
test('boost_clamp_parallel maps misconfigured values to a single child', function () {
|
||||
expect(boost_clamp_parallel(''))->toBe(1);
|
||||
@@ -131,6 +125,7 @@ test('archive-table fallback no longer relies on the lagging SHOW TABLES probe',
|
||||
// The fallback must validate the name and confirm it with a data-plane read.
|
||||
expect($func_body)->toContain('boost_is_valid_archive_table($latest_table)');
|
||||
expect($func_body)->toContain('boost_archive_table_readable($latest_table)');
|
||||
expect($func_body)->toContain("Boost ignored an unexpected archive-like table name: ' . \$display_name");
|
||||
});
|
||||
|
||||
test('boost_archive_table_readable probes data, not metadata, and rejects bad names', function () use ($boostLibPath) {
|
||||
@@ -151,6 +146,8 @@ test('boost_archive_table_readable probes data, not metadata, and rejects bad na
|
||||
|
||||
test('parent waits for all launched children before draining', function () use ($boostPollerPath) {
|
||||
$contents = file_get_contents($boostPollerPath);
|
||||
expect($contents)->not->toBeFalse();
|
||||
$contents = (string) $contents;
|
||||
|
||||
// boost_launch_children() returns the launched count and the barrier waits on
|
||||
// boost_all_children_registered() rather than releasing on the first signup.
|
||||
@@ -165,10 +162,12 @@ test('parent waits for all launched children before draining', function () use (
|
||||
|
||||
test('drain loop exits only when every child has recorded completion', function () use ($boostPollerPath) {
|
||||
$contents = file_get_contents($boostPollerPath);
|
||||
expect($contents)->not->toBeFalse();
|
||||
$contents = (string) $contents;
|
||||
|
||||
// The old loop exited as soon as no child was running. The fix also requires
|
||||
// every launched child to have a completion row before draining.
|
||||
expect($contents)->toContain('boost_completed_children() < $expected_children');
|
||||
expect($contents)->toContain('boost_completed_children($run_id) < $expected_children');
|
||||
expect($contents)->not->toMatch('/while\s*\(\s*\$running\s*=\s*boost_processes_running\(\)\s*\)/');
|
||||
});
|
||||
|
||||
@@ -203,3 +202,26 @@ test('boost_launch_children uses the shared log-path safety helper', function ()
|
||||
|
||||
expect($contents)->toContain('boost_log_path_is_safe($boost_log)');
|
||||
});
|
||||
|
||||
test('parent and child repair the completion schema before using it', function () use ($boostPollerPath) {
|
||||
$contents = file_get_contents($boostPollerPath);
|
||||
|
||||
$master_repair = strpos($contents, 'boost_ensure_process_table(true)');
|
||||
$truncate = strpos($contents, "db_execute('TRUNCATE TABLE poller_output_boost_processes')");
|
||||
$child_start = strpos($contents, "cacti_log('INFO: Boost register child process '");
|
||||
$child_repair = strpos($contents, 'boost_ensure_process_table(true)', $master_repair + 1);
|
||||
$child_insert = strpos($contents, "db_execute_prepared('INSERT INTO poller_output_boost_processes", $child_start);
|
||||
|
||||
expect($master_repair)->not->toBeFalse()
|
||||
->and($truncate)->not->toBeFalse()
|
||||
->and($master_repair)->toBeLessThan($truncate)
|
||||
->and($child_repair)->not->toBeFalse()
|
||||
->and($child_insert)->not->toBeFalse()
|
||||
->and($child_repair)->toBeLessThan($child_insert);
|
||||
});
|
||||
|
||||
test('uses the unit bootstrap cacti_sizeof implementation', function () {
|
||||
$contents = file_get_contents(__FILE__);
|
||||
|
||||
expect($contents)->not->toContain('function cacti_' . 'sizeof(');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/*
|
||||
+-------------------------------------------------------------------------+
|
||||
| Copyright (C) 2004-2026 The Cacti Group |
|
||||
+-------------------------------------------------------------------------+
|
||||
| Cacti: The Complete RRDtool-based Graphing Solution |
|
||||
+-------------------------------------------------------------------------+
|
||||
*/
|
||||
|
||||
// db_add_column() reads $column['NULL']. PHP array keys are case sensitive, so
|
||||
// a lowercase 'null' key is silently discarded and the column is created
|
||||
// nullable even though the upgrade asked for NOT NULL.
|
||||
test('upgrade column definitions spell the NULL key in upper case', function () {
|
||||
$upgrades = glob(dirname(__DIR__, 3) . '/install/upgrades/*.php');
|
||||
|
||||
expect($upgrades)->not->toBeFalse();
|
||||
$upgrades = is_array($upgrades) ? $upgrades : [];
|
||||
expect($upgrades)->not->toBeEmpty();
|
||||
|
||||
foreach ($upgrades as $upgrade) {
|
||||
$contents = file_get_contents($upgrade);
|
||||
|
||||
expect($contents)->not->toBeFalse();
|
||||
|
||||
expect(preg_match('/[\'\"]null[\'\"]\s*=>/', $contents))
|
||||
->toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('the column builder still reads the upper case key', function () {
|
||||
$database = file_get_contents(dirname(__DIR__, 3) . '/lib/database.php');
|
||||
|
||||
expect($database)->not->toBeFalse()
|
||||
->and($database)->toContain("\$column['NULL']");
|
||||
});
|
||||
@@ -72,7 +72,7 @@ beforeEach(function () {
|
||||
|
||||
$this->db_globals = [$database_sessions, $database_hostname, $database_port, $database_default];
|
||||
|
||||
$dsn = getenv('CACTI_TEST_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=33061;dbname=cacti_test';
|
||||
$dsn = getenv('CACTI_TEST_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=33061;dbname=cacti_test';
|
||||
$user = getenv('CACTI_TEST_MYSQL_USER') ?: 'root';
|
||||
$pass = getenv('CACTI_TEST_MYSQL_PASS') ?: 'root';
|
||||
|
||||
@@ -126,7 +126,7 @@ test('a row from a still-open poll round survives the archive cleanup by moving
|
||||
boost_handoff_extract_sql($contents, "INSERT IGNORE INTO poller_output_boost\n\t\t\t\tSELECT *", 'AND time >= FROM_UNIXTIME(?)'));
|
||||
|
||||
$delete_sql = str_replace('$table', 'poller_output_boost_arch_handoff_test',
|
||||
boost_handoff_extract_sql($contents, 'DELETE IGNORE', 'WHERE local_data_id = ?'));
|
||||
rtrim(boost_handoff_extract_sql($contents, 'DELETE FROM `$table`', 'WHERE local_data_id = ?'), '"'));
|
||||
|
||||
$local_data_id = 42;
|
||||
$cutoff = 1_700_000_000; // the in-progress-round safety cutoff ($timestamp)
|
||||
@@ -180,7 +180,7 @@ test('closed-round rows are removed from the archive table and not duplicated in
|
||||
boost_handoff_extract_sql($contents, "INSERT IGNORE INTO poller_output_boost\n\t\t\t\tSELECT *", 'AND time >= FROM_UNIXTIME(?)'));
|
||||
|
||||
$delete_sql = str_replace('$table', 'poller_output_boost_arch_handoff_test',
|
||||
boost_handoff_extract_sql($contents, 'DELETE IGNORE', 'WHERE local_data_id = ?'));
|
||||
rtrim(boost_handoff_extract_sql($contents, 'DELETE FROM `$table`', 'WHERE local_data_id = ?'), '"'));
|
||||
|
||||
$local_data_id = 9;
|
||||
$cutoff = 1_700_000_000;
|
||||
@@ -199,33 +199,27 @@ test('closed-round rows are removed from the archive table and not duplicated in
|
||||
expect((int) db_fetch_cell('SELECT COUNT(*) FROM poller_output_boost'))->toBe(0);
|
||||
});
|
||||
|
||||
test('a forwarded row left in the archive table would collide with itself on the next run -- proving why the delete must not be narrowed', function () use ($boostLibPath) {
|
||||
test('the temporary merge coalesces duplicate archive and live keys', function () use ($boostLibPath) {
|
||||
if (!isset($this->pdo) || !$this->pdo instanceof PDO) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This reconstructs the exact hazard the wide (non-time-narrowed) delete
|
||||
// avoids: run the archive-side temp-table seed query verbatim from the
|
||||
// source (no time filter, not INSERT IGNORE) against a row that was
|
||||
// forwarded to the live table but hypothetically left behind in the
|
||||
// archive table, to prove such a leftover would break the next run's
|
||||
// own merge query with a primary-key collision.
|
||||
// Run both temp-table seed statements verbatim. They deliberately use
|
||||
// INSERT IGNORE so a retry row present in both the archive and live tables
|
||||
// is processed once instead of aborting the handoff on a primary-key clash.
|
||||
$contents = file_get_contents($boostLibPath);
|
||||
|
||||
// Two `INSERT INTO `{$temp_table}`` statements exist in this function:
|
||||
// the first seeds from the archive table (no time filter, no IGNORE),
|
||||
// the second seeds from the live table (time < $timestamp, no IGNORE).
|
||||
$first_seed_pos = strpos($contents, 'INSERT INTO `{$temp_table}`');
|
||||
$second_seed_pos = strpos($contents, 'INSERT INTO `{$temp_table}`', $first_seed_pos + 1);
|
||||
$first_seed_pos = strpos($contents, 'INSERT IGNORE INTO `{$temp_table}`');
|
||||
$second_seed_pos = strpos($contents, 'INSERT IGNORE INTO `{$temp_table}`', $first_seed_pos + 1);
|
||||
|
||||
expect($first_seed_pos)->not->toBeFalse();
|
||||
expect($second_seed_pos)->not->toBeFalse();
|
||||
|
||||
$seed_sql = rtrim(str_replace(['{$temp_table}', '{$table}'], ['poller_output_boost_temp_test', 'poller_output_boost_arch_handoff_test'],
|
||||
boost_handoff_extract_sql(substr($contents, $first_seed_pos, $second_seed_pos - $first_seed_pos), 'INSERT INTO `{$temp_table}`', 'WHERE local_data_id = ?"')), '"');
|
||||
boost_handoff_extract_sql(substr($contents, $first_seed_pos, $second_seed_pos - $first_seed_pos), 'INSERT IGNORE INTO `{$temp_table}`', 'WHERE local_data_id = ?"')), '"');
|
||||
|
||||
$live_seed_sql = rtrim(str_replace('{$temp_table}', 'poller_output_boost_temp_test',
|
||||
boost_handoff_extract_sql(substr($contents, $second_seed_pos), 'INSERT INTO `{$temp_table}`', 'AND time < FROM_UNIXTIME(?)"')), '"');
|
||||
boost_handoff_extract_sql(substr($contents, $second_seed_pos), 'INSERT IGNORE INTO `{$temp_table}`', 'AND time < FROM_UNIXTIME(?)"')), '"');
|
||||
|
||||
$this->pdo->exec('CREATE TEMPORARY TABLE poller_output_boost_temp_test LIKE poller_output_boost');
|
||||
|
||||
@@ -245,14 +239,14 @@ test('a forwarded row left in the archive table would collide with itself on the
|
||||
|
||||
db_execute_prepared($seed_sql, [$local_data_id], false);
|
||||
|
||||
// The live-table seed (not INSERT IGNORE, matching production) must now
|
||||
// fail on the primary-key collision with the row the archive seed just
|
||||
// inserted -- demonstrating why the actual fix deletes the whole
|
||||
// archive slice instead of leaving this row behind.
|
||||
$next_cutoff = $row_time + 3600;
|
||||
$collision = db_execute_prepared($live_seed_sql, [$local_data_id, $next_cutoff], false);
|
||||
$merged = db_execute_prepared($live_seed_sql, [$local_data_id, $next_cutoff], false);
|
||||
|
||||
expect($collision)->toBeFalse();
|
||||
expect($merged)->not->toBeFalse();
|
||||
|
||||
$rows = db_fetch_assoc('SELECT output FROM poller_output_boost_temp_test');
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and($rows[0]['output'])->toBe('from-archive');
|
||||
|
||||
$this->pdo->exec('DROP TEMPORARY TABLE IF EXISTS poller_output_boost_temp_test');
|
||||
});
|
||||
|
||||
@@ -55,6 +55,28 @@ class BoostPacketLookupPDO extends FakeMySQLPDO {
|
||||
}
|
||||
}
|
||||
|
||||
class BoostFailingChunkPDO extends BoostPacketLookupPDO {
|
||||
public int $insert_attempts = 0;
|
||||
|
||||
public function prepare(string $query, array $options = []): PDOStatement|false {
|
||||
if (stripos($query, 'max_allowed_packet') !== false) {
|
||||
$this->packet_lookups++;
|
||||
|
||||
return parent::prepare("SELECT 'max_allowed_packet' AS Variable_name, '512' AS Value", $options);
|
||||
}
|
||||
|
||||
if (stripos(ltrim($query), 'INSERT IGNORE INTO poller_output_boost') === 0) {
|
||||
$this->insert_attempts++;
|
||||
|
||||
if ($this->insert_attempts === 2) {
|
||||
throw new RuntimeException('injected second-chunk failure');
|
||||
}
|
||||
}
|
||||
|
||||
return parent::prepare($query, $options);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
global $database_sessions, $database_hostname, $database_port, $database_default;
|
||||
|
||||
@@ -103,3 +125,20 @@ test('max_allowed_packet is read once per connection, not once per process', fun
|
||||
expect($count)->toBe($expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed chunk stops the batch before later tuples are staged', function () {
|
||||
$conn = new BoostFailingChunkPDO();
|
||||
$output = str_repeat('x', 300);
|
||||
|
||||
$result = boost_flush_output_batch([
|
||||
"(11,'first','2024-01-01 00:00:00','{$output}')",
|
||||
"(12,'failed','2024-01-01 00:00:00','{$output}')",
|
||||
"(13,'later','2024-01-01 00:00:00','{$output}')",
|
||||
], $conn);
|
||||
|
||||
$ids = $conn->query('SELECT local_data_id FROM poller_output_boost ORDER BY local_data_id')->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
expect($result)->toBeFalse()
|
||||
->and($conn->insert_attempts)->toBe(2)
|
||||
->and($ids)->toBe([11]);
|
||||
});
|
||||
@@ -27,16 +27,28 @@
|
||||
|
||||
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/boost.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 FakeMySQLPDO();
|
||||
$conn = new BoostBatchPDO();
|
||||
|
||||
$conn->exec('CREATE TABLE poller_output_boost (
|
||||
local_data_id INTEGER NOT NULL,
|
||||
@@ -45,6 +57,7 @@ beforeEach(function () {
|
||||
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';
|
||||
@@ -73,7 +86,7 @@ function boost_test_fetch_output(PDO $conn, int $local_data_id, string $rrd_name
|
||||
}
|
||||
|
||||
test('writes a fresh row when no key collision exists', function () {
|
||||
boost_flush_output_batch(["(1,'ds','2024-01-01 00:00:00','100')"], $this->conn);
|
||||
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);
|
||||
@@ -113,9 +126,146 @@ test('non-colliding rows in the same batch are all written', function () {
|
||||
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 () {
|
||||
boost_flush_output_batch([], $this->conn);
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
<?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 |
|
||||
+-------------------------------------------------------------------------+
|
||||
*/
|
||||
|
||||
/*
|
||||
* upgrade_boost_process_table() has to add the run_child UNIQUE key to
|
||||
* poller_output_boost_processes, and nothing stops cron from starting
|
||||
* poller_boost.php while the installer is working. Rows written before this
|
||||
* upgrade carry no run identifier, so the ALTERs default every one of them to
|
||||
* ('', 0) and the key cannot build over them; rows written by a child of an
|
||||
* in-flight run carry a real run_id and are what the parent counts when it
|
||||
* decides whether every child finished.
|
||||
*
|
||||
* These cases run against a real server because the discriminating behaviour
|
||||
* is the engine's: whether the UNIQUE key builds, and which rows survive.
|
||||
*
|
||||
* Requires a MySQL/MariaDB instance; see BoostArchiveHandoffIntegrationTest.php
|
||||
* for the same env vars. Skips (does not fail) if unreachable.
|
||||
*/
|
||||
|
||||
// Production upgrade entry points define this before loading upgrade files.
|
||||
// It also makes schema probes bypass their runtime cache while this test
|
||||
// deliberately drops and recreates the same table between cases.
|
||||
if (!defined('IN_CACTI_INSTALL')) {
|
||||
define('IN_CACTI_INSTALL', 1);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../Helpers/UnitStubs.php';
|
||||
require_once dirname(__DIR__, 2) . '/lib/database.php';
|
||||
require_once dirname(__DIR__, 2) . '/lib/boost.php';
|
||||
require_once dirname(__DIR__, 2) . '/install/functions.php';
|
||||
require_once dirname(__DIR__, 2) . '/install/upgrades/1_3_0.php';
|
||||
|
||||
beforeEach(function () {
|
||||
global $database_sessions, $database_hostname, $database_port, $database_default;
|
||||
|
||||
$this->db_globals = [$database_sessions, $database_hostname, $database_port, $database_default];
|
||||
|
||||
$dsn = getenv('CACTI_TEST_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=33061;dbname=cacti_test';
|
||||
$user = getenv('CACTI_TEST_MYSQL_USER') ?: 'root';
|
||||
$pass = getenv('CACTI_TEST_MYSQL_PASS') ?: 'root';
|
||||
|
||||
try {
|
||||
$this->pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, PDO::ATTR_TIMEOUT => 3]);
|
||||
} catch (\PDOException $e) {
|
||||
$this->pdo = null;
|
||||
test()->markTestSkipped('No MySQL/MariaDB test instance reachable at ' . $dsn . ': ' . $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$database_hostname = 'unit-test-host';
|
||||
$database_port = 3306;
|
||||
$database_default = 'cacti_boost_upgrade_test';
|
||||
|
||||
$database_sessions["$database_hostname:$database_port:$database_default"] = $this->pdo;
|
||||
$this->pdo->exec("SET SESSION sql_mode = CONCAT_WS(',', @@sql_mode, 'ANSI_QUOTES')");
|
||||
|
||||
// db_install_add_cache() stamps install_updated on every statement.
|
||||
$this->pdo->exec('CREATE TABLE IF NOT EXISTS settings (
|
||||
name varchar(50) NOT NULL default \'\',
|
||||
value varchar(2048) NOT NULL default \'\',
|
||||
PRIMARY KEY (name))');
|
||||
|
||||
$this->pdo->exec('DROP TABLE IF EXISTS poller_output_boost_processes');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
global $database_sessions, $database_hostname, $database_port, $database_default;
|
||||
|
||||
if (isset($this->pdo) && $this->pdo instanceof PDO) {
|
||||
$this->pdo->exec('DROP TABLE IF EXISTS poller_output_boost_processes');
|
||||
}
|
||||
|
||||
// Put the default db_* connection back so the test handle does not answer
|
||||
// every later read_config_option() in the run.
|
||||
[$database_sessions, $database_hostname, $database_port, $database_default] = $this->db_globals;
|
||||
});
|
||||
|
||||
function boostProcessIndexColumns(PDO $pdo) : array {
|
||||
$rows = $pdo->query("SHOW INDEX FROM poller_output_boost_processes WHERE Key_name = 'run_child'");
|
||||
|
||||
if ($rows === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
|
||||
foreach ($rows->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$columns[(int) $row['Seq_in_index']] = $row['Column_name'] . ':' . $row['Non_unique'];
|
||||
}
|
||||
|
||||
ksort($columns);
|
||||
|
||||
return array_values($columns);
|
||||
}
|
||||
|
||||
test('the 1.2.x table gains the run_child key even though its rows cannot satisfy it', function () {
|
||||
if (!isset($this->pdo) || !$this->pdo instanceof PDO) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pdo->exec('CREATE TABLE poller_output_boost_processes (
|
||||
sock_int_value bigint(20) unsigned NOT NULL auto_increment,
|
||||
status varchar(255) default NULL,
|
||||
PRIMARY KEY (sock_int_value)) ENGINE=MEMORY');
|
||||
|
||||
$this->pdo->exec("INSERT INTO poller_output_boost_processes (status) VALUES ('118'), ('92'), ('0')");
|
||||
|
||||
upgrade_boost_process_table();
|
||||
|
||||
expect(boostProcessIndexColumns($this->pdo))->toBe(['run_id:0', 'child_id:0'])
|
||||
->and((int) $this->pdo->query('SELECT COUNT(*) FROM poller_output_boost_processes')->fetchColumn())->toBe(0);
|
||||
});
|
||||
|
||||
test('an in-flight run keeps its completion rows while the legacy rows are dropped', function () {
|
||||
if (!isset($this->pdo) || !$this->pdo instanceof PDO) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A collector already carrying the 1.3.0 columns but not yet the key, which
|
||||
// is what a develop-to-develop upgrade finds.
|
||||
$this->pdo->exec("CREATE TABLE poller_output_boost_processes (
|
||||
sock_int_value bigint(20) unsigned NOT NULL auto_increment,
|
||||
run_id char(32) NOT NULL default '',
|
||||
child_id int(10) unsigned NOT NULL default 0,
|
||||
status varchar(255) default NULL,
|
||||
PRIMARY KEY (sock_int_value)) ENGINE=MEMORY");
|
||||
|
||||
$run_id = '3f2a91c4d80b47e6a15c9f0e7b32d5a8';
|
||||
|
||||
$this->pdo->exec("INSERT INTO poller_output_boost_processes (run_id, child_id, status) VALUES
|
||||
('$run_id', 1, '4210'),
|
||||
('$run_id', 2, '3987'),
|
||||
('', 0, '77')");
|
||||
|
||||
upgrade_boost_process_table();
|
||||
|
||||
$survivors = $this->pdo->query('SELECT run_id, child_id, status
|
||||
FROM poller_output_boost_processes
|
||||
ORDER BY child_id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
expect($survivors)->toBe([
|
||||
['run_id' => $run_id, 'child_id' => 1, 'status' => '4210'],
|
||||
['run_id' => $run_id, 'child_id' => 2, 'status' => '3987'],
|
||||
]);
|
||||
|
||||
// The parent drains on COUNT(*) for its own run_id, so those two rows are
|
||||
// the difference between finishing the run and warning about a crashed
|
||||
// child while holding the archive tables.
|
||||
$completed = $this->pdo->prepare('SELECT COUNT(*) FROM poller_output_boost_processes WHERE run_id = ?');
|
||||
$completed->execute([$run_id]);
|
||||
|
||||
expect((int) $completed->fetchColumn())->toBe(2)
|
||||
->and(boostProcessIndexColumns($this->pdo))->toBe(['run_id:0', 'child_id:0']);
|
||||
|
||||
// What the upgrade used to do, on the same rows.
|
||||
$this->pdo->exec('TRUNCATE TABLE poller_output_boost_processes');
|
||||
$completed->execute([$run_id]);
|
||||
|
||||
expect((int) $completed->fetchColumn())->toBe(0);
|
||||
});
|
||||
|
||||
test('a missing process table is recreated with the complete current schema', function () {
|
||||
if (!isset($this->pdo) || !$this->pdo instanceof PDO) {
|
||||
return;
|
||||
}
|
||||
|
||||
upgrade_boost_process_table();
|
||||
|
||||
$columns = $this->pdo->query('SHOW COLUMNS FROM poller_output_boost_processes')->fetchAll(PDO::FETCH_COLUMN);
|
||||
$engine = $this->pdo->query("SELECT ENGINE FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'poller_output_boost_processes'")->fetchColumn();
|
||||
|
||||
expect($columns)->toBe(['sock_int_value', 'run_id', 'child_id', 'status'])
|
||||
->and(boostProcessIndexColumns($this->pdo))->toBe(['run_id:0', 'child_id:0'])
|
||||
->and($engine)->toBe('MEMORY');
|
||||
});
|
||||
|
||||
test('an already upgraded process table and its completion row are unchanged', function () {
|
||||
if (!isset($this->pdo) || !$this->pdo instanceof PDO) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pdo->exec("CREATE TABLE poller_output_boost_processes (
|
||||
sock_int_value bigint(20) unsigned NOT NULL auto_increment,
|
||||
run_id char(32) NOT NULL default '',
|
||||
child_id int(10) unsigned NOT NULL default 0,
|
||||
status varchar(255) default NULL,
|
||||
PRIMARY KEY (sock_int_value),
|
||||
UNIQUE KEY run_child (run_id, child_id)) ENGINE=MEMORY");
|
||||
$this->pdo->exec("INSERT INTO poller_output_boost_processes (run_id, child_id, status)
|
||||
VALUES ('3f2a91c4d80b47e6a15c9f0e7b32d5a8', 4, '123')");
|
||||
|
||||
upgrade_boost_process_table();
|
||||
|
||||
$row = $this->pdo->query('SELECT run_id, child_id, status FROM poller_output_boost_processes')->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
expect($row)->toBe([
|
||||
'run_id' => '3f2a91c4d80b47e6a15c9f0e7b32d5a8',
|
||||
'child_id' => 4,
|
||||
'status' => '123',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the run_child index rejects duplicate completion reports for one child', function () {
|
||||
if (!isset($this->pdo) || !$this->pdo instanceof PDO) {
|
||||
return;
|
||||
}
|
||||
|
||||
upgrade_boost_process_table();
|
||||
|
||||
$insert = $this->pdo->prepare('INSERT INTO poller_output_boost_processes (run_id, child_id, status) VALUES (?, ?, ?)');
|
||||
expect($insert->execute(['3f2a91c4d80b47e6a15c9f0e7b32d5a8', 1, '10']))->toBeTrue();
|
||||
|
||||
$duplicate = $insert->execute(['3f2a91c4d80b47e6a15c9f0e7b32d5a8', 1, '20']);
|
||||
|
||||
expect($duplicate)->toBeFalse()
|
||||
->and((int) $this->pdo->query('SELECT COUNT(*) FROM poller_output_boost_processes')->fetchColumn())->toBe(1);
|
||||
});
|
||||
|
||||
test('runtime repair makes a legacy process table safe before a child records completion', function () {
|
||||
if (!isset($this->pdo) || !$this->pdo instanceof PDO) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pdo->exec('CREATE TABLE poller_output_boost_processes (
|
||||
sock_int_value bigint(20) unsigned NOT NULL auto_increment,
|
||||
status varchar(255) default NULL,
|
||||
PRIMARY KEY (sock_int_value)) ENGINE=MEMORY');
|
||||
$this->pdo->exec("INSERT INTO poller_output_boost_processes (status) VALUES ('legacy')");
|
||||
|
||||
expect(boost_ensure_process_table(true))->toBeTrue()
|
||||
->and(boostProcessIndexColumns($this->pdo))->toBe(['run_id:0', 'child_id:0'])
|
||||
->and((int) $this->pdo->query('SELECT COUNT(*) FROM poller_output_boost_processes')->fetchColumn())->toBe(0);
|
||||
|
||||
$insert = $this->pdo->prepare('INSERT INTO poller_output_boost_processes
|
||||
(run_id, child_id, status) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE status = VALUES(status)');
|
||||
|
||||
expect($insert->execute(['3f2a91c4d80b47e6a15c9f0e7b32d5a8', 1, '10']))->toBeTrue();
|
||||
});
|
||||
@@ -169,7 +169,7 @@ fs_write ./lib/CactiFilesystem.php $this->filesystem->dumpFile($filename, $con
|
||||
fs_write ./lib/CactiFilesystem.php self::default()->writeFile($filename, $content);
|
||||
fs_write ./lib/api_device.php if (file_put_contents($new_xmlfile, $data) === false) {
|
||||
fs_write ./lib/boost.php if ($fileptr = fopen($cache_file, 'rb')) {
|
||||
fs_write ./lib/boost.php if ($fileptr = fopen($cache_file, 'w')) {
|
||||
fs_write ./lib/boost.php $fileptr = fopen($temp_file, 'wb');
|
||||
fs_write ./lib/clog_webapi.php $log_fh = fopen($logfile, 'w');
|
||||
fs_write ./lib/csp_report_endpoint.php $inputStream = fopen('php://input', 'rb');
|
||||
fs_write ./lib/database.php file_put_contents(sys_get_temp_dir() . '/cacti-option.log',
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 22 and column 251.
|
@@ -50,7 +50,7 @@
|
||||
"poller_automation.php": 2,
|
||||
"poller_boost.php": 5,
|
||||
"poller_dsstats.php": 1,
|
||||
"poller_recovery.php": 4,
|
||||
"poller_recovery.php": 2,
|
||||
"poller_spikekill.php": 1,
|
||||
"rrdcleaner.php": 3,
|
||||
"support.php": 3,
|
||||
|
||||
Reference in new issue
Block a user