Skip to content

fix(thold): correct counter delta, wrap modulus and percent denominator - #791

Open
somethingwithproof wants to merge 4 commits into
Cacti:developfrom
somethingwithproof:fix/counter-value-math
Open

fix(thold): correct counter delta, wrap modulus and percent denominator#791
somethingwithproof wants to merge 4 commits into
Cacti:developfrom
somethingwithproof:fix/counter-value-math

Conversation

@somethingwithproof

Copy link
Copy Markdown
Member

Fixes #785.

Four defects in how a raw sample becomes the rate a threshold is compared against. Tests were written first and fail on develop.

A previous counter reading of zero was treated as no reading

if ($thold_data['oldvalue'] != 0 && is_numeric($thold_data['oldvalue'])) {

Zero is a legitimate previous reading. After a device reboot, or any interval where a counter genuinely sits at zero, the next poll reported a rate of zero — so a thold_low on interface utilisation fired spuriously and a thold_high under-reported the first interval of traffic.

The wrap modulus was off by one, and the 64-bit case lost precision

if ($thold_data['oldvalue'] > 4294967295) {
    $currentval = (18446744073709551615 - $thold_data['oldvalue']) + $item[...];
} else {
    $currentval = (4294967295 - $thold_data['oldvalue']) + $item[...];
}

A counter that wraps has advanced by (2^32 - old) + new. Using 2^32 - 1 loses exactly one count per wrap.

Worse, 18446744073709551615 is above PHP_INT_MAX and is parsed as a float:

$v = 18446744073709551615;  =>  1.8446744073709552E+19 (double)

which drops roughly eleven bits before the subtraction. The 64-bit case now goes through GMP, which Cacti already requires via ext-gmp. Extracted to thold_counter_wrap_delta() so it can be tested directly.

The percent denominator was cast to int

$t = (int) $rrd_reindexed[$thold['local_data_id']][$thold['percent_ds']];

A denominator below one truncated to zero, forcing the percentage to zero, so a percent threshold on a ratio with a sub-unit denominator reported 0% permanently and kept any configured low threshold in breach. A negative denominator gave zero rather than a negative percentage.

The daemon wrote a timestamp into oldvalue

if (isset($item[$thold_data['name']])) {
    $lasttime = $item[$thold_data['name']];              // a value
} else {
    $lasttime = $currenttime - $thold_data['rrd_step'];  // a timestamp
}
...
SET ... oldvalue = ?   [$lasttime]

When a data source produced no sample, a Unix timestamp was stored as the previous counter reading. The next poll computed a large negative delta, took the overflow branch above, and fabricated a rate in the billions — a bogus high alert on the poll after any missed sample.

includes/polling.php already carries the previous oldvalue forward and even documents why; this makes the daemon match.

Tests

17 tests, 100% of the changed lines covered, including exact 32-bit and 64-bit wrap arithmetic.

The harness commit matches #773, #788 and #790, with gmp added to the test image so the 64-bit case is verified rather than assumed. Whichever PR lands first, the others merge cleanly.

@somethingwithproof

Copy link
Copy Markdown
Member Author

The four Integration Test failures are the upstream break, not this PR. The workflow checks out Cacti/cacti unpinned and Install Cacti via CLI dies in core:

PHP Fatal error: Uncaught Error: Call to undefined function __()
  in cacti/lib/functions.php:7973

format_cacti_version_text() calls __() before the translation layer loads. Nothing here touches Cacti core, and the PHPUnit job passes. #776 pins the checkout to release/1.2.31 and is green.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes incorrect value acquisition for thold thresholds by correcting counter delta handling (including wraps), preventing percent calculations from truncating denominators, and ensuring daemon processing persists the correct previous raw reading (oldvalue). This fits into thold’s core evaluation pipeline (thold_get_currentval() / daemon thold_process.php) and adds a PHPUnit-based harness to lock the behavior down.

Changes:

  • Correct COUNTER delta handling: treat previous reading 0 as valid; fix wrap modulus; avoid 64-bit precision loss by using GMP via thold_counter_wrap_delta().
  • Fix percent-of calculations by no longer casting the denominator to int and handling non-numeric/zero denominators safely.
  • Prevent daemon mode from writing timestamps into oldvalue, and add a PHPUnit test harness + CI workflow with changed-lines coverage enforcement.

Reviewed changes

Copilot reviewed 14 out of 16 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
thold_process.php Ensures daemon persists the correct raw previous reading into oldvalue instead of timestamps.
thold_functions.php Fixes counter delta semantics (0 is valid), wrap modulus, and percent denominator handling; adds thold_counter_wrap_delta().
tests/Unit/TholdGetCurrentvalTest.php Unit coverage for COUNTER wrap/zero handling and other data source types’ current-value semantics.
tests/Unit/TholdCalculatePercentTest.php Unit coverage for percent denominator behavior (fractional/negative/zero/non-numeric).
tests/TestCase.php Base test case providing per-test reset and plugin source loading helper.
tests/Support/CactiStub.php Recording stub for Cacti framework/global functions used by plugin code.
tests/fixtures/optional-core-functions.php Provides optional-core function shims for function-exists fallback testing.
tests/fixtures/cacti-lib/variables.php Minimal fixture file so runtime include_once() paths resolve during tests.
tests/bootstrap.php PHPUnit bootstrap stubbing Cacti globals/functions and providing global-scope loader.
tests/bin/patch-coverage.php CI helper enforcing coverage on changed lines rather than whole-file coverage.
tests/docker/Dockerfile Reproducible PHP 8.1 test runner image with GMP + pcov.
tests/docker/docker-compose.yml Local runner mirroring CI’s Docker-based unit-test workflow.
phpunit.xml Configures test suite and limits coverage scope to thold_functions.php.
composer.json Adds PHPUnit dev dependency and scripts for local/CI test execution.
.github/workflows/php-unit-tests.yml Adds CI workflow to run unit tests + lint + changed-line coverage enforcement.
.gitignore Ignores composer/vendor, coverage artifacts, and phpunit caches.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread thold_functions.php
Comment on lines +789 to +797
function thold_counter_wrap_delta($oldvalue, $newvalue) {
if ($oldvalue > 4294967295) {
$delta = gmp_add(gmp_sub(gmp_pow(2, 64), gmp_init((string) $oldvalue, 10)), gmp_init((string) $newvalue, 10));

return (float) gmp_strval($delta);
}

return (4294967296 - $oldvalue) + $newvalue;
}
Comment thread thold_process.php
Comment on lines +210 to +216
// Counters, where calculating the difference is important.
// The unset case is problematic and may lead to false triggering
// events. So, in those cases, we will store the 'oldvalue'.
if (isset($item[$thold_data['name']])) {
$lasttime = $item[$thold_data['name']];
$rawvalue = $item[$thold_data['name']];
} else {
$lasttime = $currenttime - $thold_data['rrd_step'];
$rawvalue = $thold_data['oldvalue'];
Same harness as Cacti#773 and Cacti#788, with gmp added to the image so the 64-bit
counter arithmetic can be tested exactly.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
A previous counter reading of exactly zero was treated as no reading at all,
so the first interval after a device reboot reported a rate of zero. The wrap
modulus was 2^32-1 and 2^64-1 rather than 2^32 and 2^64, losing one count per
wrap, and the 64-bit literal exceeded PHP_INT_MAX so it was parsed as a float
and lost about eleven bits before the subtraction.

The percent-of denominator was cast to int, so a denominator below one
truncated to zero and forced the result to zero, keeping any configured low
threshold in permanent breach.

Refs Cacti#785

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
When a data source produced no sample this cycle the daemon wrote
$currenttime - $rrd_step into oldvalue, so the next poll computed a delta
against a Unix timestamp, took the overflow branch and fabricated a rate in
the billions. The non-daemon path already carries the previous oldvalue
forward; this matches it.

Refs Cacti#785

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Adopts the conventions from Cacti core: tests/bootstrap-unit.php, tests/Helpers
for the stubs, a phpunit.xml carrying error_reporting -1 and
CACTI_TEST_BOOTSTRAP, and composer lint / test / test:coverage scripts so CI
runs the same commands a developer does. The dev toolchain is Cacti's, pinned
to the same platform php 8.1.0.

Cacti core runs Pest and this suite does not, because pest ^2 does not resolve
on PHP 8.1 -- the platform Cacti's own composer.json pins. Releases up to
v2.36.0 conflict with phpunit 10.5.62 and later, every earlier 10.x release is
blocked by advisory PKSA-z3gr-8qht-p93v, and v2.36.1, which does resolve,
requires PHP 8.2. The stack installs on 8.2 and above; 8.1 is the floor this
plugin's CI matrix targets. The tests are written in the plain PHPUnit class
style that Cacti's tests/Pest.php explicitly supports, so they run unchanged
under Pest wherever it is installable.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Value acquisition: counter zero pins the rate, wrap modulus off by one, percent denominator cast to int

2 participants