Skip to content

Fix signed integer overflow in refreshable materialized view RANDOMIZE FOR - #113673

Open
groeneai wants to merge 2 commits into
ClickHouse:masterfrom
groeneai:groeneai/fix-refresh-randomize-for-overflow
Open

Fix signed integer overflow in refreshable materialized view RANDOMIZE FOR#113673
groeneai wants to merge 2 commits into
ClickHouse:masterfrom
groeneai:groeneai/fix-refresh-randomize-for-overflow

Conversation

@groeneai

@groeneai groeneai commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Fix undefined behavior when a refreshable materialized view uses a large RANDOMIZE FOR window. RefreshSchedule::addRandomSpread computed the random offset in double and then narrowed it to Int64 milliseconds, widened those to microseconds and added them to a time_point, none of which was range-checked, so a sufficiently wide window aborted the server on a sanitizer build and produced an out-of-range next-refresh time otherwise. The offset is now computed in Int128 and saturated inside the representable range. This also removes a rounding error of up to a few milliseconds in the jitter for windows wider than about 100 days, where the double computation no longer returned the exact value.

Description

RANDOMIZE FOR accepts an unbounded UInt64 and the random factor is a signed Int64, so
addRandomSpread had three unchecked domains in one expression: the double to Int64 conversion,
the milliseconds to microseconds widening (system_clock::duration is microseconds, so a
representable millisecond value is not automatically safe), and the time_point addition. Each is
reachable with ordinary SQL (the feature is GA) and aborts a sanitizer build, which uses
-fno-sanitize-recover=all.

The fix computes the offset in Int128, the narrowest width holding the widest possible product of
the two operands (UInt64_MAX * Int64_MIN fits with 2^63 to spare; the final sum with a factor of
2000), then saturates strictly inside the time_point range. Strictly inside, because
time_point::max() is the "no refresh scheduled" sentinel and producing it would make a view wait
indefinitely. Dividing before scaling back up is what keeps the product inside Int128.

This also covers the coordination znode, which deserializes the random factor with no range check, so
any Int64 it holds reaches this function, not just the range the scheduler draws.

Validation, on an ASan+UBSan build with the CI job's flags: three arms, one per domain, plus a YEAR
and a MONTH arm reaching the offset through a calendar-unit window, and an ordinary control.
Each arm aborts on unpatched master, also alone, and passes after the change. Six mutants of the fix
each redden a test. The new test passes 20 consecutive runs; three siblings pass 50 runs each, the
long-tagged fourth 5.

Two adjacent overflows in the same subsystem are out of scope and tracked separately:
CalendarTimeInterval's arithmetic, and the delay this schedule hands to BackgroundSchedulePool.
The retry-backoff half of this family is
#113007. No related open issue found.

RANDOMIZE FOR accepts an unbounded UInt64 and the random factor is a signed
Int64, but RefreshSchedule::addRandomSpread computed the random offset in
double and then pushed it through three unchecked domains in one expression:
the double to Int64 conversion, the milliseconds to microseconds widening,
and the time_point addition. system_clock::duration is microseconds, so a
representable milliseconds value is not automatically safe, and each of the
three has its own threshold and its own frame.

Compute the offset in Int128, the narrowest width that holds the widest
possible product of the two unbounded operands (UInt64_MAX * Int64_MIN fits
with 2^63 to spare; after the division the final sum has a factor of 2000),
then saturate the result strictly inside the time_point range. Strictly
inside matters because time_point::max() is the "no refresh scheduled"
sentinel; producing it would make a view read as waiting indefinitely.
Operand order is load-bearing: dividing before scaling back up is what keeps
the product inside Int128, whereas the naive order overflows it.

The same change covers the coordination znode, which deserializes the random
factor with no range check, so any Int64 a znode holds reaches this function
rather than only the range the scheduler draws from.

This is not exactly behaviour-preserving. The intermediate product leaves the
exact range of double above a spread of about five hours, and above about 100
days that rounding changes the result the old path returned; the Int128 form
returns the exact value instead, a difference of a few milliseconds (measured
bound 4 ms) in a deliberately randomized jitter. The changelog entry says so
rather than describing the change as a no-op.

Two adjacent overflows in the same subsystem are deliberately left alone and
tracked separately: CalendarTimeInterval's own arithmetic, whose months field
wraps, and the delay this schedule hands to BackgroundSchedulePool.

The stateless test has one arm per domain plus a control with an ordinary
spread, and a sign-independent oracle, since the random factor is drawn over
both signs and a wide window legitimately yields a past-due instant. Only a
positive draw overflows in the third domain, so that arm is probabilistic and
uses enough views to make a miss negligible; the deterministic pin for that
domain is in the gtest, which drives the addition to both ends of the range
with a fixed randomness. The gtest also covers what the stateless test cannot
see: two of the guards fail inside the wide-integer arithmetic, where the
sanitizer emits no check and the result is silently wrong rather than
aborting.
@groeneai

groeneai commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author
Internal second-model review (click to expand)

Two review rounds, each with an independent second-model pass over the whole change. Six findings
across both rounds, three of which changed the PR. No finding was about the fix itself: the
RefreshSchedule.cpp diff is byte-identical to what round one produced.

⚠️ Corrected before publishing

Round one found three countable claims in the PR text that were wrong, each on a sentence carrying
the safety or the honesty argument:

  • Int128 was described as holding the widest product with a factor of 2000 to spare. The true
    relative margin on the product is 5.4e-20 - UInt64_MAX * Int64_MIN sits exactly 2^63 above
    Int128_MIN, so Int128 is the exact minimum width for it. The factor of 2000 belongs to the
    final sum, after the division; That sentence is the justification for the chosen width, so it now states both margins separately.
  • The rounding onset was given as 208 days. Two different thresholds had been merged into one
    number: the intermediate product leaves the exact range of double at about five hours, while
    about 100 days is where that rounding changes the truncated result. The 208-day figure was the
    five-hour threshold multiplied by 1e3, and the text's own adjacent counterexample at 115 days
    contradicted it.
  • The behavioural delta was given as up to one millisecond. Measured bound is 4 ms, with a
    fully-defined, non-saturating 3 ms case (spread 73485847263779, factor 250189397: the old path
    returns 9192689907479486 ms where the exact value is ...483). The widening only requires
    |ms| <= 9.2234e15, where one double ulp is already 2 ms. Since that sentence exists to disclose
    a non-behaviour-preserving change honestly, understating it was the defect.

Round two found two more, both created by round one's own fix: the validation paragraph still claimed
50 runs of the new test after its third arm had been widened from 10 views to 30 (the 50-run
measurement predates that edit; the shipped size has 20 runs, 86.6-96.5 s each), and the changelog
entry still carried the merged 2^53 threshold that had been corrected everywhere else.

❌ Not accepted - the second model's blocker rested on a false premise

It reported that system_clock::duration is nanoseconds on this toolchain, which would make every
jitter 1000x too small and also invalidate the third test arm and the saturated values. Refuted four
ways: contrib/llvm-project/libcxx/include/__chrono/system_clock.h:28 is typedef microseconds duration; the compile line for this translation unit carries -nostdinc++ and includes that
libcxx, so libstdc++ is not visible to it; a probe built with the build's own compiler and includes
reports period = 1/1000000 (the same probe against the default toolchain reports 1/1000000000,
which is where the figure came from); and the measured pre-fix sanitizer report reads
9200000000000000000 + 292248531000000000, which is 9.2e12 seconds times exactly 1e6, emitted
from that libcxx's own duration.h. The existing randomize for 4 day 1 hour assertion in
02932_refreshable_materialized_views_2 also checks the jitter in real seconds and passes, which a
nanosecond representation would break.

💡 Noted, not changed

The saturated instant is outside what system.view_refreshes.next_refresh_time can display, since
that column is a DateTime. This is pre-existing and independent of the overflow: any schedule past
2106 already exceeds the column's range, refresh after 9200000000000 second included. Before this
change those inputs were undefined behaviour, so the change replaces UB with a deterministic value
at that boundary. Widening the column is a separate, user-visible decision.

@groeneai

groeneai commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes, on an ASAN+UBSAN build with the CI amd_asan_ubsan flags. Three DDLs, one per unchecked domain: REFRESH EVERY 1 YEAR RANDOMIZE FOR 10000000000000000 SECOND (aborts for 99.8% of draws), ... RANDOMIZE FOR 10000000000000000000 SECOND (99.9998%), and REFRESH AFTER 9200000000000 SECOND RANDOMIZE FOR 18000000000000 SECOND. The third is one-sided by construction, since only a positive draw overshoots the range: it aborted 3 of 6 single-view runs, so the shipped arm creates thirty views, leaving a 9.3e-10 chance of no abort, and the deterministic pin for that domain is in the gtest, which drives the addition to both ends of the range with a fixed factor.
b Root cause explained? RANDOMIZE FOR takes an unbounded UInt64 and the random factor is a signed Int64, but RefreshSchedule::addRandomSpread computed the offset in double and then pushed it through three unchecked domains in one expression: the double to Int64 conversion, the milliseconds to microseconds widening (system_clock::duration is microseconds, so a representable millisecond value is not automatically safe), and the time_point addition. Each has its own threshold and its own measured frame.
c Fix matches root cause? Yes. The offset is computed in Int128, the narrowest width that holds the widest possible product of the two unbounded operands (UInt64_MAX * Int64_MIN fits with 2^63 to spare, and the final sum with a factor of 2000), then saturated strictly inside the time_point range. No test bound was widened, no randomization was disabled, nothing is guarded at the crash site.
d Test intent preserved / new tests added? No existing test was modified. Added 04757_refreshable_mv_randomize_for_overflow.sh (one arm per domain plus a control with an ordinary spread, and a sign-independent oracle since the factor's sign is random) and gtest_refresh_schedule.cpp (3 tests, 15 assertions on the returned instant).
e Both directions demonstrated? Yes. On a server running unpatched master the new test fails and the server exits 134 with one sanitizer report in addRandomSpread and no unrelated fatal; on the patched build it passes. Re-confirmed for the thirty-view arm: unpatched master aborts on the second view, symbolized operator+ in duration.h:403 under determineNextRefreshTime, one report and no unrelated fatal, and the patched build passes twenty consecutive runs.
f Fix is general across code paths? addRandomSpread is the only place the spread and the random factor meet, and it has exactly one call site, so the retry, schedule and dependency instants it serves are all covered by the one change. minSeconds() appears in four other places, none of which combines it with the random factor. Two adjacent overflows in the same subsystem, in CalendarTimeInterval and in the delay handed to BackgroundSchedulePool, are deliberately out of scope and tracked separately.
g Fix generalizes across inputs (params/datatypes/wrappers)? The gtest covers spreads of 0, ordinary values, the band where the old double path began rounding, 1e19, and UInt64_MAX; random factors of 0, plus and minus 1, half scale, full scale, and both Int64 extremes, which is what the coordination znode can hold since it deserializes the value with no range check; and instants at mid-range and at both ends of the representable range. No type wrappers apply, the operands being plain integers and a time_point.
h Backward compatible? (maintainer-approved exception only) Yes. No setting default changes, so no SettingsChangesHistory.cpp entry, and the diff touches one source file. No serialization or coordination format change, and no DDL is accepted or rejected differently. The only behavioural delta is inside the previously undefined range, plus a rounding correction of up to a few milliseconds (measured bound 4 ms) for windows wider than about 100 days, which the changelog entry states.
i Invariants and contracts preserved? time_point::max() is the "no refresh scheduled" sentinel. It is never an input, because the caller tests for it before calling, and it is never an output, because the saturation stops one microsecond short; a mutant that saturates to the sentinel instead reddens the gtest. time_point::min() is not a sentinel anywhere in this subsystem. The comparator's static_assert on the struct size is unaffected, no member being added. The function is a non-virtual const method with no early returns and no error paths.

Five mutants of the fix were built, each with its own build id, and each reddens at least one test:
reverting the operand order, computing the product in Int64, clamping after the widening instead
of before it, dropping the saturation, and saturating to the sentinel. Two of them are invisible to
the stateless test because they fail inside the wide-integer arithmetic, where the sanitizer emits
no check and the result is silently wrong rather than aborting, which is why the gtest is included
rather than left out. The control arm with an ordinary spread stays green for every mutant, so each
abort is attributable. Fifty runs of the new test and of three sibling REFRESH tests, plus five of the fourth
(it is long-tagged, so the runner scales its repeats by 0.1), all pass with no sanitizer report,
and twenty further runs of the new test after its third arm was widened to thirty views: twenty of
twenty, longest 96.5 seconds against the runner's 600 second per-test default.

Session id: cron:clickhouse-review-slot-48:20260806-123800

@groeneai

groeneai commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

cc @al13n321 @tuanpach, could you review this? RANDOMIZE FOR takes an unbounded UInt64 and the random factor is a signed Int64, so addRandomSpread had three unchecked domains in one expression, the double to Int64 conversion, the milliseconds to microseconds widening and the time_point addition, each of which aborts a sanitizer build; the offset is now computed in Int128 and saturated strictly inside the time_point range, which also corrects a few-millisecond rounding error for windows wider than about 100 days.

@PedroTadim PedroTadim added the can be tested Allows running workflows for external contributors label Aug 7, 2026
@clickhouse-gh

clickhouse-gh Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [5ce7325]

Summary:

job_name test_name status info comment
Stress test (amd_tsan) FAIL
Cannot start clickhouse-server FAIL cidb
Logical error: Sizes of nested column and null map of Nullable column are not equal after deserialization (null map size = A, nested column size = B) (STID: 6726-6444) FAIL cidb
Check failed FAIL cidb

AI Review

Summary

This PR replaces the double-based RANDOMIZE FOR jitter arithmetic in RefreshSchedule::addRandomSpread with bounded Int128 math, clamps the result away from the time_point::max() sentinel, and adds focused stateless and gtest coverage, including calendar-unit spreads. After rechecking the prior inline concern against the current code and tests, I did not find any remaining correctness or test-wiring issues in this diff.

Final Verdict

✅ No remaining review findings.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.50% 86.50% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.80% 78.80% +0.00%

Changed lines: Changed C/C++ lines covered: 52/52 (100.00%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Aug 7, 2026
Comment thread src/Storages/MaterializedView/RefreshSchedule.cpp
@clickhouse-gh

clickhouse-gh Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

No arm_release build profile data for commit 5ce7325 - the build was skipped, reused from cache, or predates profile upload.

Every arm of 04757 passed a window expressed in seconds, so `months` was zero in
all of them and the months term of CalendarTimeInterval::minSeconds was never
exercised. Add a YEAR and a MONTH arm to the stateless test, and months-only
cases to the unit test.

The stateless arms are deterministic rather than probabilistic like arm C:
escaping the milliseconds-to-microseconds widening pre-fix needs |randomness|
<= 1015 for the YEAR arm and <= 6044 for the MONTH arm, out of the 1e9 the
scheduler draws. Measured on a pristine ASan+UBSan master build, each arm run
alone aborts at RefreshSchedule.cpp:73:51, and both are clean on the fixed
build.

The unit cases are what pin the calendar term itself: with only the existing
seconds-based cases, replacing spread.minSeconds with spread.seconds inside
addRandomSpread left every assertion passing in both tests. Their windows (1, 12
and 24 months) do not wrap in minSeconds, so the expected offsets are exact.
Verified against that mutant: it fails only the new unit case.

Also add the atomic-database tag. Every view here is non-APPEND with a Memory
target, and in a Replicated database StorageMaterializedView forces
refresh_coordinated and then refuses that combination, so the test cannot pass
under --replicated-database. The runner only acts on the tag when the database
engine is not Atomic, so no passing run is skipped, and the sibling
refreshable-view tests already carry it.

The wrap in the unsigned calendar conversion itself is pre-existing and out of
scope: CalendarTimeInterval.cpp is byte-identical between this branch and
master, and an input whose wrap does collapse the window to a small value
schedules identically on both.
@PedroTadim PedroTadim self-assigned this Aug 7, 2026
@groeneai

groeneai commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

CI finish ledger - 5ce7325

Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
whose fixing-PR link will be posted here when it opens. Only CH Inc sync is exempt.

CI is fully finished on this head: 175/175 check-runs completed, 157 success / 17 skipped,
Finish Workflow = success, one failing job.

Check / test Reason Owner / fixing PR
Stress test (amd_tsan) / Logical error: Sizes of nested column and null map of Nullable column are not equal after deserialization (STID 6726-6444) crash: a set skip-index granule decoded with a type its on-disk data was not written with #112484 (ours, open), with #106988 (ours, open) covering the sibling pending-mutation path
Stress test (amd_tsan) / Cannot start clickhouse-server same event as the row above; the server aborted at 15:22:33, the readiness loop reported it at 13:22:43 UTC job time same owner as above
Stress test (amd_tsan) / Check failed job-level roll-up of the two rows above same owner as above

Not caused by this PR. The diff is confined to RefreshSchedule::addRandomSpread plus its own
gtest and one stateless test; it touches no serialization, skip-index or MergeTree read code. The
abort stack is SerializationNullable::deserializeBinaryBulkWithMultipleStreams <-
MergeTreeIndexGranuleSet::deserializeBinary <- MergeTreeIndexReader::read, reached from a
background read thread on a stress-workload part, with no frame in anything this PR changes.

The three failing rows are one server abort, not three defects: the Cannot start row is the
readiness probe observing the process that had already died, and Check failed is the job
roll-up.

Session id: cron:our-pr-ci-monitor:20260807-153000

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants