Chapter 14 · Lua Scripting and Redis Functions

Replace Multi-Round-Trip Race-Prone Logic with a Small Measured Atomic Function

Replace a race-prone multi-round-trip AtlasMart reservation workflow with one small atomic Function and measure correctness plus latency under bounded concurrent clients.

Advanced170–230 minutesMeasured atomic application logicRedis Open Source 8.10.1redis-py 8.1.0 where usedFree/local-firstLast reviewed: September 6, 2026

Learning outcomes

The chapter closes with a decision, not merely another feature demo. AtlasMart will compare a deliberately race-prone read/branch/write workflow with one small Redis Function that performs the conditional state transition atomically. The goal is to measure correctness and round-trip behavior while keeping the server-side code tiny enough to remain operationally safe.

01

Explain the lost-update/oversell race in a multi-round-trip conditional workflow.

02

Deploy a bounded reserve_order_v1 Function with explicit keys, validation, and one atomic state transition.

03

Stress both approaches with deterministic concurrent clients and record success, rejection, invariant failures, retries, and latency distribution.

04

Separate network-round-trip reduction from server execution time and tail-latency risk.

05

Choose single commands, WATCH transactions, or Functions from the smallest primitive that preserves the required invariant.

Exact lab baseline

All Chapter 14 mandatory labs reuse the disposable Chapter 01 environment: Redis Open Source 8.10.1 from pinned Docker image redis:8.10.1, container atlasmart-redis-ch01, standalone topology, host endpoint 127.0.0.1:6379, TLS disabled only because traffic remains on loopback, default ACL user disabled, named academy-admin and atlasmart-app users, logical database 0, AOF with appendfsync everysec plus RDB snapshots, persistent /data, and no explicit maxmemory/eviction policy. Client examples that need Python use redis-py 8.1.0. Chapter-specific fixtures stay under atlasmart:ch14:.

1. The race-prone workflow has a gap between observation and mutation

A naive reservation service reads stock, decides in the application, then decrements stock and writes an order record. Two clients can both read the same last unit before either writes. Pipelining can reduce transport round trips but cannot make a branch that depends on a previous result atomic.

Pseudo-trace · two clients oversell one remaining unit
Client A: GET stock -> 1Client B: GET stock -> 1Client A: decides yes; DECR stock -> 0Client B: decides yes; DECR stock -> -1Invariant broken: stock must never be negative.

2. The Function keeps only the critical state transition in Redis

The server-side API receives two declared keys—the stock counter and one order hash—and a positive quantity argument. It reads the current stock, rejects when insufficient, and otherwise performs the two Redis writes before another client can interleave. External payment, inventory replenishment, email, and analytics remain outside Redis.

Lua · reserve_order_v1
#!lua name=atlasmart_ch14_reserve_v1redis.register_function{function_name='reserve_order_v1', callback=function(keys,args) local qty=tonumber(args[1]); if not qty or qty <= 0 then return redis.error_reply('ERR qty must be positive'); end; local stock=tonumber(redis.call('GET',keys[1]) or '0'); if stock < qty then return {0,stock}; end; redis.call('DECRBY',keys[1],qty); redis.call('HSET',keys[2],'status','reserved','qty',qty); return {1,stock-qty}; end, description='Atomic AtlasMart stock reservation'}
Python · deploy the exact library source
import redisr = redis.Redis(host="127.0.0.1", port=6379, username="academy-admin", password="AtlasMart-Admin-Lab-Only-2026", decode_responses=True)library = "#!lua name=atlasmart_ch14_reserve_v1\nredis.register_function{function_name='reserve_order_v1', callback=function(keys,args) local qty=tonumber(args[1]); if not qty or qty <= 0 then return redis.error_reply('ERR qty must be positive'); end; local stock=tonumber(redis.call('GET',keys[1]) or '0'); if stock < qty then return {0,stock}; end; redis.call('DECRBY',keys[1],qty); redis.call('HSET',keys[2],'status','reserved','qty',qty); return {1,stock-qty}; end, description='Atomic AtlasMart stock reservation'}"try:    r.function_delete("atlasmart_ch14_reserve_v1")except redis.ResponseError:    passprint(r.function_load(library))

3. A deterministic single-call proof comes before the stress test

Start with stock 2 and request one unit. The Function should return success + remaining stock, decrement exactly once, and create the reservation hash. Then request more than remaining stock; the Function should reject without changing either key for that second order.

redis-cli · deterministic acceptance and rejection
UNLINK atlasmart:ch14:l5:stock atlasmart:ch14:l5:order:1 atlasmart:ch14:l5:order:2SET atlasmart:ch14:l5:stock 2FCALL reserve_order_v1 2 atlasmart:ch14:l5:stock atlasmart:ch14:l5:order:1 1FCALL reserve_order_v1 2 atlasmart:ch14:l5:stock atlasmart:ch14:l5:order:2 2GET atlasmart:ch14:l5:stockHGETALL atlasmart:ch14:l5:order:1EXISTS atlasmart:ch14:l5:order:2# Expected: first succeeds, second rejects, remaining stock=1, order:2 absent.

4. Stress harness: compare naive and Function workflows

The following free/local redis-py harness runs bounded concurrent workers against a synthetic stock counter. It reports operation counts and p50/p95/p99 client-observed latency. Run each mode on a freshly reset prefix. The naive workflow intentionally inserts a tiny application-side scheduling yield between GET and DECR to make the race observable; it is not production code.

Python · bounded concurrency and latency harness
import concurrent.futures, statistics, time, redisHOST, PORT = "127.0.0.1", 6379USER, PASSWORD = "academy-admin", "AtlasMart-Admin-Lab-Only-2026"PREFIX = "atlasmart:ch14:l5"INITIAL_STOCK, CLIENTS = 40, 80def client():    return redis.Redis(host=HOST, port=PORT, username=USER, password=PASSWORD, decode_responses=True)def pct(xs, p):    ys = sorted(xs); return ys[min(len(ys)-1, max(0, round((len(ys)-1)*p)))]def run(mode):    r = client(); stock = f"{PREFIX}:{mode}:stock"    keys = [stock] + [f"{PREFIX}:{mode}:order:{i}" for i in range(CLIENTS)]    if keys: r.unlink(*keys)    r.set(stock, INITIAL_STOCK)    def one(i):        c = client(); order = f"{PREFIX}:{mode}:order:{i}"; t0=time.perf_counter_ns()        if mode == "naive":            seen = int(c.get(stock) or 0)            if seen > 0:                time.sleep(0.001)                remaining = c.decr(stock)                c.hset(order, mapping={"status":"reserved"})                ok = 1            else:                remaining, ok = seen, 0        else:            reply = c.fcall("reserve_order_v1", 2, stock, order, 1)            ok, remaining = int(reply[0]), int(reply[1])        return ok, remaining, (time.perf_counter_ns()-t0)/1_000_000    with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:        rows = list(ex.map(one, range(CLIENTS)))    final_stock = int(r.get(stock) or 0)    successes = sum(x[0] for x in rows)    lat = [x[2] for x in rows]    print(mode, {"successes":successes,"final_stock":final_stock,"invariant_ok":final_stock>=0 and final_stock+successes==INITIAL_STOCK,"p50_ms":round(pct(lat,.50),3),"p95_ms":round(pct(lat,.95),3),"p99_ms":round(pct(lat,.99),3)})run("naive")run("function")
Interpret, do not pre-fill

The exact number of naive oversells and every latency percentile depend on scheduling and the machine. Record your actual output. The Function mode should preserve the stock invariant if the environment and harness are correct; the lesson does not fabricate benchmark values.

5. What the latency numbers do—and do not—mean

The Function reduces a conditional workflow to one client request and executes the critical commands server-side. That can remove network round trips, especially when the application is not colocated with Redis. It does not imply that Lua is inherently faster than all native commands, nor that large Functions are safe. Compare client p50/p95/p99, server Slow Log/latency evidence, payload sizes, and concurrency under the same persistence/network conditions.

Metric What it reveals What it cannot prove alone
Client p50/p95/p99 Observed end-to-end latency distribution Pure Redis CPU time
SLOWLOG Slow server command/function execution Network queue/RTT
Success/reject counts Business decision distribution Durability after crash
Invariant check Atomic correctness for tested fixture All future code paths/topologies
Round-trip count Transport shape Overall throughput under every workload

6. Compare the simplest correct primitives

Do not default every conditional workflow to Functions. Chapter 13 already established that single native commands are preferred when they express the invariant, and WATCH is useful when application-side computation is necessary. Functions fit when a small stable multi-command state transition benefits from one server-side atomic API.

Requirement Preferred starting point
Increment one counter INCR/HINCRBY
Create only if absent SET NX / other exact native condition
Read/compute in application with low contention WATCH + MULTI/EXEC
Small stable conditional mutation across related Redis keys Function or Eval script
Large scan/analytics/external I/O Application/background processing, not Lua

7. Cluster, ACL, persistence, and failover remain separate axes

The function call is atomic on the target Redis node, but production correctness still depends on topology. In Cluster, all declared keys for the call should share a slot; with ACLs, the caller needs scripting plus underlying command/key permissions; with AOF/RDB, durability follows configured persistence; with asynchronous replication/Sentinel failover, successful responses do not create zero-loss guarantees. Those concerns are measured in later chapters rather than being smuggled into the word “atomic.”

redis-cli · same-slot key design for later Cluster verification
CLUSTER KEYSLOT atlasmart:ch14:{sku:42}:stockCLUSTER KEYSLOT atlasmart:ch14:{sku:42}:order:1001# Same {sku:42} hash tag is the intended Cluster design. Current mandatory lab remains standalone.

8. Failure injection: invalid quantity must fail before mutation

The Function validates quantity before reading/writing stock. Exercise zero and nonnumeric quantities and prove neither stock nor an order hash changes. This is a bounded semantic failure, not destructive chaos.

redis-cli · validation failures
SET atlasmart:ch14:l5:validate:stock 5FCALL reserve_order_v1 2 atlasmart:ch14:l5:validate:stock atlasmart:ch14:l5:validate:order0 0FCALL reserve_order_v1 2 atlasmart:ch14:l5:validate:stock atlasmart:ch14:l5:validate:orderX nopeGET atlasmart:ch14:l5:validate:stockEXISTS atlasmart:ch14:l5:validate:order0 atlasmart:ch14:l5:validate:orderX# Expected: errors, stock remains 5, both order keys absent.

9. Verification checklist and cleanup

  • The deterministic test accepted one valid reservation and rejected an oversized one without partial writes.
  • The concurrency harness recorded actual success/reject counts and p50/p95/p99 values for both modes.
  • The naive mode is labeled intentionally wrong and not reused as a recommendation.
  • The Function mode's invariant check uses exact Redis state, not a claim based on “atomic” terminology.
  • Cluster/failover/durability claims are explicitly scoped to what the standalone lab can and cannot prove.
redis-cli · bounded cleanup
UNLINK atlasmart:ch14:l5:stock atlasmart:ch14:l5:order:1 atlasmart:ch14:l5:order:2 atlasmart:ch14:l5:validate:stock atlasmart:ch14:l5:validate:order0 atlasmart:ch14:l5:validate:orderXFUNCTION DELETE atlasmart_ch14_reserve_v1# The stress harness already unlinks only its explicit naive/function fixture keys before each run.

10. Production judgment

Adopt the Function only if the measured workflow benefits from one atomic server-side state transition and the code stays bounded, versioned, observable, and least-privileged. Monitor server p95/p99, Slow Log, function errors, key hotness, replication lag, and deployment version. Keep rollback source/dumps available. Avoid expanding the Function into pricing, payment, messaging, or long-running orchestration; Redis programmability is strongest as a narrow data-local primitive.

Check your understanding

  1. Why does pipelining not fix the naive conditional race?
  2. What invariant does the stress harness check?
  3. Does a lower client p95 prove the Function is universally faster?
  4. When is WATCH preferable?
  5. What remains unproven by this standalone Function test?
Review the answers

Pipelining reduces transport overhead but does not make a read-dependent branch atomic by itself.

Final stock is nonnegative and final stock plus successful reservations equals the initial stock.

No. It applies to the measured environment/workload and must be considered with server execution and tail-latency evidence.

When application-side computation is needed and optimistic retries are acceptable, especially if server-side logic would become too large.

Cluster routing under resharding, replica/Sentinel failover loss windows, and other topology-specific behavior.

11. Chapter summary and bridge

Chapter 14 turned server-side programmability into an explicit engineering tradeoff: Eval scripts are volatile application-owned code, Functions are persisted deployed libraries, both can provide atomic data-local logic, and both can block Redis when abused. You measured cache/lifecycle/error/ACL/concurrency behavior and kept external work outside Lua. Chapter 15 moves to pipelining, batching, client connections, retries, and client-side caching—optimizations that change transport and freshness without pretending to add atomicity.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.