fix(thold): correct counter delta, wrap modulus and percent denominator - #791
fix(thold): correct counter delta, wrap modulus and percent denominator#791somethingwithproof wants to merge 4 commits into
Conversation
|
The four Integration Test failures are the upstream break, not this PR. The workflow checks out
|
There was a problem hiding this comment.
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
0as valid; fix wrap modulus; avoid 64-bit precision loss by using GMP viathold_counter_wrap_delta(). - Fix percent-of calculations by no longer casting the denominator to
intand 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.
| 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; | ||
| } |
| // 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']; |
b7841db to
a4b6ca5
Compare
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>
a4b6ca5 to
286f4b4
Compare
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
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_lowon interface utilisation fired spuriously and athold_highunder-reported the first interval of traffic.The wrap modulus was off by one, and the 64-bit case lost precision
A counter that wraps has advanced by
(2^32 - old) + new. Using2^32 - 1loses exactly one count per wrap.Worse,
18446744073709551615is abovePHP_INT_MAXand is parsed as a float:which drops roughly eleven bits before the subtraction. The 64-bit case now goes through GMP, which Cacti already requires via
ext-gmp. Extracted tothold_counter_wrap_delta()so it can be tested directly.The percent denominator was cast to int
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
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.phpalready carries the previousoldvalueforward 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
gmpadded to the test image so the 64-bit case is verified rather than assumed. Whichever PR lands first, the others merge cleanly.