Skip to content

Add INCREX command for atomic increment with ttl and bounds#15045

Merged
sundb merged 60 commits into
redis:unstablefrom
raffertyyu:addIncrex
May 11, 2026
Merged

Add INCREX command for atomic increment with ttl and bounds#15045
sundb merged 60 commits into
redis:unstablefrom
raffertyyu:addIncrex

Conversation

@raffertyyu

@raffertyyu raffertyyu commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Close #14278

Overview

Rate limiters, sliding windows, request counters, and numerous other network-facing patterns share a common primitive: atomically increment a counter and set its expiration. Achieving this in Redis requires either multiple round-trips or a Lua script that bundles INCR / INCRBY / INCRBYFLOAT with EXPIRE / PEXPIRE.

We propose a new command, INCREX, that collapses this two-step pattern into a single, native, O(1) command. INCREX atomically:

  1. Increments (or decrements) a key's numeric value — by integer or float.
  2. Optionally enforces lower and/or upper bounds, with a configurable overflow policy (error out, saturate, or no-op), enabling built-in cap enforcement (e.g., max request count) without additional client logic.
  3. Optionally sets or removes the key's expiration.
  4. Returns both the new value and the actual increment applied, giving the caller immediate feedback on whether the operation was saturated or skipped.

Use Cases

Basic Usage

# Increment by 1 (default) and set a 60-second TTL.
> SET mykey 10
> INCREX mykey EX 60
1) (integer) 11        # new value
2) (integer) 1         # actual increment

# Use 0 as initial value if the key doesn't exist.
> DEL mykey
> INCREX mykey
1) (integer) 1         # new value
2) (integer) 1         # actual increment

# Default policy (OVERFLOW FAIL): exceeding a bound returns an error.
> SET mykey 5
> INCREX mykey BYINT 20 UBOUND 10
(error) value is out of bounds

# Opt into saturation with OVERFLOW SAT.
> INCREX mykey BYINT 20 UBOUND 10 OVERFLOW SAT
1) (integer) 10     # saturated to upper bound
2) (integer) 5      # only 5 was actually applied

# Skip the operation with OVERFLOW REJECT — the key and its TTL are
# untouched, and the reply reports the current value with a zero delta.
> SET mykey 5
> INCREX mykey BYINT 20 UBOUND 10 OVERFLOW REJECT
1) (integer) 5      # current (unchanged) value
2) (integer) 0      # nothing was applied

# Increment by a float
> SET mykey 1
> INCREX mykey BYFLOAT 0.5
1) "1.5"
2) "0.5"

Use Case: Rate Limiter

Before (Lua script):

-- KEYS[1] = rate limit key, ARGV[1] = limit, ARGV[2] = window in seconds
local current = redis.call('INCR', KEYS[1])
if current > tonumber(ARGV[1]) then
    return 0  -- rejected
end
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return 1  -- allowed

Client invocation:

result = redis.eval(LUA_SCRIPT, 1, f"ratelimit:{user_id}", 100, 60)
if result == 0:
    reject_request()

After (INCREX):

new_val, actual_incr = redis.execute_command(
    "INCREX", f"ratelimit:{user_id}", "UBOUND", 100, "OVERFLOW", "REJECT", "EX", 60, "ENX"
)
if actual_incr == 0:
    # Rate limit exceeded — key left unchanged.
    reject_request()

ENX means: set expiration only if the key doesn't already have an expiration. This ensures the sliding window's TTL is only set on the first request.

Use Case: Token Bucket Refill

Refill tokens periodically up to a capacity ceiling, saturating at the cap instead of erroring:

> INCREX tokens:user123 BYINT 10 UBOUND 100 OVERFLOW SAT EX 3600 ENX
1) (integer) 10
2) (integer) 10

Tokens cannot exceed 100, and the key auto-expires after inactivity.

Use Case: Countdown / Resource Consumption

Decrement a resource counter down to zero, saturating at the floor:

> SET credits:user123 50
> INCREX credits:user123 BYINT -1 LBOUND 0 OVERFLOW SAT
1) (integer) 49
2) (integer) -1

When credits are exhausted, OVERFLOW SAT prevents negative balances without client-side checks.

Parameter Reference

Syntax

INCREX key
      [BYFLOAT increment | BYINT increment]
      [LBOUND lowerbound] [UBOUND upperbound] [OVERFLOW <FAIL | SAT | REJECT>]
      [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | PERSIST] [ENX]

Parameters

Parameter Description
key The key to increment. Created with value 0 if it does not exist.
BYFLOAT increment Increment the value by the given long-double float.
BYINT increment Increment the value by the given 64-bit signed integer.
LBOUND lowerbound Set lower bound for the increment result. Defaults to LLONG_MIN (integer) or -LDBL_MAX (float).
UBOUND upperbound Set upper bound for the increment result. Defaults to LLONG_MAX (integer) or LDBL_MAX (float).
OVERFLOW <FAIL | SAT | REJECT> Set the overflow policy when the result would be out of bounds. FAIL rejects the operation with an error (default). SAT saturates the result to the bound. REJECT leaves the key and its TTL untouched and replies with the current value and a zero delta.
EX seconds Set the key's TTL to seconds seconds.
PX milliseconds Set the key's TTL to milliseconds milliseconds.
EXAT unix-time-seconds Set the key's expiration to the absolute Unix timestamp in seconds.
PXAT unix-time-milliseconds Set the key's expiration to the absolute Unix timestamp in milliseconds.
PERSIST Remove the key's existing TTL.
ENX Set the key's TTL/expiration if it has No eXpiration

If neither BYINT nor BYFLOAT is specified, the increment defaults to integer 1.

Return Value

An array of two elements:

  1. New value — the value of the key after the increment (or the unchanged current value under OVERFLOW REJECT).
  2. Actual increment — the increment that was actually applied. May differ from the requested increment when OVERFLOW SAT saturates the result to a bound, and is always 0 when OVERFLOW REJECT skipped the operation.
  • In integer mode (default or BYINT): both elements are integers.
  • In float mode (BYFLOAT): both elements are bulk strings representing the float values on RESP2, and RESP3 Doubles on RESP3.

Overflow Policy (FAIL vs. SAT vs. REJECT)

Controlled by the optional OVERFLOW argument. A bound violation includes both exceeding an explicit LBOUND/UBOUND and overflowing the type limits when no explicit bound is given. The same policy also applies when the stored value is already out of bounds before the increment is applied (e.g. the key was set directly via SET, or the bounds have been tightened since the previous write).

  • OVERFLOW FAIL (default): if the computed result would violate a bound — or if the existing value is already out of bounds — the command returns an error and the key is left unchanged. This matches the existing semantics of INCRBY / INCRBYFLOAT on overflow.
  • OVERFLOW SAT: the result is silently capped at UBOUND / floored at LBOUND (or saturated to the type limits when no explicit bound is given). If the stored value is already out of bounds, it is still saturated to the nearest bound (LBOUND or UBOUND) — the second element of the reply reflects the actual delta that was applied to reach the bound, which may be larger in magnitude than the requested increment. If the delta cannot be represented as a 64-bit signed integer (default or BYINT), or would produce Infinity (BYFLOAT), an error is returned.
  • OVERFLOW REJECT: the operation is silently skipped — the key value and its TTL are left unchanged, no keyspace notification is fired, and nothing is replicated. This also applies when the stored value is already out of bounds: the original value is preserved untouched. The reply is [current_value, 0], allowing the caller to detect the rejection without handling an error.

Notes

  • If no expiration option is given, the key's existing TTL is preserved (like INCR).
  • ENX requires one of EX/PX/EXAT/PXAT.
  • If the result is saturated by OVERFLOW SAT, the expiration is still applied as specified.
  • Under OVERFLOW REJECT the expiration option is ignored on the rejected branch — TTL is preserved exactly as it was before the call.
  • BYINT requires an integer-typed existing value; BYFLOAT accepts both.
    Integers can be promoted to floats losslessly, but a stored float (e.g. "1.5") cannot be parsed back as an integer. This is consistent with INCR/INCRBY (integer-only) and INCRBYFLOAT (accepts both).

Note

Medium Risk
Adds a new write command with non-trivial numeric/TTL semantics and replication rewriting, and slightly refactors shared expiration parsing used by existing string commands, so regressions could affect counter or TTL behavior.

Overview
Adds a new INCREX string command that atomically increments a numeric key (integer or float) and can optionally apply bounds (LBOUND/UBOUND) with selectable overflow behavior (FAIL, SAT, REJECT) while also updating, preserving, or removing TTL (EX/PX/EXAT/PXAT/PERSIST plus ENX).

Updates the command tables/docs (commands.def, new commands/increx.json) and exposes increxCommand in server.h, implements the command in t_string.c including deterministic replication rewrite to SET (or DEL/UNLINK for already-elapsed expirations), and adds portable integer overflow helpers in util.h plus an extensive new unit test suite (tests/unit/type/increx.tcl).

Refactors getExpireMillisecondsOrReply to take an explicit relative vs absolute TTL boolean and updates call sites to avoid misinterpreting EXAT/PXAT as relative expirations.

Reviewed by Cursor Bugbot for commit 74f84be. Bugbot is set up for automated code reviews on this repo. Configure here.

@jit-ci

jit-ci Bot commented Apr 14, 2026

Copy link
Copy Markdown

Hi, I’m Jit, a friendly security platform designed to help developers build secure applications from day zero with an MVS (Minimal viable security) mindset.

In case there are security findings, they will be communicated to you as a comment inside the PR.

Hope you’ll enjoy using Jit.

Questions? Comments? Want to learn more? Get in touch with us.

Comment thread src/t_string.c Outdated
Comment thread src/t_string.c
@sundb sundb added this to Redis 8.8 Apr 15, 2026
@raffertyyu raffertyyu force-pushed the addIncrex branch 3 times, most recently from 310432d to 95be3a9 Compare April 15, 2026 02:35
Comment thread src/t_string.c Outdated
Comment thread src/commands.def Outdated
Comment thread src/t_string.c Outdated
Comment thread src/t_string.c
Comment thread src/commands/increx.json
Comment thread src/commands/increx.json Outdated
Comment thread src/commands/increx.json Outdated
Comment thread src/commands/increx.json
Comment thread src/t_string.c Outdated
Comment thread src/commands/increx.json Outdated
Comment thread src/t_string.c Outdated
Comment thread src/t_string.c Outdated
Comment thread tests/unit/type/increx.tcl Outdated
Comment thread src/t_string.c Outdated
Comment thread src/t_string.c Outdated
Comment thread src/t_string.c Outdated
Comment thread src/t_string.c Outdated
Comment thread src/t_string.c Outdated
@sundb sundb added release-notes indication that this issue needs to be mentioned in the release notes state:needs-doc-pr requires a PR to redis-doc repository labels Apr 20, 2026
Comment thread src/t_string.c Outdated
Comment thread src/commands/increx.json Outdated
Comment thread tests/unit/type/increx.tcl Outdated
Comment thread tests/unit/type/increx.tcl
Comment thread src/t_string.c Outdated
Comment thread src/commands/increx.json Outdated
Comment thread src/commands/increx.json Outdated
Comment thread src/commands/increx.json Outdated
@sundb

sundb commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

@raffertyyu can I help you work on this PR?

Comment thread src/t_string.c Outdated
Comment thread src/t_string.c Outdated
@raffertyyu

Copy link
Copy Markdown
Contributor Author

@sundb Sorry for the delay. I spent some time discussing with my colleagues what exactly need from this INCREX command.
We’ve reached a consensus that the current combination of INCR/INCRBY + EXPIRE (or INCRBYFLOAT + EXPIRE) already covers all needs. We also believe that integer-specific parsing and storage offer unique performance advantages and precision benefits.
Therefore, we’ve designed the logic to mirror existing Redis behavior:

  • Default (no flags): Behaves like INCR.
  • With BYINT: Behaves like INCRBY.

This two cases will throw an error if the value or increment is not an integer.

  • With BYFLOAT: Behaves like INCRBYFLOAT, supporting both integers and floats.

We think this keeps command simple and efficient. Users should have a clear understanding of their data types. If they truly need floating-point support, they should explicitly use BYFLOAT rather than relying on the system to implicitly convert values, which could lead to a loss of precision.

Please feel free to modify my PR as you see fit. I spent quite a bit of time debugging the refcount mechanism during increx command execution to ensure everything is handled correctly, and I’m open to any further optimizations.

Comment thread src/commands/increx.json
Comment thread src/t_string.c Outdated
Comment thread src/t_string.c Outdated
Comment thread src/t_string.c Outdated
Comment thread src/t_string.c Outdated
Comment thread src/t_string.c Outdated
Comment on lines +970 to +974
if (value < lb) {
value = lb;
} else if (value > ub) {
value = ub;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

if the old value is larger than UBOUND, now the old value will be reduced to UBOUND, i think the old value should remain unchanged.
CC @LiorKogan

127.0.0.1:6379> set mykey 1.5
OK
127.0.0.1:6379> increx mykey BYFLOAT 100 UBOUND 1
1) "1"
2) "-0.5"

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.

ok, let's keep the value unchanged on overflow - instead of increasing/decreasing up to the bound.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@LiorKogan there is another case, for example:

upper = 100
old value = 200

What is the new value after increx key 100 BYINT, keep the original value(200) or 300?

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.

If this is what you mean:
For old value = 200, and INCREX key 100 BYINT UBOUND 100

I would expect the increment to be denied, hence the value should remain as is (200).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

For old value = 200, and INCREX key -50 BYINT UBOUND 100
How is it now? 200 or 150?
Personally, I find it all very confusing and it doesn't seem to work well.
@oranagra pls share your thoughts.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reject the request (which in this case means: don't create the key!)

do you mean replying to an error message?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Another issue with the current implementation: if key doesn't exist and increment result is out-of-bounds, a zero-value key is still created with an TTL/expiration. I agree with not create new key

@LiorKogan LiorKogan Apr 29, 2026

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.

do you mean replying to an error message?

Yes (if the key doesn't exist and we follow behavior 2.).

If the doesn't exist and we follow behavior 1 - we should create the key with the value bounded:

  • INCREX key LBOUND 100 UBOUND 200 - create the key with value 100
  • INCREX key BYINT 500 LBOUND 100 UBOUND 200 - create the key with value 200

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.

the question i ask myself first, is if either of these cases can actually happen for user that properly uses this command for the use cases we aim to handle (in which case we need to make it useful), or is that just an edge case that in reality can be solved either way and it doesn't really matters.

i think it's clear that the case of an existing value being out of bound probably won't happen, unless maybe someone decides to change the bounds mid-run, in which case we should clamp.

the other case of a non-existing key and a zero being out of bounds is also probably not a real one, in which case we could error, but considering the other case needs to be solved by clamping, maybe we should align them.

WTYT?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i think it's clear that the case of an existing value being out of bound probably won't happen, unless maybe someone decides to change the bounds mid-run, in which case we should clamp.

Agreeing to this approach and keeping the original value unchanged if out of bound has already confused me while implementing it.

@sundb sundb requested a review from tezc May 9, 2026 04:07
@sundb

sundb commented May 9, 2026

Copy link
Copy Markdown
Collaborator

@LiorKogan I suddenly realized that we have a similar syntax

BITFIELD key ... [OVERFLOW <WRAP | SAT | FAIL>] 

Should we synchronize ONBOUND with it?

@LiorKogan

Copy link
Copy Markdown
Member

BITFIELD key ... [OVERFLOW <WRAP | SAT | FAIL>]
Should we synchronize ONBOUND with it?

@sundb yes, definitely.

Comment thread src/commands.def Outdated
Comment thread src/t_string.c Outdated

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit e96bd89. Configure here.

Comment thread src/t_string.c
@sundb sundb merged commit 9c1ecd0 into redis:unstable May 11, 2026
19 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Redis 8.8 May 11, 2026
sundb added a commit that referenced this pull request May 20, 2026
Follow #15045

## Summary

Simplify INCREX's out-of-bounds policy:

The original INCREX shipped with three out-of-bounds policies — OVERFLOW
FAIL, OVERFLOW SAT, OVERFLOW REJECT — but FAIL and REJECT are
functionally redundant: both leave the key untouched when the result is
out of bounds. They differ only in how the caller is notified (error
reply vs. [current_value, 0] array reply), which forces the user to make
a stylistic choice with no real semantic difference.

This PR collapses the three policies into one clear behavior:

* Default: the operation is rejected; the key value and TTL are left
unchanged, and the reply is [current_value, 0]. Callers detect
non-application by checking the applied-increment field; no
error-handling branch is required.
* SATURATE: the result is saturated to UBOUND / LBOUND, or to the type
limits (LLONG_MAX/MIN for BYINT, ±LDBL_MAX for BYFLOAT) when no explicit
bound is given.

New syntax:

    INCREX <key> [BYFLOAT increment | BYINT increment]
                 [LBOUND lowerbound] [UBOUND upperbound] [SATURATE]
[EX seconds | PX milliseconds | EXAT seconds-timestamp | PXAT
milliseconds-timestamp | PERSIST] [ENX]

---------

Co-authored-by: Ozan Tezcan <ozantezcan@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-notes indication that this issue needs to be mentioned in the release notes state:needs-doc-pr requires a PR to redis-doc repository

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[NEW] Add a new command increx

6 participants