jonnifyechomq

Cache-aside, two layers · dive 02

The cache-aside read

The read path never enters the owner. A hit is a caller-side lookup. A miss is the only owner call — and the answer is always tagged with where it came from.

fetch/3 is the single entry point for every cache read. Before either layer is touched, the kind gate runs: wrong namespace, wrong size, invalid Base62 — refused at the door. A hit is a :ets.lookup in the caller’s own process, costing nothing but the lookup. Only a miss reaches the owner, which runs a single-flight fill — a GET ecc:{table}:id against L2, then the declared loader.(id) if L2 is also empty. The answer is tagged with the source: :hit, :l2, or :fill. The L2 value is the frame version <> value — split on read so the answer carries its own mint-time version.

The kind gate

Every EchoStore.Table declares a kind — a 3-byte namespace that every id stored in it must carry as its first 3 bytes. gate/2 enforces this before either layer is touched.

# EchoStore.Table — gate/2 (private)
# The kind law runs before any layer is touched.
# Three conditions, all must hold:
#   1. id is a binary
#   2. byte_size(id) == 14  (a branded id is always 14 bytes)
#   3. the first 3 bytes match the table's declared kind (e.g. "RMM")
#   4. BrandedId.valid?/1 passes (valid Base62 encoding of a snowflake)
# A wrong-namespace id returns {:error, :kind} — never touches L1 or L2.
defp gate(kind, id) do
  if is_binary(id) and byte_size(id) == 14 and binary_part(id, 0, 3) == kind and
       BrandedId.valid?(id) do
    :ok
  else
    {:error, :kind}
  end
end

The kind law is the BCS contract enforced at the cache boundary: an id that does not belong to this cache is refused before any tier is touched. This is not a runtime check — it is the architecture.

fetch/3 annotated

The full read path, extract-and-annotated from the real source. The L1 lookup is in the caller’s process; the owner call is on the miss path only.

# EchoStore.Table — fetch/3
# The read path. timeout defaults to 10_000 ms (the GenServer.call timeout).
def fetch(name, id, timeout \\ 10_000) do
  case EchoStore.spec(name) do
    :error ->
      # The cache is not in the directory — it does not exist.
      {:error, :no_such_cache}

    {:ok, spec} ->
      # Kind gate: wrong namespace → refused at the door, never touches L1/L2.
      with :ok <- gate(spec.kind, id) do
        now = System.monotonic_time(:millisecond)

        case :ets.lookup(name, id) do
          [{^id, value, expires_at, _version}] when now < expires_at ->
            # L1 hit: the row exists and has not expired.
            # This runs in the CALLER'S process — no GenServer call, no mailbox.
            # Reads scale with the number of schedulers, not one owner mailbox.
            :counters.add(spec.counters, @counters[:hits], 1)
            {:ok, value, :hit}

          _ ->
            # Miss (absent or expired): ask the owner to fill.
            # The owner coalesces concurrent misses on the same id onto one flight.
            :counters.add(spec.counters, @counters[:misses], 1)
            GenServer.call(name, {:fill, id}, timeout)
        end
      end
  end
end

On a miss, the owner’s launch_flight/2 runs a spawn_monitord task that issues GET ecc:{table}:id to Valkey. If the result is {:ok, nil} it runs the declared loader and SETs both layers. If the result is a binary it splits the version <> value frame and inserts L1. Every waiter on the same id receives the one answer; a flight crash fails all waiters with {:error, {:flight_crashed, reason}} — no caller wedges.

The owner is the miss handler, not the read handler. A hit scales freely; a miss is serialised exactly as much as needed — once per id, not once per caller.

Read simulator — source and gate

Pick an id and a scenario. The simulator runs the read logic over a fixed dataset — a small cache with three known entries and one empty slot — and shows the source tag and the gate verdict. All pure JS, no network.

fetch/3 · the read path in order

gate/2 kind · 14b · valid? {:error, :kind} L1 · ETS caller-side lookup :ok {:ok, value, :hit} owner single flight miss L2 · Valkey GET ecc:{t}:id loader.(id) SET … PX nil {:ok, value, :l2} {:ok, value, :fill}

fetch/3 simulator · fixed dataset, kind = “RMM”

Three outcomes, one entry point. The kind gate fires first — no layer is touched for a wrong-namespace id. An expired row is a miss — monotonic time is the truth, not presence in the table.

Pattern & implementation

The pattern

Cache-aside read. The application reads the cache first. On a miss it goes to the source of truth, fills the cache, and returns the value. The cache is a tier, not the authority. The application decides what to cache and how to fill it.

EchoStore implementation

fetch/3 tries L1 (caller-side :ets.lookup), then on a miss the owner runs a single flight: GET ecc:{table}:id → loader.(id) if nil. The answer is tagged :hit | :l2 | :fill. The kind gate enforces the namespace contract at the door. The L2 frame version <> value makes every cached value self-describing.

The pattern says “try the cache, fill on a miss.” The implementation says who tries the cache (the caller, for free), who fills it (the owner, once per id), and what a fill carries (a version the write already placed in the frame).

References

Sources

Related in this course