Skip to content

Fix signed overflow in vset bucket-timestamp rounding - #4312

Open
ranshid wants to merge 5 commits into
valkey-io:unstablefrom
ranshid:fix/vset-hfe-bucket-ts-overflow
Open

Fix signed overflow in vset bucket-timestamp rounding #4312
ranshid wants to merge 5 commits into
valkey-io:unstablefrom
ranshid:fix/vset-hfe-bucket-ts-overflow

Conversation

@ranshid

@ranshid ranshid commented Aug 2, 2026

Copy link
Copy Markdown
Member

vset groups hash fields into time buckets keyed by the end of their expiry window. It computes that key by rounding the expiry up to the next window boundary:

bucket_ts = (expiry & ~(INTERVAL - 1)) + INTERVAL;   // INTERVAL = 8192ms or 16ms

For any expiry in the top 8192ms window below 2⁶³, that addition overflows signed long long and wraps to a negative value. The negative value then becomes a RAX bucket key. Because RAX compares keys as unsigned big-endian bytes, this key sorts after every legitimate timestamp — silently corrupting bucket order. When a full vector bucket is later forced to split, findSplitPosition() returns a normal positive target while the poisoned key is negative, so the invariant check fails and the server aborts:

=== ASSERTION FAILED ===
==> vset.c:1151 'target_bucket_ts < bucket_ts' is not true

How it manifests in a real flow

HPEXPIREAT accepts any absolute timestamp up to LLONG_MAX, so a client can trigger this with ordinary commands — no debug hooks or crafted state:

HSET k f1 v … f128 v
HPEXPIREAT k 9223372036854767616 FIELDS 126 f1 … f126   # ts A = 2^63 - 8192
HPEXPIREAT k 9223372036854775616 FIELDS 1   f127         # ts B, later sub-window, same 8192ms window
HPEXPIREAT k 9223372036854767616 FIELDS 1   f128         # tips the bucket over the vector limit → CRASH

The 128th field pushes the bucket past the vector size limit and forces the vector→rax split, where the overflowed negative bucket_ts fails the assertion and takes the server down. Any authenticated client with write access to a hash can do this, so it is a denial-of-service, not just a debug-build assert.

The same corruption also reaches vset through paths that skip command validation — RDB load, RESTORE, and replication full-sync all feed the field expiry straight into vsetAddEntry. A fix at the command layer alone would not cover them.

Fix

Make get_bucket_ts() and get_max_bucket_ts() saturate at LLONG_MAX instead of overflowing, so a bucket key can never go negative and invert ordering. The fix lives in the vset math — the single chokepoint every ingestion path funnels through — so it protects live commands, RDB/RESTORE, and replication alike, and keeps the accepted expiry range consistent with top-level key TTLs.

Testing

  • Added VsetTest.TestVsetLargeExpiryBucketOverflow, which reproduces the exact split scenario and also verifies a small-TTL entry still scans before the huge-TTL bucket. It aborts on the current code and passes with the fix.
  • Full VsetTest suite and the hash-field-expiry integration tests pass.

ranshid added 2 commits August 2, 2026 08:00
Hash-field expiry timestamps inside the top 8192ms window below 2^63
overflow the vset bucket-timestamp math (get_bucket_ts /
get_max_bucket_ts round up via `(expiry & ~(INTERVAL-1)) + INTERVAL`,
which wraps signed long long). The overflowed value becomes a negative
RAX bucket key; because RAX keys compare as unsigned big-endian bytes it
sorts after every real timestamp, and forcing a full vector bucket to
split trips assert(target_bucket_ts < bucket_ts) in splitBucketIfPossible
(vset.c:1151), aborting the server.

VsetTest.TestVsetLargeExpiryBucketOverflow reproduces the crash: it fills
a vector bucket with 126 entries at ts A (2^63-8192) and 1 at ts B (a
later 16ms sub-window in the same 8192ms window), then adds a 128th entry
to force the vector->rax split. It also adds a tiny-TTL entry last and
asserts it is scanned first, confirming the huge-TTL entries no longer
invert bucket scan order. The test currently fails (aborts).

Signed-off-by: Ran Shidlansik <ranshid@amazon.com>
get_bucket_ts() and get_max_bucket_ts() rounded an expiry up to the end
of its 16ms / 8192ms window via `(expiry & ~(INTERVAL-1)) + INTERVAL`.
For any hash-field expiry inside the top window below 2^63 this addition
overflowed signed long long and wrapped to a negative value. That value
became a RAX bucket key; because RAX keys are compared as unsigned
big-endian bytes, the negative key sorted after every real timestamp.
When a full vector bucket was then forced to split, findSplitPosition()
returned a positive target while the poisoned bucket_ts was negative
(LLONG_MIN), tripping assert(target_bucket_ts < bucket_ts) in
splitBucketIfPossible (vset.c) and aborting the server. This was
reachable by any client via HPEXPIREAT/HEXPIREAT with a large absolute
timestamp (HSET + HPEXPIREAT sequence), i.e. an authenticated crash.

Make both helpers saturate at LLONG_MAX instead of overflowing, so a
bucket timestamp can never become negative and poison key ordering. This
keeps the accepted expiry range consistent with top-level key TTLs.

Verified by the regression tests added in the previous commit:
VsetTest.TestVsetLargeExpiryBucketOverflow and the HPEXPIREAT near-max
timestamp test in hashexpire.tcl now pass.

Signed-off-by: Ran Shidlansik <ranshid@amazon.com>
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 576fe337-de56-4446-a76f-9b8988190da9

📥 Commits

Reviewing files that changed from the base of the PR and between e991b32 and da2484e.

📒 Files selected for processing (1)
  • src/unit/test_vset.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/unit/test_vset.cpp

📝 Walkthrough

Walkthrough

The change saturates bucket timestamps at LLONG_MAX to prevent signed overflow. It adds exact terminal-bucket lookup and regression tests for conversion, splitting, ordering, iteration, and deletion.

Changes

Expiry bucket overflow handling

Layer / File(s) Summary
Saturate and locate terminal buckets
src/vset.c
Bucket timestamp helpers saturate at LLONG_MAX. findBucket uses exact lookup for LLONG_MAX and strict-greater lookup for other expiries.
Validate large expiry behavior
src/unit/test_vset.cpp, tests/unit/hashexpire.tcl
Tests cover boundary-sized expiries, vector-to-RAX conversion, bucket splitting, ascending iteration of 131 entries, duplicate terminal entries, and hash-field deletion.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: enjoy-binbin

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the signed-overflow fix in vset bucket-timestamp rounding.
Description check ✅ Passed The description directly explains the overflow, its impact, the fix, affected ingestion paths, and regression tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@valkey-review-bot valkey-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the saturating rounding change. The direction is right — a negative bucket_ts is unambiguously broken, and the unit test does reproduce the split assert. One residual hole at the very top of the range: saturating at LLONG_MAX makes bucket_ts == expiry for expiry == LLONG_MAX, which findBucket()'s strict > seek can never resolve. Details inline, including what I verified against the real rax.c.

Comment thread src/vset.c
static inline long long get_max_bucket_ts(long long expiry) {
return (expiry & ~(VOLATILESET_BUCKET_INTERVAL_MAX - 1LL)) + VOLATILESET_BUCKET_INTERVAL_MAX;
long long aligned = expiry & ~(VOLATILESET_BUCKET_INTERVAL_MAX - 1LL);
if (aligned > LLONG_MAX - VOLATILESET_BUCKET_INTERVAL_MAX) return LLONG_MAX;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Saturating at LLONG_MAX breaks the "bucket_ts is the exclusive end of the window" invariant documented at vset.c:87 (expiry < bucket_ts) at the top of the range: get_max_bucket_ts(LLONG_MAX) == LLONG_MAX and get_bucket_ts(LLONG_MAX) == LLONG_MAX, so an entry whose expiry is exactly LLONG_MAX gets a bucket key equal to its own expiry. findBucket() resolves a bucket with raxSeek(&iter, ">", key) over encodeExpiryKey(expiry) (vset.c:1086), which is strictly greater (rax.c:1509 sets gt without eq), so that bucket can never be found again for that entry.

Two consequences, both reachable with HPEXPIREAT k 9223372036854775807 FIELDS ... on a hash holding 128+ volatile fields (convertExpireArgumentToUnixTime accepts exactly LLONG_MAX when basetime == 0, expire.c:740, and 128 volatile fields is what puts the vset in RAX encoding):

  • insertToBucket_RAX() sees VSET_BUCKET_NONE and raxInsert()s a fresh SINGLE bucket under the same key at vset.c:1257. raxInsert overwrites and old is passed as NULL, so the previous bucket pointer is dropped — the 127-entry pVector demoted into the rax at vset.c:1795 is leaked and its entries silently disappear from the set.
  • The next HDEL/HSETEX/HPERSIST on one of those orphaned fields hits assert(bucket != VSET_NONE_BUCKET_PTR) in removeFromBucket_RAX() (vset.c:1455) — that assert is serverAssert via serverassert.h, so it aborts in release builds too. On the update path vsetBucketUpdateEntry_RAX() returns NONE (vset.c:1970), which fires serverAssert(vsetUpdateEntry(...) == 1) at t_hash.c:142.

I confirmed this by linking these two helpers plus encodeExpiryKey/findBucket verbatim against the real src/rax.c: on the PR head, findBucket(LLONG_MAX) returns VSET_BUCKET_NONE while a bucket keyed LLONG_MAX is present, and the follow-up raxInsert returns 0 with old = the previous bucket. On unstable the same call resolves the bucket, because the wrapped LLONG_MIN key encodes as 0x8000..., which does sort strictly after 0x7FFF.... So this specific expiry regresses from working to crashing.

Since no long long bucket_ts can be strictly greater than LLONG_MAX, saturation alone cannot preserve the invariant. Either keep the largest expiry the vset can store strictly below LLONG_MAX (reject/cap it before it reaches vsetAddEntry, so RDB/RESTORE/replication are covered too), or teach findBucket() to resolve the saturated top bucket explicitly — exact-match on the LLONG_MAX key when get_max_bucket_ts(expiry) == LLONG_MAX and the > seek came up empty. Either way it is worth extending TestVsetLargeExpiryBucketOverflow to LLONG_MAX itself; the chosen A/B values are both strictly below it, so this boundary is untested.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

agree. handling the LLONG_MAX ttl is required. since preventing it is a breaking change, I prefer to fix it correctly

Comment thread src/unit/test_vset.cpp
* 128th tips the VECTOR over its size limit and forces the vector->rax
* conversion + split that overflows. Aborts without the fix; with the fix
* (saturating get_bucket_ts/get_max_bucket_ts) it succeeds. */
TEST_F(VsetTest, TestVsetLargeExpiryBucketOverflow) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The commit message for e164e4e states this was "Verified by ... the HPEXPIREAT near-max timestamp test in hashexpire.tcl", but the PR changes only src/vset.c and src/unit/test_vset.cpp, and tests/unit/hashexpire.tcl has no near-max-timestamp test at this ref. Either drop that line from the message or add the test.

Given the PR describes this as an authenticated DoS reachable with plain HSET + HPEXPIREAT, the command-level sequence from the description is worth adding to tests/unit/hashexpire.tcl alongside the unit test — that is the path that guards the reachable repro end to end (argument validation, the 128-volatile-field threshold that switches the vset to RAX encoding, and the subsequent HDEL/HGETALL on those fields), which the vset-level test does not exercise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@valkey-review-bot I wanted to reduce the load on our tcl tests, by only focusing on unittest addition which is focused on the problem in this specific data structure implementation.

ranshid added 2 commits August 2, 2026 14:38
The previous commit made get_bucket_ts()/get_max_bucket_ts() saturate at
LLONG_MAX to stop the signed overflow that poisoned RAX bucket keys. That
fixes every expiry strictly below LLONG_MAX, but it leaves one broken
boundary: for expiry == LLONG_MAX both helpers return LLONG_MAX, so the
entry is stored under a bucket key equal to its own expiry. This violates
the exclusive-end invariant (entries must satisfy expiry < bucket_ts, see
the header comment) at the single value where it is unavoidable -- no
long long bucket_ts can be strictly greater than LLONG_MAX.

findBucket() resolves an entry's bucket with raxSeek(">", encode(expiry)),
a strictly-greater seek. When the bucket key equals encode(LLONG_MAX) the
seek can never match it, so the LLONG_MAX bucket becomes unfindable:

- A second LLONG_MAX insert takes the "no bucket" path and raxInsert()s a
  fresh bucket under the same key with old = NULL, silently overwriting
  (and leaking) the bucket already stored there and dropping its entries.
- A subsequent HDEL/HSETEX/HPERSIST on one of those entries hits
  serverAssert(bucket != VSET_NONE_BUCKET_PTR) in removeFromBucket_RAX(),
  aborting the server in release builds too.

expiry == LLONG_MAX is a reachable input: convertExpireArgumentToUnixTime
accepts it (HPEXPIREAT k 9223372036854775807 ...), and RDB load / RESTORE
/ replication feed it straight into vsetAddEntry without command
validation. On a hash with 128+ volatile fields (RAX encoding) this
regressed from working (on unstable the wrapped negative key happened to
sort last) to data loss + crash.

Fix findBucket() to treat the saturated terminal window as inclusive:
when the ">" seek finds nothing and get_max_bucket_ts(expiry) == LLONG_MAX,
probe the LLONG_MAX key directly with an "=" seek. The probe is scoped so
it effectively fires only for expiry == LLONG_MAX -- any expiry below it
still resolves the LLONG_MAX bucket via the existing ">" seek. This keeps
the fix at the same vset chokepoint, covering commands, RDB, RESTORE, and
replication uniformly, without mutating stored TTLs.

Tests:
- Extend VsetTest.TestVsetLargeExpiryBucketOverflow with two LLONG_MAX
  entries and a full last-to-first drain: asserts no entry is lost (the
  overwrite would drop one) and that every expiry -- LLONG_MAX included --
  is removable, leaving the set empty.
- Re-add tests/unit/hashexpire.tcl integration coverage: the near-2^63
  HPEXPIREAT vector->rax split flow, and an HPEXPIREAT-at-LLONG_MAX flow
  that HDELs a field (the command path that aborts without this fix).

Signed-off-by: Ran Shidlansik <ranshid@amazon.com>
ASSERT_EQ expands to an if/else, so the unbraced
`if (count == 0) ASSERT_EQ(...)` in TestVsetLargeExpiryBucketOverflow is a
dangling else. GCC with -Wall -Werror rejects it:

  test_vset.cpp: error: suggest explicit braces to avoid ambiguous 'else'
  [-Werror=dangling-else]

This broke the `make all-with-unit-tests` step on CI (Apple clang does not
flag it, which is why it slipped through local builds), cascading into the
build/32bit/sanitizer/compatibility job failures. Wrap the guarded
ASSERT_EQ in explicit braces.

Verified with g++-15 -Wall -Wextra -Werror: the unbraced form reproduces
the dangling-else error and the braced form compiles clean.

Signed-off-by: Ran Shidlansik <ranshid@amazon.com>
@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.85%. Comparing base (f75e2d5) to head (da2484e).

Additional details and impacted files
@@             Coverage Diff              @@
##           unstable    #4312      +/-   ##
============================================
- Coverage     76.87%   76.85%   -0.03%     
============================================
  Files           162      162              
  Lines         81497    81551      +54     
============================================
+ Hits          62653    62675      +22     
- Misses        18844    18876      +32     
Files with missing lines Coverage Δ
src/unit/test_vset.cpp 99.41% <100.00%> (+0.09%) ⬆️
src/vset.c 87.72% <100.00%> (-0.37%) ⬇️

... and 23 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

clang-format-check requires the braced guard body on its own line:

    if (count == 0) {
        ASSERT_EQ(mockGetExpiry(entry), SMALL);
    }

Reformat the dangling-else guard accordingly (still braced, so no
dangling-else regression). clang-format now reports no diff for
src/vset.c or src/unit/test_vset.cpp.

Signed-off-by: Ran Shidlansik <ranshid@amazon.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.

1 participant