Repository navigation
fix: hdr_reset_internal_counters ignored normalizing_index_offset (wrong min/max on decoded shifted logs) - #155
Conversation
hdr_reset_internal_counters scanned counts[] with counts_get_direct(h, i) -- a raw storage index -- but then passed the winning max_index / min_non_zero_index to hdr_value_at_index, which decomposes its argument as a *logical* index. When normalizing_index_offset is non-zero the two differ, so min_value and max_value come out wrong. This is reachable from the log reader: hdr_decode_compressed_v1 and _v2 both assign h->normalizing_index_offset from the payload and then call this function immediately (hdr_histogram_log.c:555-557 and :671-673), so any decoded log that carries a non-zero offset -- a Java-encoded shifted log -- reports wrong min/max. total_count is unaffected, since summing every slot is rotation-invariant. Concretely, rotating the storage of a histogram without changing what it logically holds: before rotation: min=1000 max=100031 total=3 after rotation : min=43974656 max=3995074559 total=3 <- before this change after rotation : min=1000 max=100031 total=3 <- after Use counts_get_normalised so the count and the value share the logical index. This restores parity with the Java reference, which was the open question when this was first spotted: AbstractHistogram.establishInternalTackingValues() reads via getCountAtIndex(index), and in Histogram.java that is counts[normalizeIndex(index, normalizingIndexOffset, countsArrayLength)] -- the normalising accessor -- while the raw one is getCountAtNormalizedIndex(index) = counts[index]. Java pairs the normalising accessor with valueFromIndex(maxIndex), which is exactly what this change does. C was the outlier. It is also the only counts_get_direct call in hdr_histogram.c outside counts_get_normalised itself; every other accessor (hdr_count_at_index, hdr_count_at_value) already normalises. This is a cold path -- decode and manual import only, not record or percentile -- and normalize_index early-returns when the offset is zero, so the unrotated case is unchanged. The regression test rotates a histogram's storage and asserts the reported min/max/total are unchanged. It closes the histogram before asserting, because a failing assert would otherwise leak it and let LeakSanitizer _exit() before the assertion message is flushed. Verified: ctest 5/5 under gcc ASan+UBSan+LSan and clang ASan+UBSan+float-cast-overflow (-fno-sanitize-recover=all), 5/5 gcc and clang RelWithDebInfo, 4/4 with HDR_LOG_REQUIRED=DISABLED, and no new compiler warnings. The test fails on unpatched code with "min_value wrong after rotation". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Automated first-pass review — a human maintainer's review is still required before merge. Reads correct to me. One thing the description doesn't mention but which the same change fixes: the On Java parity, the quoted Reachability looks as stated: there's no value-shifting API in the C port, so the log decoder is the only thing that sets a non-zero offset, and the v0 path ( Two minor notes, neither blocking:
On the fixture question: the |
|
Review round: 2026-09-29. Reviewed commit: Verdict: MERGE-READY for this offset/min-max fix. Fresh arm64 validation passes: release CTest 5/5, ASan+UBSan+float-cast-overflow CTest 5/5, and logging-disabled CTest 4/4. An additional 50,000 deterministic V1/V2 decode cases cover positive/negative/wrapping offsets and check counts, percentiles, min and max under sanitizers. The reviewed head's Linux/Windows/macOS CI and PR fuzz checks are green. The normalizing count accessor now pairs with the logical index used to reconstruct extrema. Atomic recording, public layout and the recording/percentile hot paths are unchanged. No new performance claim is made. Scope: issue #118's separate dense total-count overflow remains open; this verdict does not certify the entire release or close that issue. Recheck after conflict resolution if the reviewed code changes. |
|
Review round: 2026-09-29 (adversarial re-verification, no code change). Reviewed commit: Ran a 7-lens adversarial pass on the current head. One lens raised a plausible OOB Validated as a false positive. Both compressed-decode paths reduce the decoded offset into
That guard is pre-existing in Verdict: MERGE-READY confirmed. A1/A2/A3/A4/A5/A6 all clean; the offset/min-max recompute is correct on decoded shifted logs. No code change. |
paulorsousa
left a comment
There was a problem hiding this comment.
LGTM!
Also confirmed the bug and the fix locally
C code
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <inttypes.h>
#include <hdr/hdr_histogram.h>
static void show(const char *label, struct hdr_histogram *h) {
printf("%-26s total=%lld min=%lld us max=%lld us counts(1000)=%lld counts(100000)=%lld\n",
label, (long long)h->total_count, (long long)hdr_min(h), (long long)hdr_max(h),
(long long)hdr_count_at_value(h, 1000), (long long)hdr_count_at_value(h, 100000));
}
int main(void) {
struct hdr_histogram *h = NULL;
if (hdr_init(1, 3600000000LL, 3, &h)) {
return 2;
}
hdr_record_value(h, 1000);
hdr_record_value(h, 1000);
hdr_record_value(h, 100000);
int64_t expected_min = hdr_min(h), expected_max = hdr_max(h);
show("Original samples", h);
int32_t offset = h->counts_len / 3;
int64_t *rotated = calloc((size_t)h->counts_len, sizeof(*rotated));
if (!rotated) {
hdr_close(h);
return 2;
}
for (int32_t i = 0; i < h->counts_len; ++i) {
int32_t slot = i - offset;
if (slot < 0) {
slot += h->counts_len;
}
rotated[slot] = h->counts[i];
}
memcpy(h->counts, rotated, (size_t)h->counts_len * sizeof(*rotated));
free(rotated);
h->normalizing_index_offset = offset;
printf("Storage offset: %d slots (array size: %d)\n", offset, h->counts_len);
show("After storage rotation", h);
/* V1/V2 decoders call this after loading counts and their offset. */
hdr_reset_internal_counters(h);
show("After rebuilding counters", h);
int correct = hdr_min(h) == expected_min && hdr_max(h) == expected_max && h->total_count == 3;
printf("Result: %s\n", correct ? "PASS: same samples, same min/max" : "BUG: unchanged samples, corrupted min/max");
hdr_close(h);
return correct ? 0 : 1;
}…istogram#149/HdrHistogram#155/HdrHistogram#157) # Conflicts: # test/hdr_histogram_test.c
…istogram#149/HdrHistogram#155/HdrHistogram#157) # Conflicts: # test/hdr_histogram_test.c
…istogram#149/HdrHistogram#155/HdrHistogram#157) # Conflicts: # test/hdr_histogram_test.c
Problem
hdr_reset_internal_countersscanscounts[]withcounts_get_direct(h, i)— a rawstorage index — but then passes the winning
max_index/min_non_zero_indextohdr_value_at_index, which decomposes its argument as a logical index:When
normalizing_index_offsetis non-zero the two differ, somin_valueandmax_valuecome out wrong.
This is reachable from the log reader, not just by hand:
hdr_decode_compressed_v1and_v2both assignh->normalizing_index_offsetfrom the payload and call this function onthe very next line (
hdr_histogram_log.c:555-557and:671-673). So any decoded logcarrying a non-zero offset — a Java-encoded shifted log — reports wrong min/max.
total_countis unaffected, because summing every slot is rotation-invariant.Rotating a histogram's storage without changing what it logically holds:
mainChange
Use
counts_get_normalisedso the count and the value share the logical index. One line.Parity with the Java reference
This was the open question when the bug was first spotted, so to settle it rather than
assert it —
AbstractHistogram.establishInternalTackingValues()reads viagetCountAtIndex(index), and inHistogram.javathat is:So Java's is the normalising accessor, and it pairs it with
valueFromIndex(maxIndex)—exactly what this change does. The raw accessor is the other one. C was the outlier here,
and this restores parity.
It is also the only
counts_get_directcall inhdr_histogram.coutsidecounts_get_normaliseditself;hdr_count_at_indexandhdr_count_at_valuealreadynormalise.
Cost
None on the hot paths — this runs on decode and manual import only, never on
hdr_record_valueorhdr_value_at_percentile.normalize_indexearly-returns when theoffset is zero, so the unrotated case is one predictable branch per slot on a cold path.
Test
test_reset_internal_counters_honours_offsetrotates a histogram's storage and asserts thereported min/max/total are unchanged. On unpatched code it fails with
min_value wrong after rotation.It closes the histogram before asserting: a failing assert would otherwise leak it, and
LeakSanitizer
_exit()s before stdio flushes, so the assertion message is swallowed and allyou see is a leak trace. Worth knowing for any test added to this suite now that the
sanitizersjob enables LSan.Verification
ctestgcc ASan+UBSan+LSan,-fno-sanitize-recover=allctestclang ASan+UBSan+float-cast-overflowctestRelWithDebInfo, gcc and clangctest-DHDR_LOG_REQUIRED=DISABLEDhdr_histogram.c,-Wall -WextramainSteps to reproduce
Note
A dedicated
.hlogfixture with a non-zeronormalizing_index_offsetwould exercise thisend-to-end through
hdr_log_read, but producing one needs the Java encoder (the C encoderalways writes offset 0). The unit test covers the same state the decoder hands to this
function. Happy to add a fixture if you can point me at a shifted log.
🤖 Generated with Claude Code