Skip to content

How caches learn of change

A cache learns of change only through a path you build. Cover every writer, and keep a TTL for when a path fails.

Predict first

Before you read: predict

ExamHall caches each of ECON-201's 20 questions in Redis with a 10-minute TTL. An AFTER UPDATE trigger on questions calls pg_notify with the updated question's cache key, and a listener deletes every key it hears. The nightly import job opens a transaction, updates all 20 questions, fails on its last step and rolls back. It retries in a new transaction that updates only Q1–Q10, and that one commits.

Once the listener has handled everything it received, how many DELs has it run?

Pick one

The 30-second version

A cache never learns of a change by itself. A TTL caps how long a copy can be wrong. A delete on write covers only writers that run it and deletes that arrive. Versioned keys and tags retire a group at once. Database events cover every writer, but announce only committed row changes, and only to listeners present.

A cache learns of change only through a path you build. Cover every writer, and keep a TTL for when a path fails.

How it works

Step 1 of 12

Nothing tells a cache the source changed. A copy stays until its TTL ends or something deletes it.

Analogy: A ski resort's piste boards

Mountain control keeps each piste's status. Every lift station shows a board copied from it, and skiers trust the board.

On the mountain In the system
each board line is rewritten from control two hours after it was chalked up a TTL (step 2)
the ski patrol closes a piste and radios the stations it names delete on write (step 3)
a groomer ropes off a piste and tells nobody a missed invalidation (step 4)
the radio call drops in a dead spot, and nobody repeats it a lost delete (step 5)
stations check control's status number first; lines chalked under an older number are ignored versioned keys (steps 6–7)
"close every piste on the north list", read from a list that is wiped each evening a tag (steps 8–9)
control's system pushes every status change to every station screen that is switched on events from the database (steps 10–11)

In real terms: each board learns of a closure only through a call, a status-number check or its scheduled rewrite; build at least two.

Breaks down when: a skier sees a rope across a piste the board shows open and reports it. Wrong inference: "someone will notice a stale copy." A cache never compares its copy with the database.

Contrasting case: every skier radios control before each run. No board is ever wrong, and control's radio never stops: no cache.

Examples, run for real

4.1 ExamHall: the answer key that went in the back door

EXH-1240: an admin corrected ECON-201's answer key, but the fix went through the bulk import job. Only the admin endpoint deleted the grading cache's key. MR !1440 moves invalidation into the database. One change per writer:

4.1 answer-key fixes to ECON-201 Q7, Q8, Q9, one per writer (grading cache TTL 600 s)
  before: only the admin endpoint deletes, after its commit
    admin endpoint Q7 A -> C · grading reads C
    import job     Q8 B -> D · grading reads B · cached copy expires within 600 s
    psql console   Q9 A -> B · grading reads A · cached copy expires within 600 s
  after MR: AFTER UPDATE trigger -> pg_notify -> listener DEL
    admin endpoint Q7 A -> C · grading reads C
    import job     Q8 B -> D · grading reads D
    psql console   Q9 A -> B · grading reads B
    listener deletes 3

Before, two writers never deleted, so only the TTL could help (steps 3–4). After, the trigger fires for every writer's UPDATE (step 10).

4.2 Ticketing: retiring an event's 30 section keys

4.2 event 9 moves venue: all 30 section prices change (section keys TTL 3600 s)
  named deletes 1 DEL naming 29 keys · deleted 29              · stale sections 1/30 · Postgres loads next pass 29
  tag set       SMEMBERS + 1 DEL · deleted 31 (30 keys + the tag) · stale sections 0/30 · Postgres loads next pass 30
  version bump  1 INCR · deleted 0                             · stale sections 0/30 · Postgres loads next pass 30 · old keys left 30, unreachable, expires within 3600 s

The named list predated section 30 (step 3). The tag deleted every member it knew (step 8). The bump did one write and left 30 unreachable entries to expire (step 6).

4.3 When a trigger's pg_notify arrives

4.3 when a trigger's pg_notify reaches a listener
  UPDATE seat 1, then ROLLBACK        heard: nothing (before the marker)
  UPDATE seat 2, then COMMIT          heard before COMMIT 0 · after COMMIT: seat:2
  seat 3 committed while no listener  new listener heard: nothing (before the marker)

The rollback sent nothing; the commit delivered its notification afterwards; a listener that connected later never heard of seat 3 (step 11). A marker notification, delivered in commit order, proves nothing earlier was pending.

Picture it

on the left, a grey-haired man in a green jumper stands at an open drawer of a pale filing cabinet, one hand on a brown folder standing in the drawer; on the right, a grey-haired woman in a blue blazer holds a card at one slot of a tall teal pigeonhole rack, with a tray of cards at the rack's foot. The image does not show whether either person is putting something in or taking it out. The lesson's reading: the cabinet is the source, the rack holds the cached copies, the tray holds retired copies.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

A streaming service caches title 12 as "Night Train" with a 15-minute TTL. The rename handler commits "Night Train: Final Cut" to Postgres, then sends DEL title:12; the connection to Redis resets before the DEL arrives. The handler logs the error and returns 200, and the Redis client reconnects on its own.

What do viewers see?

Pick one

Question 2 of 5

A shop caches product prices in Redis with a 1 h TTL. Prices change three ways: the admin app runs UPDATEs; each night an import job replaces one supplier's rows in a single transaction (DELETE … WHERE supplier = 7, then INSERT … SELECT from a staging table); support staff run UPDATEs in psql. A listener process, always running, deletes each key it is sent. Goal: after a change by any of the three, no old price is served for more than a minute.

Which design meets it?

Pick one

Question 3 of 5

A chat app caches each page of channel 3's history in Redis and tags it for group invalidation:

Pages 1–3 are filled at t = 0 s and pages 4–5 at t = 100 s. At t = 120 s a moderator deletes a spam message shown on every page, commits, and invalidateChannel() runs. Members then read all five pages: pages 4–5 are clean, and pages 1–3 show the spam until t = 600 s.

Which is the bug?

Code for this question
async function cachePage(key, html) {  await redis.set(key, html, { EX: 600 });  await redis.sAdd('tag:chan:3', key);  await redis.expire('tag:chan:3', 60);}async function invalidateChannel() {            // runs after each moderation commit  const keys = await redis.sMembers('tag:chan:3');  if (keys.length) await redis.del([...keys, 'tag:chan:3']);}
Pick one

Question 4 of 5

A billing service caches each account's plan for 24 h. An AFTER UPDATE trigger calls pg_notify, and one listener process deletes the key it names. During a deploy the listener is stopped for a minute, and three plan changes commit in that minute. After it restarts, those three accounts keep their old plans.

Which change stops that happening?

Pick one

Question 5 of 5

Event pages are cached as evpage:v{n}:{id} with a 10-minute TTL. To save a round trip, each of five pods keeps the version n in memory for a minute after reading it. An editor changes event 7's hall and bumps the version. Right after the bump, three of the five pods still serve the old hall.

Which is the bug?

Pick one

Go deeper

Pro tips
  1. Give the change a durable record, not just a message. Write the invalidation into the same transaction (an outbox row, or read the WAL through a logical slot) and let a consumer delete keys from it. When: a missed delete costs more than a slot's upkeep (steps 5, 11). Status: DOC. A slot retains WAL until it is read, so a consumer that stays down grows the server's disk; monitor the slot's lag. Not measured here.
  2. Write down each key's staleness story: which writers change the row, which signal reaches the cache for each, and which TTL ends a copy when that signal is lost. When: reviewing a new cache or a new writer (steps 3, 4, 12).
Source
  • PostgreSQL 17 documentation, NOTIFY: https://www.postgresql.org/docs/17/sql-notify.html · fetched 2026-09-27. Inside a transaction, "the notify events are not delivered until and unless the transaction is committed"; a notification goes to each client that "has previously executed LISTEN"; identical notifications in one transaction are delivered once; messages from different transactions arrive "in the order in which the transactions committed". Valid as of 2026-09-27.
  • PostgreSQL 17 documentation, Logical Decoding Concepts: https://www.postgresql.org/docs/17/logicaldecoding-explanation.html · fetched 2026-09-27. Slots "persist independently of the connection using them and are crash-safe"; a logical slot emits each change once in normal operation, and after a crash may send recent changes again. WAL configuration (https://www.postgresql.org/docs/17/runtime-config-wal.html): wal_level defaults to replica, logical "adds information necessary to support logical decoding", and it "can only be set at server start". test_decoding is the contrib output plugin used: https://www.postgresql.org/docs/17/test-decoding.html. Valid as of 2026-09-27.
  • PostgreSQL 17 documentation, Transaction Isolation, Read Committed: https://www.postgresql.org/docs/17/transaction-iso.html. A query sees only data committed before it began, so another session reads the old row while a transaction is open (t2; executed there). The isolation levels themselves belong to db-isolation-anomalies. Valid as of 2026-09-27.
  • Nishtala et al., Scaling Memcache at Facebook, NSDI 2013: https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf. Deletes are preferred because they are idempotent; invalidation daemons read the database's commit log and broadcast deletes to every cache, so writers do not have to (steps 3, 10). Valid as of 2026-09-27.
  • A stored response is fresh while its age is below its freshness lifetime, measured from when it was generated or stored, which is the model for the TTL bound (step 2). Edge purges by tag are Cloudflare's "Purge cache by cache-tags" (https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-tags/), taught in sd-cdn-edge-caching, not here. Valid as of 2026-09-27.
  • PostgreSQL 17 documentation, CREATE TRIGGER: https://www.postgresql.org/docs/17/sql-createtrigger.html · fetched 2026-09-27. The event is "One of INSERT, UPDATE, DELETE, or TRUNCATE; this specifies the event that will fire the trigger. Multiple events can be specified using OR". Valid as of 2026-09-27.
  • Redis, EXPIRE: https://redis.io/docs/latest/commands/expire/ · fetched 2026-09-27. "The timeout will only be cleared by commands that delete or overwrite the contents of the key"; commands that alter a key "without replacing it with a new one will leave the timeout untouched", so SADD keeps the set's own TTL and each EXPIRE resets it. Valid as of 2026-09-27.

Checked against the tools and docs as of 2026-09-27.