mirror of
https://github.com/Cacti/plugin_thold.git
synced 2026-11-01 01:26:47 +09:00
* INFO: set compat to 1.2.32 * docs: update Copilot instructions Cacti baseline to 1.2.32 (matches INFO compat) * INFO: back off compat to 1.2.29 * Add manifest.json + upgrade-time file pruning * Prune phpunit.xml on upgrade and ignore markdown-lint configs phpunit.xml ships in the repo for CI but is a dev-only test artifact, so it is removed from manifest.json 'expected', pruned from installs on upgrade (like tests/), and excluded from the manifest drift check. The retired markdown-lint configs (.mdlrc, .md_style.rb) are deleted, and .md* is now ignored like .git*: protected from pruning and excluded from drift if it reappears. * Move thold_functions.php and thold_webapi.php into includes/; flatten css The main threshold library becomes includes/functions.php and the web API helper becomes includes/webapi.php; every caller (setup.php, includes/*, the CLI/poller/notify entry points and the test suite) loads them from includes/. Each per-theme stylesheet css/<theme>/main.css becomes css/<theme>.css so users can drop their own css/<theme>.css into the css base, and the page_head hook looks there. Old paths are tombstoned so existing installs drop the stale copies on upgrade. * Regenerate cacti.pot for the includes/ file relocations Moving the plugin's library files into includes/ changed the source-reference lines in the translation template; regenerate it so the 'Verify translation template' CI check passes. * Harden the hook-include test and make RPN integer ops explicit The HookFunctionsIncludeTest invokes real hooks with placeholder input purely to execute their relocated include_once lines; it now swallows engine notices/warnings/deprecations during those calls (and restores the error handler and config afterward) so it cannot trip the suite's failOnDeprecation or leak state into other tests. The RPN % and ^ operators now cast their operands to int explicitly, matching their long-standing integer-truncation behaviour while silencing the implicit float-to-int conversion deprecation. * Harden prune against tampered-manifest path traversal The upgrade-time prune now rejects any tombstone containing '.'/'..' segments (which could escape the plugin directory or resolve to its root) and treats ancestors of whitelist entries as protected, so a tombstone on a parent directory can no longer delete a whitelisted file beneath it. * docs: update and align the Project Structure block Point the structure tree at the relocated files (libraries now under includes/, data files under docs/, stylesheets under css/) and align the trailing '# ...' comments to a single column so they no longer drift right. * docs/tests: standardize test header and correct manifest-contract notes - Use the full license header (from the plugin's own setup.php) in tests/Unit/PruneFilesTest.php instead of the abbreviated copyright banner. - Document that the manifest drift check and the upgrade-time prune also handle phpunit.xml and .md* files, matching the implemented behavior. * refactor: rename prune helpers to the plugin-slug function prefix The repository naming contract reserves the plugin_<name>_ prefix for plugin lifecycle / hook-registration functions; all other functions use the plain <name>_ prefix. Rename the internal upgrade helpers accordingly: plugin_<name>_prune_files() -> <name>_prune_files() plugin_<name>_rmtree() -> <name>_rmtree() The call site, unit tests, and the copilot-instructions.md references are updated to match. No behavioral change. * chore: set the Cacti compatibility floor to 1.2.29 Align the compat metadata (and the contributor-guide references) with the team decision to standardize the minimum supported Cacti version at 1.2.29; the previously proposed 1.2.32 floor was not adopted. --------- Co-authored-by: Copilot <copilot@users.noreply.github.com>
856 lines
30 KiB
PHP
856 lines
30 KiB
PHP
<?php
|
|
/*
|
|
+-------------------------------------------------------------------------+
|
|
| Copyright (C) 2004-2026 The Cacti Group |
|
|
| |
|
|
| This program is free software; you can redistribute it and/or |
|
|
| modify it under the terms of the GNU General Public License |
|
|
| as published by the Free Software Foundation; either version 2 |
|
|
| of the License, or (at your option) any later version. |
|
|
+-------------------------------------------------------------------------+
|
|
| Cacti: The Complete RRDtool-based Graphing Solution |
|
|
+-------------------------------------------------------------------------+
|
|
| http://www.cacti.net/ |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
/**
|
|
* thold_get_currentval() turns a raw sample into the rate a threshold is
|
|
* compared against, per the data source type.
|
|
*/
|
|
final class TholdGetCurrentvalTest extends TestCase {
|
|
const GAUGE = 1;
|
|
const COUNTER = 2;
|
|
const DERIVE = 3;
|
|
const ABSOLUTE = 4;
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public static function setUpBeforeClass(): void {
|
|
self::loadPluginSource('includes/functions.php');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function threshold(array $overrides = []) {
|
|
return $overrides + [
|
|
'id' => 9,
|
|
'thold_id' => 9,
|
|
'local_data_id' => 4,
|
|
'name' => 'traffic_in',
|
|
'data_source_type_id' => self::COUNTER,
|
|
'rrd_step' => 300,
|
|
'rrd_maximum' => 0,
|
|
'lasttime' => 1700000000,
|
|
'oldvalue' => 100,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $thold
|
|
* @param float|int|string $reading
|
|
*
|
|
* @return mixed
|
|
*/
|
|
private function currentValue(array $thold, $reading) {
|
|
$reindexed = [4 => ['traffic_in' => $reading]];
|
|
$time_reindexed = [4 => 1700000300];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
return thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testGaugeReturnsTheReadingUnchanged(): void {
|
|
$thold = $this->threshold(['data_source_type_id' => self::GAUGE]);
|
|
|
|
$this->assertSame(42, $this->currentValue($thold, 42));
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testAbsoluteDividesTheReadingByTheStep(): void {
|
|
$thold = $this->threshold(['data_source_type_id' => self::ABSOLUTE]);
|
|
|
|
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 600), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testAbsoluteRejectsStaleAndOutOfOrderIntervals(): void {
|
|
foreach ([1000 + 86400, 900] as $sample_time) {
|
|
$thold = $this->threshold(['data_source_type_id' => self::ABSOLUTE, 'lasttime' => 1000]);
|
|
$reindexed = [4 => ['traffic_in' => 600]];
|
|
$time_reindexed = [4 => $sample_time];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
$this->assertSame('', thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testGaugeRemainsValidAcrossAGap(): void {
|
|
$thold = $this->threshold(['data_source_type_id' => self::GAUGE, 'lasttime' => 1000]);
|
|
$reindexed = [4 => ['traffic_in' => 42]];
|
|
$time_reindexed = [4 => 1000 + 7 * 86400];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
$this->assertSame(42, thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime));
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testCounterReturnsTheDeltaOverTheStep(): void {
|
|
$thold = $this->threshold(['oldvalue' => 100]);
|
|
|
|
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 700), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* A counter that legitimately read zero last cycle is not the same as
|
|
* having no previous reading. Treating it as absent reports a rate of zero
|
|
* for the first interval after a device reboot.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function testCounterTreatsAPreviousReadingOfZeroAsReal(): void {
|
|
$thold = $this->threshold(['oldvalue' => 0]);
|
|
|
|
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 600), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testCounterWithNoPreviousReadingYieldsUnknown(): void {
|
|
$thold = $this->threshold(['lasttime' => 0, 'oldvalue' => '']);
|
|
|
|
$this->assertSame('', $this->currentValue($thold, 600));
|
|
}
|
|
|
|
/**
|
|
* A 32-bit counter that wraps has advanced by (2^32 - old) + new. Using
|
|
* 2^32-1 as the modulus loses exactly one count per wrap.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function testThirtyTwoBitWrapUsesTheCorrectModulus(): void {
|
|
$thold = $this->threshold(['oldvalue' => 4294967290, 'rrd_step' => 1, 'lasttime' => 1700000299]);
|
|
|
|
$this->assertEqualsWithDelta(11, $this->currentValue($thold, 5), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testSixtyFourBitWrapUsesTheCorrectModulus(): void {
|
|
$thold = $this->threshold(['oldvalue' => '18446744073709551610', 'rrd_step' => 1, 'lasttime' => 1700000299]);
|
|
|
|
$this->assertEqualsWithDelta(11, $this->currentValue($thold, 5), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* RRD values can arrive in scientific notation. GMP accepts only integer
|
|
* strings, so these values must use the non-fatal floating-point fallback.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function testSixtyFourBitWrapAcceptsScientificNotation(): void {
|
|
$thold = $this->threshold(['oldvalue' => '1.8446744073709552E+19', 'rrd_step' => 1, 'lasttime' => 1700000299]);
|
|
|
|
$this->assertEqualsWithDelta(5, $this->currentValue($thold, 5), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testDeriveDividesTheDeltaByTheStep(): void {
|
|
$thold = $this->threshold(['data_source_type_id' => self::DERIVE, 'oldvalue' => 100]);
|
|
|
|
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 700), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testNonNumericReadingYieldsTheNoValueSentinel(): void {
|
|
$this->assertSame('', $this->currentValue($this->threshold(), 'U'));
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testMissingDataSourceYieldsTheNoValueSentinel(): void {
|
|
$thold = $this->threshold();
|
|
$reindexed = [];
|
|
$time_reindexed = [4 => 1700000300];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
$this->assertSame('', thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime));
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testLowerUpperCombinationRejectsUnknownInputs(): void {
|
|
$thold = ['local_data_id' => 4, 'upper_ds' => 'upper'];
|
|
|
|
$this->assertSame('', thold_calculate_lower_upper($thold, '', [4 => ['upper' => 5]]));
|
|
$this->assertSame('', thold_calculate_lower_upper($thold, 7, [4 => ['upper' => 'U']]));
|
|
$this->assertSame('', thold_calculate_lower_upper($thold, 7, [4 => []]));
|
|
$this->assertEqualsWithDelta((5 * 4294967296) + 7, thold_calculate_lower_upper($thold, 7, [4 => ['upper' => 5]]), 1.0e-9);
|
|
$this->assertEqualsWithDelta((2147483648.0 * 4294967296) + 7, thold_calculate_lower_upper($thold, 7, [4 => ['upper' => 2147483648]]), 1);
|
|
$this->assertEqualsWithDelta((4294967295.0 * 4294967296) + 7, thold_calculate_lower_upper($thold, 7, [4 => ['upper' => 4294967295]]), 1);
|
|
$this->assertSame('', thold_calculate_lower_upper($thold, 7, [4 => ['upper' => -1]]));
|
|
$this->assertSame('', thold_calculate_lower_upper($thold, 7, [4 => ['upper' => 4294967296]]));
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testCdefAndNestedExpressionPreserveUnknownValues(): void {
|
|
$this->assertSame('', thold_build_cdef(1, '', 4, 5));
|
|
$this->assertSame(
|
|
['sample_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => '', 'lasttime' => 1700000300, 'oldvalue' => 700], 'status_row' => null],
|
|
thold_polling_sample_row($this->threshold(), ['traffic_in' => 700], '', 1700000300)
|
|
);
|
|
|
|
$nested = $this->threshold([
|
|
'lasttime' => 1000,
|
|
'rrd_heartbeat' => 600,
|
|
]);
|
|
CactiStubs::willReturn('db_fetch_row_prepared', $nested);
|
|
$outer = $this->threshold(['expression' => '|ds:traffic_in|', 'lastread' => 2]);
|
|
$reindexed = [4 => ['traffic_in' => 700]];
|
|
$time_reindexed = [4 => 1900];
|
|
|
|
$this->assertSame('', thold_calculate_expression($outer, '', $reindexed, $time_reindexed));
|
|
$this->assertSame([], CactiStubs::$log);
|
|
|
|
CactiStubs::reset();
|
|
CactiStubs::willReturn('db_fetch_row_prepared', $nested);
|
|
$time_reindexed[4] = 1300;
|
|
$this->assertEqualsWithDelta(2, thold_calculate_expression($outer, '', $reindexed, $time_reindexed), 1.0e-9);
|
|
|
|
CactiStubs::reset();
|
|
CactiStubs::willReturn('db_fetch_row_prepared', []);
|
|
CactiStubs::willReturn('db_fetch_row_prepared', ['data_source_type_id' => self::COUNTER]);
|
|
CactiStubs::willReturn('db_fetch_row_prepared', ['rrd_step' => 300]);
|
|
CactiStubs::willReturn('rrdtool_execute', '1700000000');
|
|
CactiStubs::willReturn('rrdtool_function_fetch', [
|
|
'data_source_names' => ['traffic_in'],
|
|
'values' => [['1700000000' => 2.0]],
|
|
]);
|
|
$this->assertEqualsWithDelta(2.0, thold_calculate_expression($outer, '', $reindexed, $time_reindexed), 1.0e-9);
|
|
$this->assertSame([], CactiStubs::$log);
|
|
|
|
CactiStubs::reset();
|
|
CactiStubs::$configOptions['dsstats_enable'] = 'on';
|
|
CactiStubs::willReturn('db_fetch_row_prepared', []);
|
|
CactiStubs::willReturn('db_fetch_row_prepared', ['data_source_type_id' => self::COUNTER]);
|
|
CactiStubs::willReturn('db_fetch_cell_prepared', 3.5);
|
|
$this->assertEqualsWithDelta(3.5, thold_calculate_expression($outer, '', $reindexed, $time_reindexed), 1.0e-9);
|
|
$this->assertSame([], CactiStubs::callsTo('rrdtool_function_fetch'));
|
|
|
|
CactiStubs::reset();
|
|
CactiStubs::willReturn('db_fetch_row_prepared', []);
|
|
CactiStubs::willReturn('db_fetch_row_prepared', ['data_source_type_id' => self::GAUGE]);
|
|
$this->assertSame('700', thold_calculate_expression($outer, '', $reindexed, $time_reindexed));
|
|
$this->assertSame([], CactiStubs::callsTo('rrdtool_function_fetch'));
|
|
|
|
foreach ([
|
|
[],
|
|
['data_source_names' => ['traffic_out'], 'values' => [['1700000000' => 2.0]]],
|
|
] as $missing_fetch) {
|
|
CactiStubs::reset();
|
|
CactiStubs::willReturn('db_fetch_row_prepared', []);
|
|
CactiStubs::willReturn('db_fetch_row_prepared', ['data_source_type_id' => self::COUNTER]);
|
|
CactiStubs::willReturn('db_fetch_row_prepared', ['rrd_step' => 300]);
|
|
CactiStubs::willReturn('rrdtool_execute', '1700000000');
|
|
CactiStubs::willReturn('rrdtool_function_fetch', $missing_fetch);
|
|
$this->assertSame('', thold_calculate_expression($outer, '', $reindexed, $time_reindexed));
|
|
}
|
|
|
|
CactiStubs::reset();
|
|
CactiStubs::willReturn('db_fetch_row_prepared', []);
|
|
CactiStubs::willReturn('db_fetch_row_prepared', []);
|
|
$this->assertSame('', thold_calculate_expression($outer, '', $reindexed, $time_reindexed));
|
|
|
|
CactiStubs::reset();
|
|
CactiStubs::willReturn('db_fetch_row_prepared', []);
|
|
$reindexed = [];
|
|
$this->assertSame('', thold_calculate_expression($outer, '', $reindexed, $time_reindexed));
|
|
$this->assertStringContainsString('expression source traffic_in is unavailable', CactiStubs::$log[0]);
|
|
$log_call = CactiStubs::callsTo('cacti_log')[0];
|
|
$this->assertSame('THOLD', $log_call['params'][2]);
|
|
$this->assertSame(POLLER_VERBOSITY_MEDIUM, $log_call['params'][3]);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testMissedSampleCarriesTheValueAndTimestampTogether(): void {
|
|
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100]);
|
|
|
|
$missed = thold_sample_persistence($thold, [], 1300);
|
|
$this->assertSame(['lasttime' => 1000, 'oldvalue' => 100], $missed);
|
|
$this->assertSame($missed, thold_sample_persistence($thold, ['traffic_in' => 'U'], 1300));
|
|
$this->assertSame($missed, thold_sample_persistence($thold, ['traffic_in' => 'nan'], 1300));
|
|
$this->assertSame($missed, thold_sample_persistence($thold, ['traffic_in' => ''], 1300));
|
|
$this->assertSame(
|
|
['lasttime' => 1600, 'oldvalue' => 700],
|
|
thold_sample_persistence($thold, ['traffic_in' => 700], 1600)
|
|
);
|
|
$this->assertSame(
|
|
['lasttime' => 1600, 'oldvalue' => '700'],
|
|
thold_sample_persistence($thold, ['traffic_in' => '700'], 1600)
|
|
);
|
|
|
|
$thold['lasttime'] = $missed['lasttime'];
|
|
$thold['oldvalue'] = $missed['oldvalue'];
|
|
$reindexed = [4 => ['traffic_in' => 700]];
|
|
$time_reindexed = [4 => 1600];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
$this->assertEqualsWithDelta(
|
|
1,
|
|
thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime),
|
|
1.0e-9
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A numeric raw sample that arrives after a gap beyond the effective
|
|
* heartbeat still re-anchors the pair to the current sample and time:
|
|
* thold_get_currentval() already rejected this cycle's rate using the
|
|
* pair as it stood before this call, so freezing lasttime/oldvalue here
|
|
* would buy no extra protection for the current read while permanently
|
|
* disqualifying every later poll, whose elapsed time is measured against
|
|
* that same frozen lasttime and only grows. A gauge has no rate baseline
|
|
* to protect, and the first-ever sample and a backward-clock re-anchor
|
|
* always advance the pair regardless of the gap.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function testStaleGapReanchorsARateBearingPairAndAllOtherCasesStillAdvance(): void {
|
|
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 300]);
|
|
|
|
// 1000 -> 1601: a 601s gap exceeds the 600s effective heartbeat (2 * 300s),
|
|
// but the pair still re-anchors so the next poll can recover.
|
|
$this->assertSame(
|
|
['lasttime' => 1601, 'oldvalue' => 700],
|
|
thold_sample_persistence($thold, ['traffic_in' => 700], 1601)
|
|
);
|
|
|
|
// Right at the heartbeat boundary, the pair still advances.
|
|
$this->assertSame(
|
|
['lasttime' => 1600, 'oldvalue' => 700],
|
|
thold_sample_persistence($thold, ['traffic_in' => 700], 1600)
|
|
);
|
|
|
|
// A gauge has no baseline to protect, so the same gap still advances it.
|
|
$gauge = $this->threshold(['data_source_type_id' => self::GAUGE, 'lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 300]);
|
|
$this->assertSame(
|
|
['lasttime' => 1601, 'oldvalue' => 700],
|
|
thold_sample_persistence($gauge, ['traffic_in' => 700], 1601)
|
|
);
|
|
|
|
// No prior sample at all: the gap check does not apply.
|
|
$firstSample = $this->threshold(['lasttime' => 0, 'oldvalue' => null, 'rrd_step' => 300]);
|
|
$this->assertSame(
|
|
['lasttime' => 1601, 'oldvalue' => 700],
|
|
thold_sample_persistence($firstSample, ['traffic_in' => 700], 1601)
|
|
);
|
|
|
|
// A backward clock always re-anchors, even across a heartbeat-exceeding gap.
|
|
$backward = $this->threshold(['lasttime' => 5000, 'oldvalue' => 100, 'rrd_step' => 300]);
|
|
$this->assertSame(
|
|
['lasttime' => 1601, 'oldvalue' => 700],
|
|
thold_sample_persistence($backward, ['traffic_in' => 700], 1601)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* issue #815: a stale gap must not lock a rate-bearing threshold out of
|
|
* ever recovering. The first (gap) poll re-anchors the pair; the second
|
|
* poll, arriving at a normal interval from that new anchor, must produce
|
|
* a real numeric rate rather than being rejected as stale again.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function testASecondPollAtANormalIntervalRecoversAfterAStaleGapReanchors(): void {
|
|
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 300]);
|
|
|
|
// First (gap) poll: re-anchors instead of freezing.
|
|
$afterGap = thold_sample_persistence($thold, ['traffic_in' => 700], 1601);
|
|
$this->assertSame(['lasttime' => 1601, 'oldvalue' => 700], $afterGap);
|
|
|
|
// Second poll, a normal 300s interval after the re-anchored pair.
|
|
$recovered = $this->threshold(['lasttime' => $afterGap['lasttime'], 'oldvalue' => $afterGap['oldvalue'], 'rrd_step' => 300]);
|
|
|
|
$reindexed = [4 => ['traffic_in' => 900]];
|
|
$timeReindexed = [4 => 1901];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
// A COUNTER's delta is a per-second rate: (900 - 700) / (1901 - 1601).
|
|
$this->assertEqualsWithDelta(
|
|
200 / 300,
|
|
thold_get_currentval($recovered, $reindexed, $timeReindexed, $item, $currenttime),
|
|
1.0e-9
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The re-anchor warning fires only on the poll that first crosses the
|
|
* heartbeat, while the prior cycle's persisted rate (lastread) is still
|
|
* numeric. A source that stays stale poll after poll has already had its
|
|
* rate replaced with an unavailable state, so it must not keep logging,
|
|
* or a persistently flaky device floods medium-verbosity logs.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function testStaleGapReanchorLogsOnlyOnceOnTheTransitionIntoUnavailable(): void {
|
|
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 300, 'lastread' => 50]);
|
|
|
|
thold_sample_persistence($thold, ['traffic_in' => 700], 1601);
|
|
|
|
$this->assertCount(1, CactiStubs::$log);
|
|
$this->assertStringContainsString('sample gap exceeded', CactiStubs::$log[0]);
|
|
|
|
CactiStubs::reset();
|
|
|
|
// Same kind of gap, but the previous cycle already persisted the
|
|
// rate as unavailable: no further warning.
|
|
$stillStale = $this->threshold(['lasttime' => 1601, 'oldvalue' => 700, 'rrd_step' => 300, 'lastread' => '']);
|
|
|
|
thold_sample_persistence($stillStale, ['traffic_in' => 900], 2500);
|
|
|
|
$this->assertSame([], CactiStubs::$log);
|
|
}
|
|
|
|
/**
|
|
* The effective heartbeat thold_sample_interval_eligible() compares
|
|
* against honors an explicitly configured rrd_heartbeat, and falls back
|
|
* to the poller interval alone when rrd_step is not a usable positive
|
|
* number - mirroring thold_get_currentval()'s own fallbacks. The pair
|
|
* always advances; the heartbeat comparison now only affects whether a
|
|
* re-anchor is logged.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function testEligibilityHonorsAConfiguredHeartbeatAndFallsBackForAnInvalidRrdStep(): void {
|
|
$withHeartbeat = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 300, 'rrd_heartbeat' => 1800]);
|
|
|
|
$this->assertSame(
|
|
['lasttime' => 2800, 'oldvalue' => 700],
|
|
thold_sample_persistence($withHeartbeat, ['traffic_in' => 700], 2800)
|
|
);
|
|
$this->assertSame(
|
|
['lasttime' => 2801, 'oldvalue' => 700],
|
|
thold_sample_persistence($withHeartbeat, ['traffic_in' => 700], 2801)
|
|
);
|
|
|
|
$invalidStep = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100, 'rrd_step' => 0]);
|
|
|
|
$this->assertSame(
|
|
['lasttime' => 1600, 'oldvalue' => 700],
|
|
thold_sample_persistence($invalidStep, ['traffic_in' => 700], 1600)
|
|
);
|
|
$this->assertSame(
|
|
['lasttime' => 1601, 'oldvalue' => 700],
|
|
thold_sample_persistence($invalidStep, ['traffic_in' => 700], 1601)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array{0: int|string|null, 1: int, 2: float}>
|
|
*/
|
|
public static function emptyMaximumProvider() {
|
|
return [
|
|
'integer zero' => [0, 1009000000100, 1009000000100 / 600],
|
|
'empty string' => ['', 1009000000100, 1009000000100 / 600],
|
|
'null maximum' => [null, 1009000000100, 1009000000100 / 600],
|
|
'unknown maximum' => ['U', 1009000000100, 1009000000100 / 600],
|
|
'unresolved if speed' => ['|query_ifSpeed|', 1009000000100, 1009000000100 / 600],
|
|
'explicit maximum' => [20000000, 1015000000000, 1015000000000 / 600],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @dataProvider emptyMaximumProvider
|
|
*
|
|
* @param int|string|null $maximum
|
|
* @param int $reading
|
|
* @param float $expected
|
|
*
|
|
* @return void
|
|
*/
|
|
public function testMultiIntervalDeltaScalesTheResetGuard($maximum, $reading, $expected): void {
|
|
if ($maximum === '|query_ifSpeed|') {
|
|
CactiStubs::willReturn('db_fetch_row_prepared', ['host_id' => 1, 'snmp_query_id' => 2, 'snmp_index' => 'eth0']);
|
|
CactiStubs::willReturn('db_fetch_cell_prepared', '');
|
|
}
|
|
|
|
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 1000000000000, 'rrd_maximum' => $maximum]);
|
|
$reindexed = [4 => ['traffic_in' => $reading]];
|
|
$time_reindexed = [4 => 1600];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
$this->assertEqualsWithDelta($expected, thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testMultiIntervalWrapUsesTheWholeElapsedTime(): void {
|
|
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 4294967290, 'rrd_maximum' => 0]);
|
|
$reindexed = [4 => ['traffic_in' => 5]];
|
|
$time_reindexed = [4 => 1600];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
$this->assertEqualsWithDelta(
|
|
11 / 600,
|
|
thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime),
|
|
1.0e-9
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testWrapResetGuardUsesTheEffectiveSampleInterval(): void {
|
|
CactiStubs::$configOptions['poller_interval'] = 300;
|
|
$thold = $this->threshold([
|
|
'lasttime' => 1700000000,
|
|
'oldvalue' => 1000,
|
|
'rrd_step' => 60,
|
|
'rrd_maximum' => 0,
|
|
]);
|
|
|
|
$this->assertEqualsWithDelta(999 / 300, $this->currentValue($thold, 999), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testStaleCounterAndDeriveSamplesAreDiscarded(): void {
|
|
foreach ([self::COUNTER, self::DERIVE] as $type) {
|
|
$thold = $this->threshold(['data_source_type_id' => $type, 'lasttime' => 1000, 'oldvalue' => 100]);
|
|
$reindexed = [4 => ['traffic_in' => 700]];
|
|
$time_reindexed = [4 => 1000 + 7 * 86400];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
$this->assertSame('', thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array{0: mixed}>
|
|
*/
|
|
public static function invalidRrdStepProvider() {
|
|
return [
|
|
'zero' => [0],
|
|
'null' => [null],
|
|
'non-numeric' => ['invalid'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @dataProvider invalidRrdStepProvider
|
|
*
|
|
* @param mixed $rrd_step
|
|
*
|
|
* @return void
|
|
*/
|
|
public function testInvalidRrdStepFallsBackToThePollerInterval($rrd_step): void {
|
|
CactiStubs::$configOptions['poller_interval'] = 300;
|
|
$thold = $this->threshold([
|
|
'data_source_type_id' => self::ABSOLUTE,
|
|
'lasttime' => 0,
|
|
'rrd_step' => $rrd_step,
|
|
]);
|
|
|
|
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 600), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testEvaluationCadenceMayExceedTheRrdStep(): void {
|
|
CactiStubs::$configOptions['poller_interval'] = 300;
|
|
$thold = $this->threshold(['rrd_step' => 60]);
|
|
|
|
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 700), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testRrdHeartbeatControlsGapAcceptanceWithASafeFloor(): void {
|
|
$accepted = $this->threshold(['lasttime' => 1000, 'rrd_heartbeat' => 1800]);
|
|
$reindexed = [4 => ['traffic_in' => 700]];
|
|
$time_reindexed = [4 => 1900];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
$this->assertEqualsWithDelta(
|
|
600 / 900,
|
|
thold_get_currentval($accepted, $reindexed, $time_reindexed, $item, $currenttime),
|
|
1.0e-9
|
|
);
|
|
|
|
$floored = $accepted;
|
|
$floored['rrd_heartbeat'] = 120;
|
|
$time_reindexed[4] = 1300;
|
|
$this->assertEqualsWithDelta(
|
|
2,
|
|
thold_get_currentval($floored, $reindexed, $time_reindexed, $item, $currenttime),
|
|
1.0e-9
|
|
);
|
|
|
|
$time_reindexed[4] = 1700;
|
|
$this->assertSame('', thold_get_currentval($floored, $reindexed, $time_reindexed, $item, $currenttime));
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testNonNumericSampleTimeUsesTheRrdStep(): void {
|
|
$thold = $this->threshold();
|
|
$reindexed = [4 => ['traffic_in' => 700]];
|
|
$time_reindexed = [4 => 'U'];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
$this->assertEqualsWithDelta(2, thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testFirstAbsoluteSampleUsesTheValidatedPollerInterval(): void {
|
|
CactiStubs::$configOptions['poller_interval'] = 300;
|
|
$thold = $this->threshold([
|
|
'data_source_type_id' => self::ABSOLUTE,
|
|
'lasttime' => 0,
|
|
'rrd_step' => 0,
|
|
]);
|
|
|
|
$this->assertEqualsWithDelta(2, $this->currentValue($thold, 600), 1.0e-9);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testOutOfOrderCounterAndDeriveSamplesAreUnknown(): void {
|
|
foreach ([self::COUNTER, self::DERIVE] as $type) {
|
|
$thold = $this->threshold(['data_source_type_id' => $type, 'lasttime' => 1700000400]);
|
|
$reindexed = [4 => ['traffic_in' => 700]];
|
|
$time_reindexed = [4 => 1700000300];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
|
|
$this->assertSame('', thold_get_currentval($thold, $reindexed, $time_reindexed, $item, $currenttime));
|
|
}
|
|
|
|
$this->assertSame(
|
|
['lasttime' => 1700000300, 'oldvalue' => 700],
|
|
thold_sample_persistence($thold, ['traffic_in' => 700], 1700000300)
|
|
);
|
|
$this->assertSame(
|
|
['lasttime' => 1700000400, 'oldvalue' => 100],
|
|
thold_sample_persistence($thold, ['traffic_in' => 700], 1700000400)
|
|
);
|
|
|
|
CactiStubs::reset();
|
|
$this->assertSame([
|
|
'sample_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => '', 'lasttime' => 1700000300, 'oldvalue' => 700],
|
|
'status_row' => null,
|
|
], thold_polling_sample_row($thold, ['traffic_in' => 700], '', 1700000300));
|
|
$this->assertCount(1, CactiStubs::$log);
|
|
$this->assertStringContainsString('clock moved backwards', CactiStubs::$log[0]);
|
|
|
|
CactiStubs::reset();
|
|
$this->assertTrue(thold_daemon_persist_sample($thold, ['traffic_in' => 700], '', 1700000300));
|
|
$this->assertCount(1, CactiStubs::$log);
|
|
$this->assertStringContainsString('clock moved backwards', CactiStubs::$log[0]);
|
|
$call = end(CactiStubs::$calls);
|
|
$this->assertSame([1, '', 1700000300, 700, 9], $call['params']);
|
|
|
|
$reanchored = $this->threshold(['lasttime' => 1700000300, 'oldvalue' => 700]);
|
|
$reindexed = [4 => ['traffic_in' => 1000]];
|
|
$time_reindexed = [4 => 1700000600];
|
|
$item = [];
|
|
$currenttime = 0;
|
|
$this->assertEqualsWithDelta(
|
|
1,
|
|
thold_get_currentval($reanchored, $reindexed, $time_reindexed, $item, $currenttime),
|
|
1.0e-9
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testDeriveWithAnInvalidPriorValueIsUnknown(): void {
|
|
$thold = $this->threshold([
|
|
'data_source_type_id' => self::DERIVE,
|
|
'oldvalue' => 'U',
|
|
]);
|
|
|
|
$this->assertSame('', $this->currentValue($thold, 700));
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testDaemonPersistsThePairWithBoundParameters(): void {
|
|
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100]);
|
|
|
|
$this->assertTrue(thold_daemon_persist_sample($thold, [], '', 1300));
|
|
$call = end(CactiStubs::$calls);
|
|
$this->assertStringContainsString('lasttime = FROM_UNIXTIME(?)', $call['sql']);
|
|
$this->assertSame([1, '', 1000, 100, 9], $call['params']);
|
|
|
|
CactiStubs::reset();
|
|
$this->assertTrue(thold_daemon_persist_sample($thold, ['traffic_in' => 700], 2, 1600));
|
|
$call = end(CactiStubs::$calls);
|
|
$this->assertSame([1, 2, 1600, 700, 9], $call['params']);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testDaemonPropagatesPersistenceFailure(): void {
|
|
CactiStubs::willReturn('db_execute_prepared', false);
|
|
$this->assertFalse(thold_daemon_persist_sample(
|
|
$this->threshold(['lasttime' => 1000]),
|
|
[],
|
|
'',
|
|
1300
|
|
));
|
|
|
|
CactiStubs::reset();
|
|
CactiStubs::willReturn('db_execute_prepared', false);
|
|
$this->assertFalse(thold_daemon_persist_sample(
|
|
$this->threshold(['lasttime' => 0]),
|
|
[],
|
|
'',
|
|
1300
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testNeverSampledThresholdLeavesTheTimestampPairUntouched(): void {
|
|
$thold = $this->threshold(['lasttime' => 0, 'oldvalue' => null]);
|
|
|
|
$this->assertSame(
|
|
['lasttime' => 0, 'oldvalue' => null],
|
|
thold_sample_persistence($thold, ['traffic_in' => 'U'], 1300)
|
|
);
|
|
$this->assertTrue(thold_daemon_persist_sample($thold, ['traffic_in' => 'U'], '', 1300));
|
|
$call = end(CactiStubs::$calls);
|
|
$this->assertStringNotContainsString('FROM_UNIXTIME', $call['sql']);
|
|
$this->assertStringNotContainsString('oldvalue', $call['sql']);
|
|
$this->assertSame([1, '', 9], $call['params']);
|
|
|
|
CactiStubs::reset();
|
|
$this->assertSame(
|
|
['sample_row' => null, 'status_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => '']],
|
|
thold_polling_sample_row($thold, ['traffic_in' => 'U'], '', 1300)
|
|
);
|
|
$this->assertSame([], CactiStubs::$calls);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testPollerBuildsTheSamePersistedPair(): void {
|
|
$thold = $this->threshold(['lasttime' => 1000, 'oldvalue' => 100]);
|
|
|
|
$this->assertSame([
|
|
'sample_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => 2, 'lasttime' => 1000, 'oldvalue' => 100],
|
|
'status_row' => null,
|
|
], thold_polling_sample_row($thold, [], 2, 1300));
|
|
$this->assertSame([
|
|
'sample_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => 2, 'lasttime' => 1600, 'oldvalue' => 700],
|
|
'status_row' => null,
|
|
], thold_polling_sample_row($thold, ['traffic_in' => 700], 2, 1600));
|
|
$this->assertSame([
|
|
'sample_row' => ['id' => 9, 'tcheck' => 1, 'lastread' => 2, 'lasttime' => 1000, 'oldvalue' => null],
|
|
'status_row' => null,
|
|
], thold_polling_sample_row($this->threshold(['lasttime' => 1000, 'oldvalue' => null]), [], 2, 1300));
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testMissingPersistenceKeysFailClosed(): void {
|
|
$this->assertSame(
|
|
['lasttime' => 0, 'oldvalue' => null],
|
|
thold_sample_persistence([], ['traffic_in' => 700], 1600)
|
|
);
|
|
$this->assertFalse(thold_daemon_persist_sample([], ['traffic_in' => 700], 2, 1600));
|
|
$this->assertSame(
|
|
['sample_row' => null, 'status_row' => null],
|
|
thold_polling_sample_row([], ['traffic_in' => 700], 2, 1600)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testUnavailableSampleLogsOnlyOnTheStateTransition(): void {
|
|
$thold = $this->threshold([
|
|
'lastread' => 12,
|
|
'name_cache' => 'Traffic in',
|
|
]);
|
|
|
|
thold_polling_sample_row($thold, [], '', 1700000300);
|
|
$this->assertCount(1, CactiStubs::$log);
|
|
$log_call = CactiStubs::callsTo('cacti_log')[0];
|
|
$this->assertSame('THOLD', $log_call['params'][2]);
|
|
$this->assertSame(POLLER_VERBOSITY_MEDIUM, $log_call['params'][3]);
|
|
|
|
$thold['lastread'] = '';
|
|
thold_polling_sample_row($thold, [], '', 1700000600);
|
|
$this->assertCount(1, CactiStubs::$log);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
public function testPollerCleanupRunsForEitherBatchType(): void {
|
|
thold_polling_cleanup(false);
|
|
$this->assertSame([], CactiStubs::$calls);
|
|
|
|
CactiStubs::willReturn('db_affected_rows', 1);
|
|
thold_polling_cleanup(true);
|
|
$this->assertSame('db_execute_prepared', CactiStubs::$calls[0]['fn']);
|
|
$this->assertStringContainsString('local_data_id = 0', CactiStubs::$calls[0]['sql']);
|
|
$this->assertArrayHasKey('time_last_change_thold', CactiStubs::$configOptions);
|
|
}
|
|
}
|