Knowledge Guide
HomeHands-On BuildsBackend Primitives

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:

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)

  1. 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.
  2. Two processes, shared Redis. Run node A and node B simultaneously against the same Redis, same tenant. Sum their allowed counts. It is still ≈600 — the two processes are drawing from one bucket. The cap held across the process boundary. That is the whole point.
  3. Two processes, in-process bucket (the lie). Swap in LocalBucket and 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:

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.

Two app processes (ports 8080/8081) each doing one atomic EVALSHA per request into a single shared Redis token bucket; a dashed red partition line marks docker pause, after which each node forks to fail-open (allow all) or fail-closed (reject all)
Two app processes (ports 8080/8081) each doing one atomic EVALSHA per request into a single shared Redis token bucket; a dashed red partition line marks docker pause, after which each node forks to fail-open (allow all) or fail-closed (reject all)

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.

Setupallowed (global)rejectedverdict
1 process, Redis≈600restcap holds
2 processes, Redis (shared bucket)≈600restcap holds ACROSS processes
2 processes, in-process bucket≈1200rest2× overshoot — the lie
2 processes, Redis paused, fail-OPENunbounded (all admitted)≈0availability kept, cap lost
2 processes, Redis paused, fail-CLOSED≈0allcap 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:

Pathp50p99note
in-process LocalBucket≈0.0002ms≈0.001msno 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–5msRTT 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

AxisOption AOption BPick 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


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

  1. Why Lua? — atomicity without WATCH multi races.
  2. Fail-open or closed? — availability vs abuse; product call.
  3. 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

  1. Two app nodes interleave GET/SET.
  2. 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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes