Every project I have worked on that used a Redis-style store ended up with a folder of tiny Lua scripts. They were never complicated. Read a number, compare it with something, write one or two keys, return. But each of them had to be loaded, called by its SHA, kept in step with the application code, and explained to the next person who opened the folder.
When I started Ruvio I went through that folder and asked a plain question for each script: is this a command that nobody has written yet? Surprisingly often the answer was yes. This post is the list of the ones that made it into the server, with the exact replies you get back. Everything below was run against the 0.4.0 release.
- 1
- WAL record for both sides of a TRANSFER
- nil
- the answer when a command refuses; nothing was written
- 0
- scripts to load, version or keep in sync with the code
1. Move money only if there is enough
The classic one. Take 30 credits from a wallet and put them in escrow, but only if the wallet holds at least 30. Nobody should ever see the money in both places, or in neither.
SET {user:42}:wallet 50
TRANSFER {user:42}:wallet {user:42}:escrow 30
# 1) 20 -> wallet after
# 2) 30 -> escrow after
TRANSFER {user:42}:wallet {user:42}:escrow 30
# (nil) -> only 20 left, nothing changeduse ruvio_client::Client;
let mut db = Client::connect("127.0.0.1", 6379)?;
db.set("{user:42}:wallet", "50")?;
match db.transfer("{user:42}:wallet", "{user:42}:escrow", 30, None)? {
Some((wallet, escrow)) => println!("{wallet} left, {escrow} held"), // 20 left, 30 held
None => println!("not enough, nothing changed"),
}
let held = db.htransfer("account:7", "available", "held", 30, Some(1000))?;
// Some((available_after, held_after)), or None when it was refusedawait using var connection = await RuvioConnection.ConnectAsync("127.0.0.1", 6379);
var db = await connection.GetDefaultDatabaseAsync();
await db.SetAsync("{user:42}:wallet", "50");
var moved = await db.TransferAsync("{user:42}:wallet", "{user:42}:escrow", 30);
// (Source: 20, Destination: 30)
// null -> not enough, nothing changed
var held = await db.TransferHashFieldAsync("account:7", "available", "held", 30, max: 1000);
// (From: 20, To: 30), or null when it was refusedc, err := ruvio.Connect(ctx, ruvio.Options{Addr: "127.0.0.1:6379"})
if err != nil { return err }
if err := c.Set(ctx, "{user:42}:wallet", "50"); err != nil { return err }
wallet, escrow, ok, err := c.Transfer(ctx, "{user:42}:wallet", "{user:42}:escrow", 30)
if err != nil { return err }
fmt.Println(wallet, escrow, ok) // 20 30 true; 0 0 false -> not enough, nothing changed
available, held, ok, err := c.HTransferMax(ctx, "account:7", "available", "held", 30, 1000)var c = RuvioClient.connect("127.0.0.1", 6379);
c.set("{user:42}:wallet", "50");
List<Long> after = c.transfer("{user:42}:wallet", "{user:42}:escrow", 30);
// [20, 30]
// null -> not enough, nothing changed
List<Long> held = c.htransfer("account:7", "available", "held", 30, 1000);
// [available after, held after], or null when it was refusedimport ruvio
r = ruvio.Ruvio("127.0.0.1", 6379)
r.set("{user:42}:wallet", 50)
after = r.transfer("{user:42}:wallet", "{user:42}:escrow", 30)
# (20, 30)
# None -> not enough, nothing changed
held = r.htransfer("account:7", "available", "held", 30, max=1000)
# (available after, held after), or None when it was refusedimport { createClient } from 'ruvio-client';
const c = createClient({ host: '127.0.0.1', port: 6379 });
await c.set('{user:42}:wallet', 50);
const after = await c.transfer('{user:42}:wallet', '{user:42}:escrow', 30);
// [20, 30]
// null -> not enough, nothing changed
const held = await c.htransfer('account:7', 'available', 'held', 30, 1000);
// [available after, held after], or null when it was refusedBoth balances change in one WAL record, so a crash cannot leave you with only half of the move on disk.
When the balances are fields of one hash, HTRANSFER account:7 available held 30 MAX 1000 does the same between two fields and also refuses if the destination would pass the cap.
2. Sell the last ticket once
DECRBY happily goes below zero, so the usual fix is a script that reads the stock first. TAKE subtracts only when the key holds enough:
SET stock:ticket-42 500
TAKE stock:ticket-42 2
# 498 -> took 2, 498 left
TAKE stock:ticket-42 1000
# (nil) -> not enough, the key is unchangeduse ruvio_client::Client;
let mut db = Client::connect("127.0.0.1", 6379)?;
db.set("stock:ticket-42", "500")?;
let left = db.take("stock:ticket-42", 2)?;
// Some(498) -> took 2, 498 left
// None -> not enough, the key is unchanged
let seats = db.incr_by_max("seats:room-9", 1, 120)?;
// Some(57) -> joined
// None -> room is full, nothing was writtenawait using var connection = await RuvioConnection.ConnectAsync("127.0.0.1", 6379);
var db = await connection.GetDefaultDatabaseAsync();
await db.SetAsync("stock:ticket-42", "500");
long? left = await db.TakeAsync("stock:ticket-42", 2);
// 498 -> took 2, 498 left
// null -> not enough, the key is unchanged
long? seats = await db.IncrementWithLimitAsync("seats:room-9", 1, 120);
// 57 -> joined
// null -> room is full, nothing was writtenc, err := ruvio.Connect(ctx, ruvio.Options{Addr: "127.0.0.1:6379"})
if err != nil { return err }
if err := c.Set(ctx, "stock:ticket-42", "500"); err != nil { return err }
left, took, err := c.Take(ctx, "stock:ticket-42", 2)
if err != nil { return err }
fmt.Println(left, took) // 498 true -> took 2; 0 false -> not enough, the key is unchanged
seats, joined, err := c.IncrByMax(ctx, "seats:room-9", 1, 120)
if err != nil { return err }
fmt.Println(seats, joined) // 57 true -> joined; 0 false -> room is full, nothing was writtenvar c = RuvioClient.connect("127.0.0.1", 6379);
c.set("stock:ticket-42", "500");
Long left = c.take("stock:ticket-42", 2);
// 498 -> took 2, 498 left
// null -> not enough, the key is unchanged
Long seats = c.incrbyMax("seats:room-9", 1, 120);
// 57 -> joined
// null -> room is full, nothing was writtenimport ruvio
r = ruvio.Ruvio("127.0.0.1", 6379)
r.set("stock:ticket-42", 500)
left = r.take("stock:ticket-42", 2)
# 498 -> took 2, 498 left
# None -> not enough, the key is unchanged
seats = r.incrby("seats:room-9", 1, max=120)
# 57 -> joined
# None -> room is full, nothing was writtenimport { createClient } from 'ruvio-client';
const c = createClient({ host: '127.0.0.1', port: 6379 });
await c.set('stock:ticket-42', 500);
const left = await c.take('stock:ticket-42', 2);
// 498 -> took 2, 498 left
// null -> not enough, the key is unchanged
const seats = await c.incrbyMax('seats:room-9', 1, 120);
// 57 -> joined
// null -> room is full, nothing was writtenThe mirror image is a counter with a ceiling, for seats in a room or members in a team:
INCRBY seats:room-9 1 MAX 120
# 57 -> joined
# (nil) -> room is full, nothing was written
</code></pre>
<!-- scene: inventory -->
<h2 id="3-a-daily-counter-that-cleans-up-after-itself">3. A daily counter that cleans up after itself</h2>
<p>This one is so small it hurts. You want a per-day counter that disappears tomorrow. With plain <code>INCR</code> you need a second <code>EXPIRE</code> call, and if you only want to set the deadline when the key is created, you are back to a script or a <code>MULTI</code> block.</p>
<pre><code class="language-redis">INCR hits:user-7:2026-10-29 EX 86400
# 1 -> first hit, the day's deadline is set
# 2, 3… -> later hits leave the deadline alone
</code></pre>
<p><code>EX</code> and <code>PX</code> on <code>INCR</code> and <code>INCRBY</code> only apply when the call creates the counter. Later calls do not push the deadline forward.</p>
<h2 id="4-keep-the-newest-value-whatever-order-writers-arrive-in">4. Keep the newest value, whatever order writers arrive in</h2>
<p>Event consumers that run in parallel love to write an older watermark after a newer one. <code>IFGT</code> and <code>IFLT</code> on <code>SET</code> compare against the stored integer and only write when the new number wins:</p>
<pre><code class="language-redis">SET watermark:orders 1700000200 IFGT
# OK -> written
SET watermark:orders 1700000100 IFGT
# (nil) -> stored number is greater, nothing written
SET price:best 499 IFLT PX 3600000
# OK -> new lowest price, expires in an houruse std::time::Duration;
use ruvio_client::Client;
let mut db = Client::connect("127.0.0.1", 6379)?;
let advanced = db.set_if_greater("watermark:orders", 1_700_000_200, None)?;
// true -> written (key was missing or smaller)
// false -> stored number is greater, nothing written
let lowered = db.set_if_less("price:best", 499, Some(Duration::from_secs(3600)))?;await using var connection = await RuvioConnection.ConnectAsync("127.0.0.1", 6379);
var db = await connection.GetDefaultDatabaseAsync();
bool advanced = await db.SetIfGreaterAsync("watermark:orders", 1_700_000_200);
// true -> written (key was missing or smaller)
// false -> stored number is greater, nothing written
bool lowered = await db.SetIfLessAsync("price:best", 499, TimeSpan.FromHours(1));c, err := ruvio.Connect(ctx, ruvio.Options{Addr: "127.0.0.1:6379"})
if err != nil { return err }
advanced, err := c.SetIfGreater(ctx, "watermark:orders", 1700000200, ruvio.SetOptions{})
// true -> written; false -> stored number is greater, nothing written
lowered, err := c.SetIfLess(ctx, "price:best", 499, ruvio.SetOptions{TTL: time.Hour})var c = RuvioClient.connect("127.0.0.1", 6379);
boolean advanced = c.setIfGreater("watermark:orders", 1_700_000_200L);
// true -> written (key was missing or smaller)
// false -> stored number is greater, nothing written
boolean lowered = c.setIfLess("price:best", 499L, SetOptions.px(3_600_000));import ruvio
r = ruvio.Ruvio("127.0.0.1", 6379)
advanced = r.set("watermark:orders", 1700000200, ifgt=True)
# True -> written (key was missing or smaller)
# False -> stored number is greater, nothing written
lowered = r.set("price:best", 499, iflt=True, px=3_600_000)import { createClient } from 'ruvio-client';
const c = createClient({ host: '127.0.0.1', port: 6379 });
const advanced = await c.set('watermark:orders', 1700000200, { ifGt: true });
// 'OK' -> written (key was missing or smaller)
// null -> stored number is greater, nothing written
const lowered = await c.set('price:best', 499, { ifLt: true, px: 3_600_000 });5. A rate limit in one round trip
Fixed window, sliding window and token bucket are all LIMIT. The reply is the allowance that is left, and a refused call is not counted:
LIMIT api:key-7 3 60000
# 2
# 1
# 0
# (nil) -> window is full, this call was not counted
LIMIT api:key-7:burst 10 1000 TOKENS COST 2 WITHRETRY
# 1) 8 -> 8 tokens left
# 2) 0 -> no need to waituse ruvio_client::Client;
let mut db = Client::connect("127.0.0.1", 6379)?;
let left = db.limit("api:key-7", 100, 60_000)?;
// Some(99) -> 99 calls left in this 60 s window
// None -> window is full, nothing was counted
// Smoother: sliding window, or a token bucket that tells you how long to wait
let sliding = db.limit_sliding("api:key-7", 100, 60_000)?;
let bucket = db.limit_tokens_with_retry("api:key-7:burst", 10, 1_000, 2)?;
// bucket.allowed, bucket.remaining, bucket.retry_after_msawait using var connection = await RuvioConnection.ConnectAsync("127.0.0.1", 6379);
var db = await connection.GetDefaultDatabaseAsync();
long? left = await db.LimitAsync("api:key-7", 100, 60_000);
// 99 -> 99 calls left in this 60 s window
// null -> window is full, nothing was counted
// Smoother: sliding window, or a token bucket that tells you how long to wait
long? sliding = await db.LimitSlidingAsync("api:key-7", 100, 60_000);
LimitOutcome bucket = await db.LimitTokensWithRetryAsync("api:key-7:burst", 10, 1_000, 2);c, err := ruvio.Connect(ctx, ruvio.Options{Addr: "127.0.0.1:6379"})
if err != nil { return err }
left, ok, err := c.Limit(ctx, "api:key-7", 100, time.Minute)
if err != nil { return err }
fmt.Println(left, ok)
// 99 true -> 99 calls left in this 60 s window
// 0 false -> window is full, nothing was counted
// Smoother: sliding window, or a token bucket that tells you how long to wait
left, ok, err = c.LimitSliding(ctx, "api:key-7", 100, time.Minute)
bucket, err := c.LimitTokensWithRetry(ctx, "api:key-7:burst", 10, time.Second, 2)
// bucket.Allowed, bucket.Remaining, bucket.RetryAftervar c = RuvioClient.connect("127.0.0.1", 6379);
Long left = c.limit("api:key-7", 100, 60_000);
// 99 -> 99 calls left in this 60 s window
// null -> window is full, nothing was counted
// Smoother: sliding window, or a token bucket that tells you how long to wait
Long sliding = c.limitSliding("api:key-7", 100, 60_000);
LimitOutcome bucket = c.limitTokensWithRetry("api:key-7:burst", 10, 1_000, 2);import ruvio
r = ruvio.Ruvio("127.0.0.1", 6379)
left = r.limit("api:key-7", 100, 60_000)
# 99 -> 99 calls left in this 60 s window
# None -> window is full, nothing was counted
# Smoother: sliding window, or a token bucket that tells you how long to wait
sliding = r.limit_sliding("api:key-7", 100, 60_000)
allowed, remaining, retry_after_ms = r.limit_tokens_with_retry("api:key-7:burst", 10, 1_000, 2)import { createClient } from 'ruvio-client';
const c = createClient({ host: '127.0.0.1', port: 6379 });
const left = await c.limit('api:key-7', 100, 60_000);
// 99 -> 99 calls left in this 60 s window
// null -> window is full, nothing was counted
// Smoother: sliding window, or a token bucket that tells you how long to wait
const sliding = await c.limitSliding('api:key-7', 100, 60_000);
const bucket = await c.limitTokensWithRetry('api:key-7:burst', 10, 1_000, 2);
// bucket.allowed, bucket.remaining, bucket.retryAfterMsbuilder.Services.AddRuvioClient(builder.Configuration.GetSection("Ruvio"));
builder.Services.AddRuvioRateLimiting(options =>
{
options.KeyPrefix = "orders:rate:v1:";
// Default budget owner for every policy that does not name its own: here the tenant.
options.PartitionKey = context => context.User.FindFirst("tenant_id")?.Value ?? "";
// A request without a tenant shares one budget instead of failing. It is never unlimited.
// The default (Throw) treats a blank key as a configuration error.
options.EmptyPartition = RuvioEmptyPartitionMode.SharedBucket;
// 100 requests a minute per tenant (uses options.PartitionKey).
options.AddPolicy("create-order", permits: 100, window: TimeSpan.FromMinutes(1));
// A policy can count something else: this one is per client IP, whatever the tenant is.
options.AddPolicy("login", permits: 5, window: TimeSpan.FromMinutes(1),
partitionKey: context => context.Connection.RemoteIpAddress?.ToString() ?? "");
// Chained limits: a request must pass all of them, in this order. Put the strictest first,
// because a request refused by a later limit has already used a permit of the earlier ones.
options.AddPolicy("search", policy => policy
.Limit(20, TimeSpan.FromSeconds(10),
partitionKey: context => context.Connection.RemoteIpAddress?.ToString() ?? "") // per IP
.Limit(1000, TimeSpan.FromMinutes(1), algorithm: RuvioRateLimitAlgorithm.SlidingWindow) // per tenant
.Concurrency(maxConcurrent: 8, maxRequestDuration: TimeSpan.FromSeconds(30))); // 8 at once per tenant
// Applies to every routed endpoint that has no limit of its own. Opt out with DisableRuvioRateLimit().
options.DefaultPolicy = "create-order";
// Runs after the default 429 and Retry-After are set; change the status or write a body here.
options.OnRejected = async (context, rejection) =>
{
await context.Response.WriteAsJsonAsync(new
{
error = "rate_limited",
policy = rejection.Policy,
retryAfterSeconds = (int)rejection.RetryAfter.TotalSeconds,
});
};
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseRuvioRateLimiting();
app.MapPost("/login", HandleLogin).WithRuvioRateLimit("login");
app.MapGet("/search", HandleSearch).WithRuvioRateLimit("search");
app.MapPost("/orders", HandleOrder).RequireAuthorization(); // limited by DefaultPolicy
app.MapGet("/health", () => "ok").DisableRuvioRateLimit(); // never limitedClientsRustC#GoJavaPythonNode.js
PackagesRuvio.AspNetCore.RateLimiting NuGet
WITHRETRY puts the wait in the reply, so the number for a Retry-After header comes from the server rather than from a guess in the client.
6. Pop the jobs that are due
A sorted set with the run time as the score is the simplest scheduler there is. The awkward part was always “give me what is due and remove it” without two workers picking the same job.
ZADD {jobs}:due 100 job:9 200 job:10 300 job:11
ZPOPBYSCORE {jobs}:due 150 COUNT 10
# job:9 -> due and removed in the same call
ZMOVEDUE {jobs}:due {jobs}:ready 250 COUNT 100
# 1 -> job:10 moved to the tail of the list {jobs}:ready
</code></pre>
<p><code>ZMOVEDUE</code> hands due members to a list queue in one recorded step, up to 10,000 per call. Both keys must share a slot; without the <code>{jobs}</code> tag the server answers <code>CROSSSLOT</code>, as it should. There is a whole post on <a href="/blog/delayed-jobs-without-a-scheduler/">delayed jobs</a> if you want the stream version too.</p>
<h2 id="7-save-only-if-nobody-else-saved-first">7. Save only if nobody else saved first</h2>
<p>Two people open the same profile, both press save. With <code>SETV</code> every write returns a version and a write that names an old version is refused:</p>
<pre><code class="language-redis">SETV profile:ada "v1"
# 1
SETV profile:ada "v2" 1
# 2
SETV profile:ada "v3" 1
# ERR version mismatchuse ruvio_client::Client;
let mut db = Client::connect("127.0.0.1", 6379)?;
db.setv("profile:ada", "{...}", None)?; // 1 -> version 1 created
db.setv("profile:ada", "{...}", Some(1))?; // 2 -> accepted, now version 2
let stale = db.setv("profile:ada", "{...}", Some(1));
// Err(Error::Server("ERR version mismatch")) -> stale version, value unchangedawait using var connection = await RuvioConnection.ConnectAsync("127.0.0.1", 6379);
var db = await connection.GetDefaultDatabaseAsync();
await db.SetVersionAsync("profile:ada", "{...}"); // 1 -> version 1 created
await db.SetVersionAsync("profile:ada", "{...}", 1); // 2 -> accepted, now version 2
try
{
await db.SetVersionAsync("profile:ada", "{...}", 1);
}
catch (RuvioException)
{
// stale version, value unchanged
}c, err := ruvio.Connect(ctx, ruvio.Options{Addr: "127.0.0.1:6379"})
if err != nil { return err }
v, err := c.SetV(ctx, "profile:ada", "{...}", 0) // 1 -> version 1 created (0 sends no version)
if err != nil { return err }
v, err = c.SetV(ctx, "profile:ada", "{...}", 1) // 2 -> accepted, now version 2
if err != nil { return err }
_, err = c.SetV(ctx, "profile:ada", "{...}", 1)
fmt.Println(v, err) // 2 ERR version mismatch -> stale version, value unchangedvar c = RuvioClient.connect("127.0.0.1", 6379);
c.setv("profile:ada", "{...}"); // 1 -> version 1 created
c.setv("profile:ada", "{...}", 1); // 2 -> accepted, now version 2
try {
c.setv("profile:ada", "{...}", 1);
} catch (RuvioServerException e) {
// stale version, value unchanged
}import ruvio
r = ruvio.Ruvio("127.0.0.1", 6379)
r.setv("profile:ada", "{...}") # 1 -> version 1 created
r.setv("profile:ada", "{...}", 1) # 2 -> accepted, now version 2
try:
r.setv("profile:ada", "{...}", 1)
except ruvio.ResponseError:
pass # stale version, value unchangedimport { createClient } from 'ruvio-client';
const c = createClient({ host: '127.0.0.1', port: 6379 });
await c.setv('profile:ada', '{...}'); // 1 -> version 1 created
await c.setv('profile:ada', '{...}', 1); // 2 -> accepted, now version 2
try {
await c.setv('profile:ada', '{...}', 1);
} catch (err) {
// RuvioError: stale version, value unchanged
}No WATCH, no retry loop around MULTI.
What I did not turn into commands
Not every script deserves to become a command. Anything with real business rules, several branches and a dozen keys is still better as code in your application, or as a script if you really need it next to the data. Ruvio runs Lua for exactly that. The commands above made the cut because they are small, they come up in almost every codebase, and they share three properties:
- All or nothingEither the whole thing happens or nothing is written, not to the key and not to the log.
- One recordA refused call leaves no trace, and an accepted one is a single WAL record that replicas apply the same way.
- A typed methodEach one is a method in all six clients, so there is no script text to send and no reply to parse by hand.
There is also a speed side. Every one of these is measured against the script it replaces on the benchmarks page, together with the memory each key takes. I will let the table speak there rather than paste numbers here.
All six first-party clients have a typed method for each command, so in C# the first example is await db.TransferAsync(...) and not a string you send and parse. The tabs under each example show the same steps in all six languages.