Index Ai Academy
Phase 11 · Professional practice · System design

Caches, queues and background jobs

Speed reads with caches, and move slow work to queues that retry safely.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Two tools solve a large share of real performance and reliability problems. A cache keeps answers you've already worked out, so repeat requests are fast and cheap. That matters even more for AI features, where one model call can take seconds and cost cents. A queue takes slow or risky work out of the request: the user gets a quick reply, and workers do the job, retrying when it fails. Both are simple ideas with sharp edges. This lesson covers both.

What you'll be able to do

  • Use the cache-aside pattern with expiry and LRU eviction, and measure the hit rate
  • Handle the hard parts of caching: stale data, invalidation and stampedes
  • Move slow work to a queue with visibility timeouts, retries and a dead-letter queue
  • Explain at-least-once delivery, and why consumers must be idempotent

What it covers

  1. Cache-aside
  2. Cache-aside, with a clock
  3. The hard parts
  4. Queues and workers
  5. A worker that crashes at the worst moment
  6. LRU with OrderedDict
  7. Mistakes that reach production

Cache-aside

Think of the sticky note where you keep the phone numbers you use most. You check the note first. If the number isn't there, you look it up in the directory and add it to the note. This is the most common pattern. To read:

  1. Look in the cache. If it's there (a hit), return it.
  2. If not (a miss), load it from the database or service, store it in the cache, return it.

Each entry gets a time to live (TTL), after which it expires, like a "best before" date. When the cache is full, it evicts something. When your sticky note is full, you cross out the number you haven't used in the longest time, and that's LRU (least recently used).

Hit rate, the share of reads the cache can answer, is everything. With a 1 ms cache and a 50 ms database:

hit rateaverage read
50%25.5 ms
90%5.9 ms
99%1.5 ms
Worked example

Cache-aside, with a clock

Check the cache, then go to the database on a miss, and save the answer for next time. A pretend clock shows what expiry does.

DB = {"tent": 89}
cache = {}
db_reads = 0

def get_price(sku, now, ttl=60):
    global db_reads
    hit = cache.get(sku)
    if hit and now < hit["expires"]:
        return hit["value"], "hit"
    db_reads += 1
    cache[sku] = {"value": DB[sku], "expires": now + ttl}
    return DB[sku], "miss"

print(get_price("tent", now=0))
print(get_price("tent", now=30))
DB["tent"] = 79                     # the price changes
print(get_price("tent", now=45), "still the old price")
print(get_price("tent", now=70), "after it expires")
print("database reads:", db_reads)
It runs right here in your browser, with nothing to install.

Two of the four calls skipped the database, but the third returned a stale price. That's the trade-off a TTL makes: how long may a wrong answer live, in exchange for how much less load?

The hard parts

  • Stale data. A cached value can be out of date until it expires, like an old number on your note. Choose TTLs by how stale is acceptable: seconds for stock levels, hours for a product description.
  • Invalidation. When data changes, delete or update its cache entry. Missing one place means users see old data. Versioned keys (product:42:v7) make old entries unreachable.
  • Stampedes. Picture a shelf running empty, and a thousand shoppers all heading for the stockroom at once. A popular entry expires, and a thousand requests miss at once and all hit the database. Fixes: let one request reload while others wait (request coalescing), add random jitter to TTLs so entries don't expire together, or refresh hot entries early.
  • Keys. Include everything that changes the answer: user, language, version. A cache key without the user id can show one user's private data to another.

Caching AI calls

Model calls are slow and costly, so caching pays. Two layers:

  • Prompt caching at the API: reuses the processed prefix of a request (Phase 1 and Phase 10).
  • Response caching in your app: store the answer for an identical request, such as the same document summarized with the same prompt and model. Include the model and prompt version in the key, and don't cache replies that depend on the user unless the key includes the user.
Quick check
A cache key for "recommended products" is recs:homepage, but the recommendations are personalized. What goes wrong?
  1. Nothing; caches handle personalization
  2. Every user sees the recommendations cached for whoever loaded the page first
  3. The TTL stops working
  4. The cache gets slower
Show the answer

Every user sees the recommendations cached for whoever loaded the page first

The key must include everything that changes the answer, here the user id.

Queues and workers

A busy kitchen keeps orders on a rail of tickets. The waiter adds a ticket and goes back to the guests, and the cooks take tickets as they're free. A queue holds jobs; producers add them and workers take them. Use one when work is slow (sending email, video processing, a batch of model calls), when it can fail and should retry, or when traffic comes in bursts the workers can smooth out.

A good queue gives each job a life cycle:

  1. A worker pulls a job. The job becomes invisible to other workers for a visibility timeout, say 30 seconds.
  2. If the worker finishes, it acknowledges the job, which removes it.
  3. If it fails, or crashes and never acknowledges, the job becomes visible again after a delay and is retried.
  4. After too many attempts, the job moves to a dead-letter queue for a person to look at, so one bad job can't retry forever. A dead-letter queue holds jobs that failed too many times, set aside for a person to inspect.

At-least-once delivery

A cook finishes a dish and then gets called away before marking the ticket done, so another cook makes it again. Because a crashed worker's job is retried, a job can run more than once: the worker may have done the work and crashed before acknowledging. Most queues promise at-least-once delivery, not exactly-once. So workers must be idempotent: running a job twice has the same effect as once. Record finished job ids, use idempotency keys with payment APIs, or write with upserts, which insert a row or update it if it's already there.

In plain words: assume every job can run twice, and make sure running it twice does no harm.

Common tools: Redis-based job libraries, Amazon SQS, RabbitMQ, and Kafka (a log that consumers read at their own pace).

Worked example

A worker that crashes at the worst moment

The worker charges the card, then crashes before it can acknowledge the job. The queue delivers the job again, as it should. Compare what happens with and without a check.

def run(idempotent):
    charges, done = [], set()
    deliveries = [("job-1", "crash"), ("job-1", "ack")]   # delivered twice
    for job, then in deliveries:
        if idempotent and job in done:
            continue
        charges.append(job)
        done.add(job)
        if then == "crash":
            continue    # the acknowledgment never reaches the queue
    return charges

print("no check:", run(False))
print("with a check:", run(True))
In the lesson you can change this and run it.

Without a check, the customer pays twice. With one, the second delivery changes nothing. You can't avoid the repeat, since the queue can't tell whether the worker finished, so you make the repeat harmless.

Quick check
A worker charges a card, then crashes before acknowledging the job. What happens, and what protects the customer?
  1. The job is lost; nothing else happens
  2. The charge is undone automatically
  3. The queue refunds the customer
  4. The job is retried after the visibility timeout; an idempotency key stops a second charge
Show the answer

The job is retried after the visibility timeout; an idempotency key stops a second charge

At-least-once delivery means the job runs again. The payment API's idempotency key turns the repeat into a no-op.

Worked example

LRU with OrderedDict

An OrderedDict remembers order, and move_to_end marks an item as recently used.

from collections import OrderedDict

cache = OrderedDict()
capacity = 2
for key in ["a", "b", "a", "c"]:
    if key in cache:
        cache.move_to_end(key)
        print("hit ", key)
    else:
        print("miss", key)
        cache[key] = key.upper()
        if len(cache) > capacity:
            evicted, _ = cache.popitem(last=False)
            print("  evicted", evicted)
print(list(cache))
In the lesson you can change this and run it.

"a" was used again, so "b" became the least recently used and was evicted when "c" arrived.

Try it now

The second "a" is a hit, so it moves to the newest end. When "c" arrives, "b" is the least recently used and is evicted.

Quick check
An LRU cache holds 3 items. What does this print?
from collections import OrderedDict

cache = OrderedDict()
for key in ["a", "b", "c", "a", "d"]:
    if key in cache:
        cache.move_to_end(key)
    else:
        cache[key] = True
        if len(cache) > 3:
            cache.popitem(last=False)
print(list(cache))
  1. ['b', 'c', 'd']
  2. ['a', 'c', 'd']
  3. ['c', 'a', 'd']
  4. ['a', 'b', 'c']
Show the answer

['c', 'a', 'd']

Using a moves it to the newest end, so b becomes the least recently used and is evicted when d arrives. That leaves ['c', 'a', 'd'].

Mistakes that reach production

Leaving the user out of a cache key. If /account is cached by its URL alone, the first person to load it fills the cache, and the next person sees their data. This has caused real and embarrassing leaks. Any personalized response needs the user in its key, or it shouldn't be cached at all.

Retrying forever. A job that always fails, perhaps because of a bad input, will keep going round and using up workers. After a few attempts, move it to a dead-letter queue, where a person can look at it.

A visibility timeout shorter than the job. A queue hides a job while a worker has it, for a set time. If the job takes longer than that, the queue assumes the worker died, and hands the job to another worker. Now two workers are doing it at once. Set the timeout well above your slowest job.

Practice what you just read

  • 3 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

A popular cache entry expires and 2,000 requests hit the database at once. What's this called, and one fix?

  1. A stampede; let one request reload while the others wait, or add jitter to TTLs
  2. A cache hit; there's nothing to fix, because the reload will finish soon
  3. A deadlock; restart the database, so that the waiting requests are cleared
  4. An eviction; buy more memory, so that entries like this one don't get removed

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson