Coordination in one command

Use cases

Ruvio speaks RESP, so any Redis client can talk to it. What sets it apart is a set of commands that finish a coordination job on the shard that owns the key, in one call. Below are the jobs people reach for, with the commands as you would type them in redis-cli and a link to the reference.

Rate limits and quotas

A fixed window per API key, per user, or per tenant, in one command on the shard that owns the key.

LIMIT key max window_ms returns the remaining allowance, or a null bulk when the window is full. A rejected call is not counted. The window is the key's deadline, so a quiet key disappears by itself. In WAL-backed modes the accepted call is a log record.

Keep in mindFixed window, not sliding. If you need per-second smoothing on top, keep a short second window next to the long one.

ReferenceLIMIT

.NET packageRuvio.AspNetCore.RateLimiting

LIMIT api:key-7 100 60000
# 99    -> 99 calls left in this 60 s window
# (nil) -> window is full, nothing was counted

Locks, leader election and fencing

One worker at a time holds a named resource; others get a null reply until it frees or expires.

Each grant returns a token that grows. Send it with writes to the thing you protect and have that side reject older tokens. Expiry and RELEASE keep the sequence while the key exists.

Keep in mindDeleting the key restarts the token at 1, and data loss can do the same. A token alone does not fence an external system after that.

ReferenceLEASE and RELEASE

.NET packageRuvio.AspNetCore.DistributedLock

LEASE job:nightly-report 30000
# 17    -> you hold it, token 17
# (nil) -> someone else holds it
RELEASE job:nightly-report 17
# 1     -> released (0 if the token no longer matches)

Concurrency limits for workers

At most N holders at once: image resizers, export jobs, calls to a slow upstream.

Every holder has its own TTL, so a crashed worker frees its slot when the TTL ends. The key is removed when the last holder leaves.

Keep in mindPick a TTL longer than the longest normal job, and release as soon as you finish.

ReferenceSEMAPHORE

SEMAPHORE upstream:billing 8 15000
# 1700000000123 -> a slot, this is your token
# (nil)         -> all 8 slots are taken
RELEASE upstream:billing 1700000000123

Inventory, tickets and seat counts

Sell from a counter without overselling, with no script and no read-then-write race.

TAKE subtracts only when the key holds enough. INCRBY … MAX adds only while the result stays at or below the cap. Both answer with a null bulk and write nothing when they refuse.

Keep in mindA refused call writes nothing, neither to the key nor to the WAL.

ReferenceTAKEINCRBY MAX

SET stock:ticket-42 500
TAKE stock:ticket-42 2
# 498   -> took 2, 498 left
# (nil) -> not enough, the key is unchanged

INCRBY seats:room-9 1 MAX 120
# 57    -> joined
# (nil) -> room is full, nothing was written

Idempotent APIs and payments

A retry of the same request replays the stored answer instead of running the handler again.

BEGIN answers acquired, pending, conflict (same key, different fingerprint), or completed with the saved result. For plain once-only work such as a webhook delivery id, ONCE key ttl_ms is the lighter tool: 1 the first time, 0 until the deadline.

Keep in mindThis does not make an outside effect exactly-once. A handler can crash before COMPLETE, so the payment provider still needs its own idempotency key.

ReferenceIDEMONCE

.NET packageRuvio.AspNetCore.Idempotency

IDEM order:42 BEGIN request-digest attempt-1 60000
# ["acquired"]                 -> run the handler once
IDEM order:42 COMPLETE request-digest attempt-1 saved-result
# 1
IDEM order:42 BEGIN request-digest attempt-2 60000
# ["completed","saved-result"]

Lost-update protection

Two editors open the same record; only the one holding the current version may save.

Optimistic concurrency without WATCH or a script. Only keys written with SETV carry a version.

Keep in mindA plain SET replaces a versioned string.

ReferenceSETV

SETV profile:ada "{...}"
# 1     -> version 1 created
SETV profile:ada "{...}" 1
# 2     -> accepted, now version 2
SETV profile:ada "{...}" 1
# error -> stale version, value unchanged

Sessions and sliding expiry

Reading a session keeps it alive; an idle one expires on its own.

GETEX returns the string and moves its deadline in one step, so there is no separate EXPIRE call. GET does not move a deadline.

ReferenceGETEX

.NET packagesRuvio.Extensions.CachingRuvio.AspNetCore.DataProtection

SET session:9f3 "{...}" PX 1800000
GETEX session:9f3 1800000
# "{...}" and the deadline is now 30 min from now

Cache fills without stampedes

When a hot key expires, one caller refills it and the others wait or serve the stale copy.

One round trip decides who loads from the database. WAIT holds a caller that would get BUSY until the filler writes, so there is no retry loop in the client.

Keep in mindFill markers live in memory only and lapse after fill_ms. WAIT is refused inside MULTI and scripts.

ReferenceFILLGET and FILLSET

.NET packagesRuvio.Extensions.CachingRuvio.AspNetCore.OutputCaching

FILLGET product:42 5000 WAIT 200
# [HIT, value]          -> served from memory
# [FILL, token]         -> you refill it
# [BUSY, nil]           -> someone else is filling
# [STALE, value, token] -> stale copy, and you refresh it (SWR window)
# [STALE, value, nil]   -> stale copy, another caller is refreshing
FILLSET product:42 "{...}" 60000 <token> SWR 30000
# 1 -> stored (0 if the token is no longer live)

Delayed jobs and queues

Schedule work for later with a stream and a consumer group, without a separate scheduler.

The entry is stored right away. XREADGROUP skips it until the delay passes, and XREAD can still see it. Pending entries, XCLAIM and XAUTOCLAIM work as in Redis streams.

Keep in mindThe hold is a WAL record and is written only when a delay is set.

ReferenceXADD DELAY

XGROUP CREATE jobs workers $ MKSTREAM
XADD jobs DELAY 30000 * kind send-reminder user 42
XREADGROUP GROUP workers w1 COUNT 10 BLOCK 5000 STREAMS jobs >
# nil until the 30 s delay has passed; the worker keeps reading in a loop
XACK jobs workers <id>

Change feeds

React to writes (cache invalidation, search indexing, audit) by reading a feed instead of polling.

The durable feed is built from the WAL and can resume from a saved position, with optional key patterns, types and values. CHANGES START is the lighter in-memory feed of up to 1024 notes per shard.

Keep in mindThe stream needs a durability profile with a WAL and is refused on a replica. It takes over its connection, so give it a connection of its own. Limits and retention are under Change stream in the configuration reference.

ReferenceCHANGESChange stream settings

CHANGES SUBSCRIBE FROM NOW MATCH user:* TYPES put,del
# a durable stream of changes, resumable with FROM <shard>:<seq>

Leaderboards, geo and documents

The data types you already use, plus JSON documents with an exact-match index.

Strings, lists, sets, hashes, sorted sets, streams, geo, Bloom filters and JSON are covered. JSON.INDEX is an exact-equality index on one scalar field; it works on database 0 and keeps its postings in memory.

ReferenceData typesCommands

ZADD leaderboard 1200 ada 980 grace
ZRANGE leaderboard 0 9 REV WITHSCORES
# cities was filled earlier with GEOADD
GEOSEARCH cities FROMLONLAT 13.36 38.11 BYRADIUS 50 km COUNT 10
JSON.SET user:7 $ '{"plan":"pro","country":"TR"}'
JSON.INDEX CREATE by_plan PREFIX user: PATH $.plan TYPE string
JSON.FIND by_plan VALUE pro COUNT 20
# ERR index is building -> the first build runs in the background; retry

Moving from Redis

Ruvio is not a full Redis clone. It speaks RESP2 and covers the commands listed in the command reference. Check that the commands you use are there before you switch.

ruvio-ctl import copies keys with their TTLs from a running Redis and can verify the copy. It does not move consumer groups, Lua scripts, ACL users or module types.

Start with Get started, then pick a client. For durability modes and what each one promises, see Configuration and Recovery.

ReferenceImportCompatibilityCommands

Try it on your machine

One Docker command or a prebuilt binary, then redis-cli. Or try three of these commands in the browser.