Build a Cross-Process Rate Limiter — Real Redis, Real Partition
Cross-Process Rate Limiter — Wire Two Real Processes, Then Partition Them
Every kata on this site is a lie in exactly one way: it runs in one process. A lock, a counter, a queue — all correct because one JVM (or one Go runtime) owns all the state and the language memory model guarantees the threads agree. The single-process token-bucket rate limiter is the cleanest example: it is provably correct, and it stops being correct the instant you run a second copy. This lab is the first one that makes you wire two real OS processes to one real Redis over a real socket, then cut that socket while traffic is flowing — and handle a failure nobody hand-injected into your code. The bug arrives from the network, not from a // TODO: break this comment.
It pairs directly with the design-workshop version, Design a Distributed Rate Limiter (which reasons to the architecture on a whiteboard). This one is the runnable counterpart: you type the Lua, you run java twice, you docker pause redis, and you watch what your own code actually does when the shared source of truth vanishes mid-request.
The trap — "correct" in one process is a per-process limit, not a limit
You promised the business one number: "tenant acme gets 50 requests/second sustained, 100 burst." You built a token bucket that enforces exactly that, with tests that pass. Then the service got popular and now runs as N identical processes behind a load balancer. Trace what the promised number becomes:
- Each process holds its own in-memory bucket, initialised to the same "50/s, burst 100" config.
- The load balancer splits
acme's traffic across all N. Each process sees a fraction, stays under its local cap, and admits it. - Summed across the fleet,
acmegets up to N × 50/s. At 2 processes that's 100/s — double. At 10 processes it's 500/s. Every process is behaving exactly as coded; every unit test still passes. The limit you promised does not exist.
The fix is not a better bucket — it's where the bucket lives. The counter must move out of process memory into one store every process shares, and the check-and-decrement must be atomic on that store. That is the entire lab. Scope for what follows: one tenant key, a global token bucket (capacity 100, refill 50/s), a single shared Redis, and one deliberately-faced decision — what happens to the limiter when Redis is unreachable.
Build it — the atomic token bucket as a Lua script
The naive client-side version reads fine and is wrong:
tokens = GET rl:acme -- round trip 1
if tokens >= 1: -- decision made HERE, in the app process
SET rl:acme (tokens - 1) -- round trip 2
allow
else:
reject
Between round trip 1 and round trip 2 there is a gap, and in that gap other requests — from this process, from other threads, and from the other process entirely — run the same GET and read the same stale value. Ten concurrent requests at tokens = 1 all read 1, all decide "allow," all write 0. Ten admitted where one slot existed. Moving the counter into Redis did not fix the race; it relocated it from "across threads in one JVM" to "across every connection in the fleet." This is a textbook check-then-act (TOCTOU) bug, now distributed.
The fix uses the one property Redis guarantees: a single command — or a single EVAL of a Lua script — executes to completion with no other client's command interleaved (Redis runs commands on one thread). Put the read, the refill, the compare, and the decrement inside one script and the gap is gone by construction. Here is the full token bucket — refill-on-read, so no background job is needed:
-- token_bucket.lua — ATOMIC check-and-take. This script IS the design.
-- KEYS[1] = bucket key, e.g. "rl:{acme}" ({} = one Redis-Cluster hash slot)
-- ARGV[1] = capacity : max tokens (burst ceiling)
-- ARGV[2] = refill_rate : tokens added per second (the sustained limit)
-- ARGV[3] = requested : tokens this call wants (usually 1)
-- ARGV[4] = ttl_seconds : idle-key expiry (housekeeping)
-- returns { allowed(1|0), tokens_remaining, retry_after_ms }
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
-- Redis is the ONE clock authority: no app-node clock skew can change the result.
local t = redis.call('TIME') -- { seconds, microseconds }
local now = tonumber(t[1]) + tonumber(t[2]) / 1000000.0
local st = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(st[1])
local last_ts = tonumber(st[2])
if tokens == nil then -- first time we ever see this key
tokens = capacity -- start full
last_ts = now
end
local elapsed = now - last_ts
if elapsed < 0 then elapsed = 0 end -- guard against TIME going backwards
tokens = math.min(capacity, tokens + elapsed * refill_rate) -- lazy refill
local allowed = 0
local retry_ms = 0
if tokens >= requested then
allowed = 1
tokens = tokens - requested
else
retry_ms = math.ceil(((requested - tokens) / refill_rate) * 1000)
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], ttl) -- self-cleaning idle keys
return { allowed, math.floor(tokens), retry_ms }
Everything the app process used to decide is now decided on the server, in one indivisible step. The app's only job is to call it and to survive the call failing.
Call it from Java (Jedis)
Run this class as-is in two terminals against one Redis. The load-bearing details are marked: the socket timeout (a partition must surface as a fast exception, never a hung request thread) and the fail policy (what allow() returns when Redis is gone).
// RateLimiterNode.java
// javac -cp jedis-5.1.0.jar RateLimiterNode.java
// Terminal 1: java -cp .:jedis-5.1.0.jar RateLimiterNode A open
// Terminal 2: java -cp .:jedis-5.1.0.jar RateLimiterNode B open
import redis.clients.jedis.*;
import redis.clients.jedis.exceptions.JedisException;
import redis.clients.jedis.exceptions.JedisNoScriptException;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class RateLimiterNode {
static final String LUA = // paste token_bucket.lua here as one string
"local capacity=tonumber(ARGV[1]) local refill_rate=tonumber(ARGV[2]) "
+ "local requested=tonumber(ARGV[3]) local ttl=tonumber(ARGV[4]) "
+ "local t=redis.call('TIME') local now=tonumber(t[1])+tonumber(t[2])/1000000.0 "
+ "local st=redis.call('HMGET',KEYS[1],'tokens','ts') "
+ "local tokens=tonumber(st[1]) local last_ts=tonumber(st[2]) "
+ "if tokens==nil then tokens=capacity last_ts=now end "
+ "local elapsed=now-last_ts if elapsed<0 then elapsed=0 end "
+ "tokens=math.min(capacity,tokens+elapsed*refill_rate) "
+ "local allowed=0 local retry_ms=0 "
+ "if tokens>=requested then allowed=1 tokens=tokens-requested "
+ "else retry_ms=math.ceil(((requested-tokens)/refill_rate)*1000) end "
+ "redis.call('HSET',KEYS[1],'tokens',tokens,'ts',now) "
+ "redis.call('EXPIRE',KEYS[1],ttl) "
+ "return { allowed, math.floor(tokens), retry_ms }";
static final String CAP = "100", REFILL = "50", TTL = "120"; // global bucket
final JedisPool pool;
final String sha;
final boolean failOpen; // the policy decision, made explicit
RateLimiterNode(JedisPool pool, boolean failOpen) {
this.pool = pool; this.failOpen = failOpen;
try (Jedis j = pool.getResource()) { this.sha = j.scriptLoad(LUA); }
}
/** One atomic round trip. Returns true iff the request is admitted. */
boolean allow(String tenant) {
List<String> keys = Collections.singletonList("rl:{" + tenant + "}");
List<String> argv = Arrays.asList(CAP, REFILL, "1", TTL);
try (Jedis j = pool.getResource()) {
Object raw;
try {
raw = j.evalsha(sha, keys, argv);
} catch (JedisNoScriptException ns) {
// A Redis restart/failover flushes the script cache -> NOSCRIPT.
// Reload by sending the whole body once; SHA is valid again after.
raw = j.eval(LUA, keys, argv);
}
return ((Long) ((List<?>) raw).get(0)) == 1L;
} catch (JedisException e) {
// ---- THE CROSS-PROCESS FAILURE ----
// Socket timed out or connection refused: the shared source of truth
// is unreachable. Policy, not luck, decides the outcome.
return failOpen; // fail-open => admit ; fail-closed => reject
}
}
public static void main(String[] a) throws Exception {
String node = a.length > 0 ? a[0] : "A";
boolean open = a.length < 2 || a[1].equalsIgnoreCase("open");
int threads = 16, seconds = 10;
JedisPoolConfig cfg = new JedisPoolConfig();
cfg.setMaxTotal(threads * 2);
// 100ms connect + 100ms socket read. WITHOUT this, docker-pause hangs
// every request thread and the whole node stalls instead of failing over.
JedisPool pool = new JedisPool(cfg, "127.0.0.1", 6379, 100, null);
RateLimiterNode rl = new RateLimiterNode(pool, open);
AtomicLong allowed = new AtomicLong(), rejected = new AtomicLong();
long[] lat = new long[4_000_000]; AtomicInteger li = new AtomicInteger();
long deadline = System.nanoTime() + seconds * 1_000_000_000L;
ExecutorService pool2 = Executors.newFixedThreadPool(threads);
for (int i = 0; i < threads; i++) pool2.submit(() -> {
while (System.nanoTime() < deadline) {
long s = System.nanoTime();
boolean ok = rl.allow("acme");
long d = System.nanoTime() - s;
int idx = li.getAndIncrement();
if (idx < lat.length) lat[idx] = d;
(ok ? allowed : rejected).incrementAndGet();
}
});
pool2.shutdown(); pool2.awaitTermination(seconds + 5, TimeUnit.SECONDS);
int n = Math.min(li.get(), lat.length);
long[] s = Arrays.copyOf(lat, n); Arrays.sort(s);
System.out.printf("node=%s policy=%s allowed=%d rejected=%d "
+ "p50=%.2fms p99=%.2fms%n",
node, open ? "OPEN" : "CLOSED", allowed.get(), rejected.get(),
n > 0 ? s[n/2]/1e6 : 0.0, n > 0 ? s[(int)(n*0.99)]/1e6 : 0.0);
}
}
The in-process "lie," for contrast. Swap rl.allow(...) for this local bucket, run two copies, and the promised cap doubles — this is the trap made runnable:
// Correct in ONE process, a lie in two. No network, no shared truth.
static final class LocalBucket {
final double cap = 100, rate = 50; double tokens = cap; long ts = System.nanoTime();
synchronized boolean allow() {
long now = System.nanoTime();
tokens = Math.min(cap, tokens + (now - ts) / 1e9 * rate); ts = now;
if (tokens >= 1) { tokens -= 1; return true; }
return false;
}
}
Call it from Go (go-redis)
The same script, the same two knobs (timeout via context deadline, fail policy via a flag). go-redis's redis.NewScript does EVALSHA-then-EVAL-on-NOSCRIPT for you, so the failover case is handled by the library.
// node.go
// go mod init rl && go get github.com/redis/go-redis/v9
// Terminal 1: go run node.go A open
// Terminal 2: go run node.go B open
package main
import (
"context"
"fmt"
"os"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
)
// NewScript caches the SHA and auto-falls back to EVAL after a NOSCRIPT (failover).
var bucket = redis.NewScript(`
local capacity=tonumber(ARGV[1]) local refill_rate=tonumber(ARGV[2])
local requested=tonumber(ARGV[3]) local ttl=tonumber(ARGV[4])
local t=redis.call('TIME') local now=tonumber(t[1])+tonumber(t[2])/1000000.0
local st=redis.call('HMGET',KEYS[1],'tokens','ts')
local tokens=tonumber(st[1]) local last_ts=tonumber(st[2])
if tokens==nil then tokens=capacity last_ts=now end
local elapsed=now-last_ts if elapsed<0 then elapsed=0 end
tokens=math.min(capacity,tokens+elapsed*refill_rate)
local allowed=0 local retry_ms=0
if tokens>=requested then allowed=1 tokens=tokens-requested
else retry_ms=math.ceil(((requested-tokens)/refill_rate)*1000) end
redis.call('HSET',KEYS[1],'tokens',tokens,'ts',now)
redis.call('EXPIRE',KEYS[1],ttl)
return { allowed, math.floor(tokens), retry_ms }`)
const cap, refill, ttl = 100, 50, 120
// One atomic round trip, bounded by a per-call timeout.
func allow(rdb *redis.Client, tenant string, failOpen bool) bool {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
res, err := bucket.Run(ctx, rdb,
[]string{"rl:{" + tenant + "}"}, cap, refill, 1, ttl).Result()
if err != nil {
// THE CROSS-PROCESS FAILURE: timeout / connection refused.
return failOpen // fail-open => admit, fail-closed => reject
}
vals := res.([]interface{})
return vals[0].(int64) == 1
}
func main() {
node := "A"; if len(os.Args) > 1 { node = os.Args[1] }
open := len(os.Args) < 3 || os.Args[2] == "open"
threads, seconds := 16, 10
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
DialTimeout: 100 * time.Millisecond,
ReadTimeout: 100 * time.Millisecond, // partition -> fast error, not a hang
PoolSize: threads * 2,
})
var allowed, rejected int64
lat := make([]int64, 0, 4_000_000); var mu sync.Mutex
deadline := time.Now().Add(time.Duration(seconds) * time.Second)
var wg sync.WaitGroup
for i := 0; i < threads; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for time.Now().Before(deadline) {
s := time.Now()
ok := allow(rdb, "acme", open)
d := time.Since(s).Nanoseconds()
mu.Lock(); lat = append(lat, d); mu.Unlock()
if ok { atomic.AddInt64(&allowed, 1) } else { atomic.AddInt64(&rejected, 1) }
}
}()
}
wg.Wait()
sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
p := func(q float64) float64 {
if len(lat) == 0 { return 0 }
return float64(lat[int(float64(len(lat))*q)]) / 1e6
}
policy := "OPEN"; if !open { policy = "CLOSED" }
fmt.Printf("node=%s policy=%s allowed=%d rejected=%d p50=%.2fms p99=%.2fms\n",
node, policy, allowed, rejected, p(0.50), p(0.99))
}
Break it by running it
Start one Redis and keep it in view:
docker run --rm -p 6379:6379 --name redis redis:7
Experiment 1 — prove the limit is GLOBAL (and that in-process is a lie)
- One process, shared Redis. Run a single node for 10s. With capacity 100 + 50/s refill, admissions over 10s ≈
100 + 50×10 = 600. Everything past that is rejected. Record the number. - Two processes, shared Redis. Run node A and node B simultaneously against the same Redis, same tenant. Sum their
allowedcounts. It is still ≈600 — the two processes are drawing from one bucket. The cap held across the process boundary. That is the whole point. - Two processes, in-process bucket (the lie). Swap in
LocalBucketand rerun both. Each admits ≈600 on its own clock → combined ≈1200, exactly 2× the promised limit, with zero errors and both "correct." This is the trap from the top, now on your screen.
Experiment 2 — partition Redis mid-flight and face the choice you can't avoid
With two processes hammering, freeze Redis so the socket stays open but no reply ever comes (the realistic partition — a hung dependency, not a clean refusal):
docker pause redis # requests now block until your 100ms timeout fires
# ...watch the nodes for a few seconds...
docker unpause redis # traffic recovers on its own
Now the behaviour is entirely determined by the flag you passed — and you must have chosen on purpose:
- Run with
open(fail-open). During the pause, everyallow()times out after ~100ms and returnstrue.allowedkeeps climbing — the limiter is now admitting everything. You traded the cap for availability. Correct for a public content endpoint you'd rather over-serve than 500; catastrophic for a login endpoint, because you've just disabled the brute-force protection at the exact moment you can't see it working. - Run with
closed(fail-closed). During the pause, every call times out and returnsfalse.rejectedgoes to 100% — a full self-inflicted outage foracmewhile Redis is merely slow, not even down. Correct for "must never exceed" quotas (payments, signups); a needless outage for anything that could have coasted.
Also try docker kill redis (connection refused → fast error instead of a 100ms wait) and docker starting a fresh one to see the NOSCRIPT-after-restart path the code guards against. The lesson the running system teaches that a whiteboard can't: fail-open and fail-closed are not "error handling," they are a product decision that changes which disaster you get, and the network will force the decision whether or not you made it deliberately.
Measure
Representative numbers from one 10s run on a laptop with Redis in Docker on localhost (config: capacity 100, refill 50/s, 16 threads/process). Absolute latencies vary by machine and network; the shape is the lesson.
| Setup | allowed (global) | rejected | verdict |
|---|---|---|---|
| 1 process, Redis | ≈600 | rest | cap holds |
| 2 processes, Redis (shared bucket) | ≈600 | rest | cap holds ACROSS processes |
| 2 processes, in-process bucket | ≈1200 | rest | 2× overshoot — the lie |
| 2 processes, Redis paused, fail-OPEN | unbounded (all admitted) | ≈0 | availability kept, cap lost |
| 2 processes, Redis paused, fail-CLOSED | ≈0 | all | cap kept, tenant down |
The cost of correctness is a network hop. The in-process check is a few nanoseconds (a lock + arithmetic). The Redis version is a round trip:
| Path | p50 | p99 | note |
|---|---|---|---|
in-process LocalBucket | ≈0.0002ms | ≈0.001ms | no I/O; but wrong at 2+ nodes |
| Redis EVALSHA, localhost | ≈0.3ms | ≈1.5ms | +1 RTT + tiny script exec |
| Redis EVALSHA, cross-AZ (~1ms base RTT) | ≈1.2ms | ≈3–5ms | RTT dominates; every request pays it |
That per-request tax is the real reason the design lab reaches for local batching (claim N tokens per hop, spend them locally) at high QPS — you are buying back the RTT you just spent to make the counter global.
Defend under drilling
"Region-local vs globally-coordinated counting — what does going global actually cost?" A single shared Redis gives an exact global cap but puts a synchronous cross-network hop on every request; span regions and that hop becomes 50–150ms, which no hot path tolerates. The honest multi-region answer is not one global Redis — it is a limiter per region, each enforcing a share of the budget locally, with asynchronous reconciliation between regions. You give up exactness (a client can briefly exceed the global cap during the reconciliation lag) to keep the check local and fast. Insisting on strong global consistency across regions is the classic over-design: you pay latency on 100% of requests to prevent an overshoot that, for abuse protection, simply does not matter.
"What does a Redis failover do to your in-flight state?" Redis replication is asynchronous. When a primary dies and a replica is promoted, any writes the primary accepted but hadn't yet shipped to the replica are lost — the token counts silently roll back to a slightly older value, so the bucket briefly over-admits. Acceptable for abuse protection (a few extra requests), unacceptable if you'd sold this as a hard billing quota. Two concrete aftershocks to name: (1) the new primary's script cache is empty, so the first EVALSHA returns NOSCRIPT — which is exactly why the code reloads on that error rather than crashing; (2) during the election window (seconds) every request hits your timeout and falls to the fail-open/closed policy, so failover is really a short burst of the Experiment-2 behaviour.
"One tenant goes viral — every request for it hits the same key, so the same shard. Now what?" A single logical counter is a single hot key; sharding Redis by hash(tenant) does not help because all of acme's traffic still maps to one slot. Two real levers: (1) local batching — each process claims a block of, say, 20 tokens in one EVAL and spends them in memory, cutting that key's Redis traffic ~20×; (2) counter splitting — represent one logical bucket as K physical sub-buckets rl:{acme}:0..K-1, route each request to a random one with cap/K, and accept a bounded over-admission in exchange for spreading writes across K slots.
"Why Lua and not WATCH/MULTI (optimistic transactions)?" WATCH+MULTI also closes the race, but it aborts and retries on contention — and a hot rate-limit key is nothing but contention, so you'd spin. Lua runs server-side to completion with no abort, so it degrades gracefully under exactly the load a limiter exists to handle.
The trade-offs, named
| Axis | Option A | Option B | Pick A when… / Pick B when… |
|---|---|---|---|
| Where the count lives | Local approximate (per-process bucket, periodic budget sync) | Global exact (shared Redis, atomic EVAL) | A: QPS too high to pay an RTT per request and a small overshoot is harmless (DoS/abuse). B: the cap is a contract that must hold (billing, hard quotas). |
| When Redis is unreachable | Fail-open (admit) | Fail-closed (reject) | A: over-serving is cheaper than downtime — public reads, content. B: under-protecting is the worse outcome — auth, payments, signups. Decide per-endpoint, not once globally. |
| Cross-region | Per-region local + async reconcile | One global synchronous store | A: almost always — keeps the check on-continent. B: only when a genuinely global hard cap outranks the 50–150ms cross-region tax on every request. |
Acceptance criteria
- ☐ The Lua script does read + refill + compare + decrement in one
EVAL; there is no client-side GET-then-SET anywhere. - ☐ Two processes against one Redis enforce the cap globally — summed
allowed≈ single-processallowed, not 2×. - ☐ You can demonstrate the in-process version admitting ~2× with two processes, and explain why in one sentence.
- ☐ Every Redis call has a bounded timeout;
docker pause redisproduces fast errors, not hung threads. - ☐ The unreachable-Redis path is an explicit, named fail-open or fail-closed choice — not an unhandled exception, and you can say which endpoints get which.
- ☐ The code survives a Redis restart (NOSCRIPT is caught and the script reloaded) rather than throwing.
- ☐ You measured the added latency (in-process ns vs Redis-hop ms) and can state what batching buys back.
- ☐ You can name the hot-key problem and two mitigations (local batching, counter splitting), and the multi-region answer (per-region local + async reconcile).
Re-authored/Deepened for this guide. Sources: Redis docs, "Rate limiting" patterns and the INCR/Lua-EVAL atomicity model (redis.io); Stripe, "Scaling your API with rate limiters" (stripe.com/blog/rate-limiters); Cloudflare, "How we built rate limiting capable of scaling to millions of domains"; the Envoy/Kong ratelimit service design (shared-store token counters); Redis Cluster hash-slot and replication/failover semantics.
🎯 STRICT STANDOUT — Cross-Process Rate Limiter (Hands-On H1)
Why this lab: The classic trap: each instance allows R/s → N instances allow N·R/s. Cluster limits need a shared atomic counter (Redis Lua / GCRA). Then network partition teaches split-brain over-admit.
1. Runnable contract (K11)
- Two+ processes call
allow(key)against shared Redis; global rate ≈ R not N·R. - Atomic script: read tokens, refill by time, decide, write — one Redis round-trip.
- Document behavior under Redis blip (fail-open vs fail-closed).
Hand-run: R=10, 2 processes, no Redis → ~20/s observed. With Redis Lua → ~10/s total. Partition Redis from one app: state chosen policy (open→over-admit, closed→false denies).
2. Failure modes (K12)
- Non-atomic GET/SET in app → race, over-admit.
- Redis down + fail-open → unprotected backend.
- Hot key on one Redis shard → latency spike; need local smoothing or shard by key carefully.
3. When-NOT (K13)
- Single instance forever → in-process token bucket is simpler and faster.
- Ultra-low latency path → local limit + async reconcile (approximate).
4. Hostile panel
- Why Lua? — atomicity without WATCH multi races.
- Fail-open or closed? — availability vs abuse; product call.
- How does this relate to API gateway limits? — edge vs service-local defense in depth.
5. Drills
Measure RPS with/without Redis; inject Redis latency; write policy table for fail modes.
🔩 Production depth — Cross-Process Rate Limiter
Spine: distributed-correctness · private lab · not a tool list
Production angle
Horizontal scale multiplies local limits. Production design picks an authority (Redis/etc.), an atomic script, and a failure policy when the authority is down — fail-open (abuse risk) vs fail-closed (availability risk).
Worked failure: GET tokens / SET tokens-1 in app code
- Two app nodes interleave GET/SET.
- Both allow the last token; global R broken without any single node “bug.”
Fix shape: one atomic server-side op (Lua/EVAL, etc.); clocks skew considered for refill; metrics on deny rate and store latency.
Partition story
If half the fleet cannot reach Redis, do you shed load or open the floodgates? That is a product decision, not a library default — write it down before the incident.
When-NOT
Single instance forever; ultra-low latency path may use local soft limit + async reconcile (approximate).
Related: Distributed limiter design · Operability · In-process baseline.
🤖 Don't fully get this? Learn it with Claude
Stuck on Build a Cross-Process Rate Limiter — Real Redis, Real Partition? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Build a Cross-Process Rate Limiter — Real Redis, Real Partition** (Hands-On Builds) and want to truly understand it. Explain Build a Cross-Process Rate Limiter — Real Redis, Real Partition from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Build a Cross-Process Rate Limiter — Real Redis, Real Partition** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Build a Cross-Process Rate Limiter — Real Redis, Real Partition** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Build a Cross-Process Rate Limiter — Real Redis, Real Partition** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.