Skip to content

fix(commands): reject expire times that overflow int64 - #3680

Open
shivampwm2020 wants to merge 6 commits into
apache:unstablefrom
shivampwm2020:fix-3674-expire-overflow-v2
Open

shivampwm2020 wants to merge 6 commits into
apache:unstablefrom
shivampwm2020:fix-3674-expire-overflow-v2

Conversation

@shivampwm2020

@shivampwm2020 shivampwm2020 commented Oct 15, 2026 •

Copy link
Copy Markdown

Problem

SET k v EX 9223372036854775807 returns OK but silently removes the key. Redis rejects it with (error) ERR invalid expire time in set. The same overflow also affects EXPIRE, PX, EXAT, and PXAT.

The overflowing seconds*1000 (or +now for the absolute variants) wraps int64 to a past timestamp, so the key is written and immediately expired.

Repro:

127.0.0.1:6666> SET k old
OK
127.0.0.1:6666> SET k new EX 9223372036854775807
OK
127.0.0.1:6666> GET k
(nil)                     # expected: error, key keeps "old"

Root cause

ParseExpireFlags validated TTL seconds against a fixed INT64_MAX / 1000 bound (seconds) or INT64_MAX (ms). That neither catches seconds*1000 overflow-adjacent values for EX/PX, nor + now_ms for EXAT/PXAT — a value can pass the check and still overflow into a past expiration.

Fix

ttl_util.h gains ToExpireAtMs(ttl, ttl_in_seconds, add_now): the upper bound is computed against INT64_MAX - now_ms at the moment now is added, and the result is range-checked after conversion. ParseExpireFlags routes all four flags (EX, PX, EXAT, PXAT) through it, so seconds and milliseconds paths share one limit.

After:

127.0.0.1:6666> SET k new EX 9223372036854775807
(error) ERR invalid expire time in set
127.0.0.1:6666> GET k
"old"

Testing

  • New gocase test in tests/gocase/unit/expire/expire_test.go asserts the overflow values are rejected and the old value survives, for EX, PX, EXAT, PXAT, EXPIRE and PEXPIRE.
  • go test ./unit/expire/ -args -binPath=.../build/kvrocks -workspace=... → ok (TestExpire green).
  • Full build of kvrocks from build/ with make -j6 → exit 0.

Notes

  • Only commands with user-supplied expire values are touched; no behavior change for valid times.
  • Matches Redis' error text for the EX family.

AI usage disclosure

Per the guidelines for AI-assisted contributions: this change was prepared with the help of an LLM coding assistant (GLM), used to reproduce the overflow behavior against a local build, draft the ToExpireAtMs bound and the gocase regression test, and run them locally (TestExpire green, full make build OK). The change is reviewed and maintained by @shivampwm2020, who is responsible for it.

@github-actions

Copy link
Copy Markdown

Hi @shivampwm2020,

Thank you for your pull request. Please review our Contributing Guide.

Please make sure you understand your changes and explain your reasoning in this pull request. Low-quality pull requests may be closed.

@weim0000

weim0000 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

The fix looks correct and complete. ToExpireAtMs properly caps the input so that neither the seconds-to-milliseconds scaling nor the addition of the current time can overflow, and all affected commands (SET EX/PX/EXAT/PXAT, SETEX/PSETEX, EXPIRE/PEXPIRE/EXPIREAT/PEXPIREAT) are now routed through it.

One minor suggestion: the Go test covers SET EX, SETEX, and EXPIRE for the overflow case, but ToExpireAtMs has four (ttl_in_seconds, add_now) combinations. It might be worth adding assertions for PEXPIRE and EXPIREAT as well to ensure each path is exercised:

> // PX overflow
> util.ErrorRegexp(t, rdb.Do(ctx, "set", "overflow", "new", "px", "9223372036854775807").Err(), ".*invalid expire time*.")
> require.Equal(t, "old", rdb.Get(ctx, "overflow").Val())
> 
> // EXPIREAT overflow
> util.ErrorRegexp(t, rdb.Do(ctx, "expireat", "overflow", "9223372036854775807").Err(), ".*invalid expire time*.")
> require.Equal(t, "old", rdb.Get(ctx, "overflow").Val())
>

LGTM otherwise.

shivampwm2020 pushed a commit to shivampwm2020/kvrocks that referenced this pull request Oct 16, 2026
Per review feedback on apache#3680: ToExpireAtMs has four (ttl, add_now)
combinations; add PX and EXPIREAT assertions alongside the existing
SET EX / SETEX / EXPIRE coverage.

Signed-off-by: Shivam Maurya <shivam@programmingwithmaurya.com>
shivampwm2020 pushed a commit to shivampwm2020/kvrocks that referenced this pull request Oct 16, 2026
Per review feedback on apache#3680: ToExpireAtMs has four (ttl, add_now)
combinations; add PX and EXPIREAT assertions alongside the existing
SET EX / SETEX / EXPIRE coverage.

Signed-off-by: Shivam Maurya <shivam@programmingwithmaurya.com>
@shivampwm2020

Copy link
Copy Markdown
Author

Done — added PX and EXPIREAT overflow assertions alongside the existing SET EX / SETSETEX / EXPIRE coverage (0befba8), each checking the error and that the old value is preserved. Thanks for the review.

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.

🟡 Changes recommended

Positive-only validation rejects valid immediate-expiration requests and leaves existing keys alive.

1 open finding
What changed in this PR

This PR adds overflow-safe expiration conversion to Kvrocks commands, preventing oversized expiration values from silently expiring keys.

Changes:

  • Centralizes timestamp conversion and overflow checks.
  • Applies validation to string and key-expiration commands.
  • Adds rejection and value-preservation regression tests.
File Description
tests/​gocase/​unit/​expire/​expire_test.go Adds expiration-overflow regression cases.
src/​commands/​ttl_util.h Introduces shared overflow-safe conversion.
src/​commands/​cmd_string.cc Updates SETEX and PSETEX conversion.
src/​commands/​cmd_key.cc Updates EXPIRE, PEXPIRE, and EXPIREAT parsing.

🧠 Review effort: Balanced


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

Comment thread src/commands/cmd_key.cc Outdated
Comment on lines +204 to +205
auto ttl = GET_OR_RET(ParseInt<int64_t>(args[2], TTL_RANGE<int64_t>));
expire_at_ = GET_OR_RET(ToExpireAtMs(ttl, true, true));

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the review! Fixed in 4b09629: EXPIRE/PEXPIRE/EXPIREAT parse signed values again, and non-positive ones now map to a small past timestamp (1 ms, which becomes 1 s in the 32-bit expire encoding) so the key is deleted exactly like Redis. Zero cannot be stored directly because it means "no expiration". Overflowing values are still rejected before the multiplication. Added test cases for zero and negative timeouts across all three commands.

@jihuayu jihuayu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the fix!
This changes the behavior of EXPIRE/PEXPIRE for non-positive timeouts. According to the Redis documentation, these values should delete the key, but this PR rejects them as invalid input.

Image

Could you preserve the existing behavior and add test cases for zero and negative timeouts?

Otherwise, LGTM.

@jihuayu

jihuayu commented Oct 16, 2026

Copy link
Copy Markdown
Member

Also, @shivampwm2020 please include an AI usage disclosure as required by our contribution guidelines.

@shivampwm2020

Copy link
Copy Markdown
Author

Apologies for missing this - disclosure per the guidelines: this PR was prepared with AI assistance. The assistant was used to reproduce the overflow behavior against a local build, draft the ToExpireAtMs bound and the gocase regression test, and run them locally (TestExpire is green on the current head, full make build ok). I reviewed the final diff against Redis's error behavior for the EX family and I'm responsible for the change, maintaining it through review. Happy to rework any part manually if useful.

shivampwm2020 pushed a commit to shivampwm2020/kvrocks that referenced this pull request Oct 16, 2026
EXPIRE, PEXPIRE and EXPIREAT now parse signed values again and map
non-positive ones to a small past timestamp (1 ms), so they delete the
key exactly like Redis instead of rejecting the command. Overflowing
values are still rejected before any multiplication can wrap.

Requested in review on PR apache#3680.
jihuayu
jihuayu previously approved these changes Oct 16, 2026

@jihuayu jihuayu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks LGTM

Shivam Maurya and others added 5 commits October 18, 2026 00:04
Converting a user supplied expire time to milliseconds could overflow
int64: values up to INT64_MAX passed the range check and were then
multiplied by 1000 (or had the current time added), wrapping the expire
time into the past. The key was then treated as already expired, so
`SET k v EX <huge>` and `EXPIRE k <huge>` returned success and silently
removed the key.

Add a shared ToExpireAtMs helper that caps the input so neither the
seconds-to-milliseconds scaling nor the addition of the current time can
overflow, and route EX/PX/EXAT/PXAT, EXPIRE/EXPIREAT/PEXPIRE and
SETEX/PSETEX through it. Overflowing values now get an "invalid expire
time" error and the previous value is preserved.
Per review feedback on apache#3680: ToExpireAtMs has four (ttl, add_now)
combinations; add PX and EXPIREAT assertions alongside the existing
SET EX / SETEX / EXPIRE coverage.

Signed-off-by: Shivam Maurya <shivam@programmingwithmaurya.com>
EXPIRE, PEXPIRE and EXPIREAT now parse signed values again and map
non-positive ones to a small past timestamp (1 ms), so they delete the
key exactly like Redis instead of rejecting the command. Overflowing
values are still rejected before any multiplication can wrap.

Requested in review on PR apache#3680.
@shivampwm2020
shivampwm2020 force-pushed the fix-3674-expire-overflow-v2 branch from 76e50c2 to 17caa70 Compare October 17, 2026 18:36
@shivampwm2020

Copy link
Copy Markdown
Author

Rebased onto unstable to clear the BEHIND state, so the merge button is not waiting on a stale branch. New head 17caa70d.

Nothing about the change itself moved: all four touched files are byte-identical to the version jihuayu approved (cmd_key.cc, cmd_string.cc, ttl_util.h, expire_test.go), verified by comparing blobs before and after. The three upstream commits it now sits on (#3609 cuckoo-filter, #3691 hash migration test, #3685 stream XREAD) touch unrelated subsystems, so nothing was absorbed or re-resolved.

CI is re-running on the new SHA. Sorry for the churn of a second full run — say the word if you would rather have kept the old SHA and I will leave the branch alone.

@shivampwm2020

Copy link
Copy Markdown
Author

One thing I should flag on my own change, since it affects you rather than me: the rebase required a new CI run, and because I am a first-time contributor here both CI and Compatibility Tests came back as action_required — held for approval — rather than starting. The previous SHA (76e50c29) had already run to completion with 30 checks and no failures.

So if you would rather have the already-green SHA back than approve a fresh run, say so and I will force the branch back to 76e50c29 and leave it. That is a real trade I made on your behalf without asking: I cleared BEHIND, but I cannot confirm that BEHIND was actually blocking your merge button — I could not read the branch protection rules with my token, and the repo has no rulesets. If it was not blocking, the rebase was unnecessary churn and I would rather undo it than leave you approving two 40-minute runs.

If you do approve the runs and they go green, nothing else needs doing from me.

@shivampwm2020

Copy link
Copy Markdown
Author

Following up on my own message, because I think I overstated the problem.

I checked the repo's action queue rather than reasoning about it: 8 of the 20 most recent workflow runs are in action_required, across four different branches — feat/cluster-failover-takeover, feat/cluster-master-lease, feature/cf-del and mine. So being held for approval is just how this repo treats outside contributors' runs; it is not something the rebase broke, and it is not specific to #3680.

That means the only real question is the one I raised: whether clearing BEHIND was worth a fresh run. I still cannot tell whether BEHIND was blocking your merge button — protection rules 404 for my token and there are no rulesets — so I will leave the branch at 17caa70d and not churn it again. If you would rather have the old green SHA back, the offer stands and it is one force-push; otherwise approving the two runs is all this needs.

Sorry for the noise — two messages on this was one more than it deserved.

@jihuayu

jihuayu commented Oct 18, 2026

Copy link
Copy Markdown
Member

@shivampwm2020 It don’t need you to do anything. I need to merge the PRs one by one, so please wait. The action requires manual approval from a maintainer to run.
Thanks

@shivampwm2020

Copy link
Copy Markdown
Author

Understood — holding at 17caa70 and touching nothing. Thanks for approving the runs when you get to it.

One correction to my earlier messages, for your queue's sake rather than mine: I raised the action_required state as if it might be a problem I had created. It isn't — it is just how outside-contributor runs are treated here, and I said so once I checked. It needed no reply, and this one needs none either.

@jihuayu
jihuayu enabled auto-merge (squash) October 19, 2026 09:00
@git-hulk
git-hulk disabled auto-merge October 19, 2026 10:23
@git-hulk

Copy link
Copy Markdown
Member

@shivampwm2020 I don't think it's a proper way to use AI to reply what reviewers suggested.

For this PR, the fix is correct but we can improve ToExpireAtMS the design like the following way:

enum class ExpireTimeMode {
  kRelativeSeconds,
  kRelativeMilliseconds,
  kAbsoluteSeconds,
  kAbsoluteMilliseconds,
};

StatusOr<uint64_t> ConvertToExpireAtMs(int64_t expire_arg, ExpireTimeMode time_mode, uint64_t now_ms) {
  auto value = static_cast<uint64_t>(expire_arg);
  bool seconds =
      time_mode == ExpireTimeMode::kRelativeSeconds || time_mode == ExpireTimeMode::kAbsoluteSeconds;
  bool relative = time_mode == ExpireTimeMode::kRelativeSeconds ||
                  time_mode == ExpireTimeMode::kRelativeMilliseconds;

  if (seconds) {
    if (value > kMaxExpireAtMs / 1000) {
      return {Status::RedisExecErr, "expire time overflow"};
    }
    value *= 1000;
  }
  if (value > kMaxExpireAtMs) {
    return {Status::RedisExecErr, "expire time overflow"};
  }
  if (relative) {
    if (value > kMaxExpireAtMs - now_ms) {
      return {Status::RedisExecErr, "expire time overflow"};
    }
    value += now_ms;
  }

  return value;
}

So that we can reuse this in HFE as well, see

constexpr uint64_t kMaxHashFieldExpireAtMs = (static_cast<uint64_t>(1) << 46) - 1;
enum class HashFieldExpireTimeMode {
kRelativeSeconds,
kRelativeMilliseconds,
kAbsoluteSeconds,
kAbsoluteMilliseconds,
};
enum class HashFieldExpireCommandTTLAction {
kNone,
kKeep,
kPersist,
kSet,
};
enum class HashFieldSetCommandCondition {
kNone,
kFNX,
kFXX,
};
struct HashFieldExpireCommandArgs {
HashFieldExpireCommandTTLAction ttl_action = HashFieldExpireCommandTTLAction::kNone;
HashFieldSetCommandCondition condition = HashFieldSetCommandCondition::kNone;
uint64_t expire_at_ms = 0;
std::vector<std::string> fields;
std::vector<FieldValue> field_values;
};
std::vector<Slice> ToSlices(const std::vector<std::string> &values) {
std::vector<Slice> slices;
slices.reserve(values.size());
for (const auto &value : values) {
slices.emplace_back(value);
}
return slices;
}
std::string IntegerArray(const std::vector<int64_t> &results) {
std::vector<std::string> entries;
entries.reserve(results.size());
for (auto result : results) {
entries.emplace_back(redis::Integer(result));
}
return redis::Array(entries);
}
uint64_t CeilDiv1000(uint64_t value) { return value / 1000 + (value % 1000 != 0 ? 1 : 0); }
Status ParseHashFieldExpireArgument(std::string_view raw, int64_t *expire_arg) {
auto parsed = ParseInt<int64_t>(raw, 10);
if (!parsed) {
return {Status::RedisParseErr, errValueNotInteger};
}
if (*parsed < 0) {
return {Status::RedisParseErr, "invalid expire time, must be >= 0"};
}
*expire_arg = *parsed;
return Status::OK();
}
Status ConvertHashFieldExpireAtMs(int64_t expire_arg, HashFieldExpireTimeMode time_mode, uint64_t now_ms,
uint64_t *expire_at_ms) {
auto value = static_cast<uint64_t>(expire_arg);
bool seconds =
time_mode == HashFieldExpireTimeMode::kRelativeSeconds || time_mode == HashFieldExpireTimeMode::kAbsoluteSeconds;
bool relative = time_mode == HashFieldExpireTimeMode::kRelativeSeconds ||
time_mode == HashFieldExpireTimeMode::kRelativeMilliseconds;
if (seconds) {
if (value > kMaxHashFieldExpireAtMs / 1000) {
return {Status::RedisExecErr, "expire time overflow"};
}
value *= 1000;
}
if (value > kMaxHashFieldExpireAtMs) {
return {Status::RedisExecErr, "expire time overflow"};
}
if (relative) {
if (value > kMaxHashFieldExpireAtMs - now_ms) {
return {Status::RedisExecErr, "expire time overflow"};
}
value += now_ms;
}
*expire_at_ms = value;
return Status::OK();
}
template <typename Parser>
std::optional<HashFieldExpireTimeMode> ParseHashFieldExpireTimeMode(Parser &parser) {
if (parser.EatEqICase("EX")) return HashFieldExpireTimeMode::kRelativeSeconds;
if (parser.EatEqICase("PX")) return HashFieldExpireTimeMode::kRelativeMilliseconds;
if (parser.EatEqICase("EXAT")) return HashFieldExpireTimeMode::kAbsoluteSeconds;
if (parser.EatEqICase("PXAT")) return HashFieldExpireTimeMode::kAbsoluteMilliseconds;
return std::nullopt;
}
template <typename Parser>
Status TakeHashFieldExpireArgument(Parser &parser, int64_t *expire_arg) {
auto parsed = parser.template TakeInt<int64_t>(10);
if (!parsed) {
return {Status::RedisParseErr, errValueNotInteger};
}
if (*parsed < 0) {
return {Status::RedisParseErr, "invalid expire time, must be >= 0"};
}
*expire_arg = *parsed;
return Status::OK();
}
Status ConvertHashFieldExpireAtMsForCommand(int64_t expire_arg, HashFieldExpireTimeMode time_mode, uint64_t now_ms,
uint64_t *expire_at_ms) {
bool relative = time_mode == HashFieldExpireTimeMode::kRelativeSeconds ||
time_mode == HashFieldExpireTimeMode::kRelativeMilliseconds;
if ((!relative || now_ms <= kMaxHashFieldExpireAtMs) &&
ConvertHashFieldExpireAtMs(expire_arg, time_mode, now_ms, expire_at_ms).IsOK()) {
return Status::OK();
}
return {Status::RedisParseErr, "invalid expire time"};
}

@git-hulk git-hulk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

see comment

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.

5 participants