Skip to main content

Serving stale values while refreshing

Give an entry a freshness deadline shorter than its expiry, so readers never wait for a recompute and only one worker does it.

The newsletter goes out at 9:00. At 9:00:01, four hundred readers land on the front page, and the cached copy expired at 8:59:58. Every worker misses at the same instant, every one of them starts rendering the same page, and the database spends its morning answering one question four hundred times. The caching recipe calls this a thundering herd. For most values it’s harmless. When the value is slow to build (a front page that takes 400 ms, a report that queries a warehouse), there are two ways to make sure readers never see the gap, and only one worker does the work.

Stale-while-revalidate

Store the value with its own freshness deadline inside, and give the entry a longer hard expiry. A reader that finds the entry fresh returns it. One that finds it stale returns it anyway, and the first of them to win a claim starts a refresh in the background. Readers never wait, and the herd is one worker.

import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any

from diskcache_oxide import Cache

now = [1_000_000.0]                                   # a fake clock; in a real app use time.time()
cache = Cache(tempfile.mkdtemp(), clock=lambda: now[0])
refresher = ThreadPoolExecutor(max_workers=2)         # one per process, for background refreshes
loads: list[float] = []

def load_front_page() -> dict:
    time.sleep(0.05)                                  # stands in for 400 ms of rendering
    loads.append(now[0])
    return {'rendered_at': now[0]}

def store(key: str, value: Any, fresh_for: float, grace: float) -> Any:
    cache.set(key, {'value': value, 'fresh_until': now[0] + fresh_for}, expire=fresh_for + grace)
    return value

def refresh(key: str, fresh_for: float, grace: float) -> None:
    try:
        store(key, load_front_page(), fresh_for, grace)
    finally:
        cache.delete(f'refresh:{key}')

def front_page(fresh_for: float = 60, grace: float = 300) -> dict:
    key = 'page:front'
    entry = cache.get(key)
    if entry is None:                                 # a real miss: nothing to serve, compute inline
        return store(key, load_front_page(), fresh_for, grace)
    if now[0] >= entry['fresh_until'] and cache.add(f'refresh:{key}', 1, expire=30):
        refresher.submit(refresh, key, fresh_for, grace)   # stale, and this worker won the refresh
    return entry['value']                             # fresh or stale, the reader gets it now

front_page()                                          # the first request renders the page
now[0] += 61                                          # a minute later it's stale

results = []
start = threading.Barrier(8)

def request() -> None:
    start.wait()
    results.append(front_page()['rendered_at'])

threads = [threading.Thread(target=request) for _ in range(8)]
for t in threads:
    t.start()
for t in threads:
    t.join()
refresher.shutdown(wait=True)                         # in the example only: let the refresh land

print(f'{len(results)} readers got the stale page from t={results[0]:.0f}, {len(loads)} renders in total')
print(f"the next reader gets the fresh page from t={front_page()['rendered_at']:.0f}")
8 readers got the stale page from t=1000000, 2 renders in total
the next reader gets the fresh page from t=1000061

The three numbers to choose:

  • fresh_for is how old a value may get before a refresh starts. It plays the role expire plays in a plain cache.
  • grace is how much longer the stale value stays available. If no refresh lands within it (the refresher crashed, the source was down), the entry expires and the next reader computes inline, herd and all. Make it generous: minutes, not seconds.
  • The claim’s expire (30 s here) bounds how long a crashed refresher blocks the next one. Make it longer than a refresh ever takes.

In an asyncio app, start the refresh with asyncio.create_task(asyncio.to_thread(refresh, ...)) instead of a thread pool; see Using the cache from asyncio. With durable=True, a failed add() returns False, so nobody refreshes until the cache is healthy again, which is the right outcome.

Refreshing ahead from a job

When the hot keys are known in advance (the front page, the top ten episodes, the pricing table), it’s simpler to never let them go stale: a scheduled job, claimed by one worker, recomputes them every few minutes and stores each with an expire of at least twice the interval.

HOT_PAGES = ['front', 'episodes', 'pricing']

def refresh_hot_pages() -> None:                      # runs every 5 minutes, on one worker
    if not coordination.add('job:refresh-hot-pages', os.getpid(), expire=4 * 60):
        return
    for name in HOT_PAGES:
        cache.set(f'page:{name}', render_page(name), expire=15 * 60)

Readers do a plain get() and never miss; a missed run or two costs nothing because the entries outlive several intervals. The cost is that the job renders pages nobody may be asking for, which is fine for a handful of keys and wrong for a long tail. Stale-while-revalidate handles the tail, since it only refreshes what’s being read.

A memoize decorator with this built in is planned (see the roadmap).