Seven small scripts I no longer write

Moving credit between wallets, selling the last ticket, keeping the newest timestamp. Each of these used to be a short Lua script. In Ruvio each one is a single command that either does the whole job or writes nothing.

Salih Cantekin · · 5 min read

account:aaccount:b1002070503090?✕ not enoughTRANSFER a b 30 …Moved in one atomic step: a 70, b 50.TRANSFER a b 90 is refused. Balances stay put.
Balances that cannot go negativeTRANSFER moves an amount between two keys only when the source holds enough.

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 changed

ClientsRustC#GoJavaPythonNode.js

Both 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 unchanged

ClientsRustC#GoJavaPythonNode.js

The 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 hour

ClientsRustC#GoJavaPythonNode.js

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 wait

ClientsRustC#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.

ClientLIMITApptokens leftrejectedRequests pass while tokens last.Bucket empty: the next requests are rejected.Refilled: requests pass again.
Rate limits in one commandLIMIT does the fixed-window, sliding-window or token-bucket check atomically on the shard.

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 mismatch

ClientsRustC#GoJavaPythonNode.js

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.