---
title: Caching
source: 'https://academia.sh/en/courses/mvc-framework/caching'
course: 'The Model–View–Controller Framework'
language: en
updated: '2026-10-21T05:03:41+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Describes the fragment cache through the cache key and measures that the block runs once with the same key'
    - 'Compares the stay of the fragment cache with the stay of the query cache'
    - 'Shows that content becomes stale as long as the key does not change'
    - 'Measures invalidation by adding a version to the key and states the cost of the old entry'
---

# Caching

Measures a fragment being produced once and served to later requests, how it differs from the query cache, and how content becomes stale when the key does not change.

The previous lesson measured that the record of an enqueued job is persistent; the record waited until the worker that processes the job arrived. In this lesson what is made persistent is not a record but the result of a computation. Instead of redrawing one row of a product list on every request, it is possible to draw it once, store the result and serve it to later requests. The question is this: until which request is the stored result valid, and when does it stop being valid?

A **cache** is a store that keeps the result of an expensive computation so that the computation is not repeated when it is requested again. A **fragment cache** stores the drawn output of one part of a view, for example a single list row. The **cache key** decides which result belongs to which computation: a second request that arrives with the same key does not perform the computation, it takes the stored result. M11/K03 showed that a cache is a shared store; here we do not ask who sees it, we ask how long it stays. The measuring axis is the course's axis: **how many requests does this value stay for?** The four stays: **this request**, **next request**, **session** and **persistent**.

## Core

The measurements rest on the same small request loop. The file is the course core and is given as it is. The cache in this core is a hash inside `App#store`; in a real application the cache is a separate store, and the core carries its behavior, not the cache itself.

```ruby
# stay.rb — a small request loop and a helper that classifies how many requests a value stays for

# Flash: a value written in this request is carried to the next request only.
class Flash
  def initialize(incoming)
    @incoming = incoming
    @fresh = {}
  end

  def [](key) = @fresh.fetch(key) { @incoming[key] }

  def []=(key, value)
    @fresh[key] = value
  end

  def passed_on = @fresh
end

# Every request gets a new context: params, flash and the request store start empty each time.
class Context
  attr_reader :app, :params, :session, :flash, :request_store

  def initialize(app, session)
    @app = app
    @params = {}
    @session = session
    @flash = Flash.new(session.delete(:flash) || {})
    @request_store = {}
  end
end

# The application store is the same across all requests and all clients;
# the session is chosen per client.
class App
  attr_reader :store

  def initialize
    @sessions = Hash.new { |h, client| h[client] = {} }
    @store = { products: {}, schema: { products: %i[name price] }, templates: {},
               queue: [], cache: {}, counter: 0 }
  end

  def request(client)
    context = Context.new(self, @sessions[client])
    result = yield context
    context.session[:flash] = context.flash.passed_on unless context.flash.passed_on.empty?
    result
  end
end

# Rule: request 1 (client A) writes the value; requests 2 and 3 (A) and request 4 (B) read it.
# If B sees it, persistent; if A's third request sees it, session; if only the second
# request sees it, next; if none sees it, this_request.
def stay(write, read)
  app = App.new
  first = app.request("A") { |b| write.call(b); read.call(b) }
  raise ArgumentError, "value could not be read in the request that wrote it" if first.nil?

  seen = [["A", 2], ["A", 3], ["B", 4]].map do |client, order|
    app.request(client) { |b| read.call(b) }.nil? ? "--" : "#{client}#{order}"
  end
  kind = if seen[2] != "--" then "persistent"
         elsif seen[1] != "--" then "session"
         elsif seen[0] != "--" then "next"
         else "this_request"
         end
  [kind, ["A1", *seen].join(" "), first]
end
```

This block is source; the file is written to disk and pulled in with `require_relative`. The core is not a framework but a model of one. The rule reads how the value behaves, not where it is stored; the measurements are meaningful within a single application process.

## The Fragment Cache

The caching helper is a single line: if the key is in the cache it returns the stored value; if not, it runs the block, puts the result under the key and returns it. The excerpt of the lesson gives it together with two small helpers to be used in the measurements. The `Product` module is a short copy of the one from an earlier lesson and is needed for the comparison with the query cache.

```ruby
# cache.rb — the cached helper, a fragment-producing helper and Product for comparison
require_relative "stay"

def cached(b, key) = b.app.store[:cache][key] ||= yield

def escape(text) = text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")

def fragment(b, product)
  cached(b, "product/#{product[:id]}/#{product[:version]}") { "<li>#{escape(product[:name])}</li>" }
end

module Product
  def self.insert(b, attributes)
    errors = []
    errors << "name cannot be blank" if attributes[:name].to_s.empty?
    errors << "price cannot be negative" if attributes[:price].to_i.negative?
    return { errors: } unless errors.empty?

    table = b.app.store[:products]
    id = table.size + 1
    table[id] = attributes.merge(id:)
    { id:, errors: }
  end

  def self.lookup(b, id)
    queries = (b.request_store[:queries] ||= {})
    queries.fetch([:products, id]) do
      b.request_store[:query_count] = b.request_store.fetch(:query_count, 0) + 1
      queries[[:products, id]] = b.app.store[:products][id]
    end
  end
end

def report(name, kind, evidence, value)
  printf("%-14s%-14s%-13s%s\n", name, kind, evidence, value.inspect)
end
```

This block is source as well. `cached` writes the key with `||=`: if the value is `nil` or `false` it is computed again, but since the fragment outputs are strings this causes no problem in these measurements. The first observation is the fragment written to the cache itself.

```ruby
# fragment.rb — how many requests does the fragment cache stay for
require_relative "cache"

write = ->(b) { cached(b, "product/1") { "<li>pen</li>" } }
read = ->(b) { b.app.store[:cache]["product/1"] }
report("fragment", *stay(write, read))
```
```
fragment      persistent    A1 A2 A3 B4  "<li>pen</li>"
```

The fragment appeared in all four of the four requests, including the request of B. This is the **persistent** stay, and it is the purpose of a cache: the fragment that one client produced is also used by the request of another client. The model in the core shows this plainly. **AS7.** In the core the fragment is in process memory and has no lifetime at all. A real cache can give entries a lifetime or discard the old ones when it fills up; this measurement does not model them.

## The Computation Runs Once

The gain of a cache is that the computation is not repeated. This can be seen by counting: three calls with the same key in one request, and one call in another client; how many times does the block run?

```ruby
# count.rb — how many times is the block entered with the same key
require_relative "cache"

runs = 0
app = App.new
app.request("A") do |b|
  3.times { cached(b, "product/1") { runs += 1; "<li>pen</li>" } }
end
app.request("B") { |b| cached(b, "product/1") { runs += 1; "<li>pen</li>" } }
puts "same key: #{runs}"
app.request("A") { |b| cached(b, "product/2") { runs += 1; "<li>eraser</li>" } }
puts "second key: #{runs}"
```
```
same key: 1
second key: 2
```

In spite of four calls the block ran once; a different key ran the block once more. The key is the identity of the computation: if two calls use the same key, the same computation is considered to have been done for them.

## Comparison with the Query Cache

An earlier lesson measured the query cache. The same query in the same request did not go to the database a second time, but the next request started the counter from zero. The same rule applies to the two caches together.

```ruby
# comparison.rb — the query cache and the fragment cache side by side
require_relative "cache"

pen = ->(b) { Product.insert(b, name: "pen", price: 12) }
write = ->(b) { pen.call(b); Product.lookup(b, 1) }
read = ->(b) { b.request_store.dig(:queries, [:products, 1], :name) }
report("query", *stay(write, read))
write = ->(b) { cached(b, "product/1") { "<li>pen</li>" } }
read = ->(b) { b.app.store[:cache]["product/1"] }
report("fragment", *stay(write, read))
```
```
query         this_request  A1 -- -- --  "pen"
fragment      persistent    A1 A2 A3 B4  "<li>pen</li>"
```

Both carry the name of a cache, but their stays are opposite. The query cache is in the **this request** stay: it lives in the request store and goes at the end of the request. The fragment cache is in the **persistent** stay: it lives in the store of the application. The difference is that the repetition of the query is a repetition within the same request, while the repetition of the fragment is a repetition across requests. The limit of the query cache is one request; therefore its staleness (the state of lagging behind the source) is also limited to that request. The limit of the fragment cache is not one request; therefore going stale is its real problem. We have come to that problem. Because these two caches were measured with the same rule, both are part of the same core, but one follows the request and the other follows the application.

## Invalidation

If the fragment is persistent, what happens when the product changes? The cache stores the fragment independently of its source; it does not know that the source changed. Managing a cache therefore includes replacing an entry that has gone out of date with a new one. Treating an entry as outdated and discarding it is called **invalidation**. One way is to delete the entry. The other way is to change the key: the old entry is not invalidated, but the new key never reaches it. Our `fragment` helper uses the second way; it adds the version of the product to the key. When the product changes the version increases and the key is different.

```ruby
# invalidation.rb — invalidation with a versioned key
require_relative "cache"

app = App.new
product = { id: 1, name: "pen", version: 1 }
p app.request("A") { |b| fragment(b, product) }
product = product.merge(name: "eraser", version: 2)
p app.request("A") { |b| fragment(b, product) }
p app.store[:cache].keys
```
```
"<li>pen</li>"
"<li>eraser</li>"
["product/1/1", "product/1/2"]
```

When the product changed the output changed too. But when we look at the keys of the cache, the old entry is still there: a versioned key does not invalidate the old entry, it only never visits it again. The old entry fills the cache although it is never used; this cost is paid by a lifetime or a space-reclaiming policy. **AS8.** Deleting the old entry was not shown here; at which moment and by whom the deletion is done depends on the configuration of the cache.

## Invalidating by Deleting the Entry

The old entry left behind by a versioned key shows why the first way needs a separate measurement: deleting the entry directly. In the measurement the same key is used twice; in between the entry is deleted from the cache. The block increments a counter every time it runs.

```ruby
# delete.rb — does deleting the entry make the block run again
require_relative "cache"

runs = 0
app = App.new
2.times { app.request("A") { |b| cached(b, "k") { runs += 1; "old" } } }
puts "before delete: #{runs}"
app.request("A") { |b| b.app.store[:cache].delete("k") }
app.request("A") { |b| cached(b, "k") { runs += 1; "new" } }
puts "after delete: #{runs}"
p app.store[:cache]
```
```
before delete: 1
after delete: 2
{"k" => "new"}
```

Before the delete the block ran once. After the entry was deleted, the next call ran the block again and the cache now holds the new value. Deleting does the opposite of a versioned key: the old entry goes, the key stays the same. On this way the cache does not swell, but code that does the deleting has to run when the source changes; if it does not, the entry stays stale.

## `cached` Does Not Store a `nil` Result

The `cached` helper writes the value with `||=`. In Ruby `nil` and `false` count as false, so these two values do not stay in the cache. If the result of a computation is `nil` in the sense of "not found", the computation is redone on every call. Make two calls with the same key and look at how many times the block runs.

```ruby
# empty.rb — does a computation that returns nil enter the cache
require_relative "cache"

runs = 0
app = App.new
2.times { app.request("A") { |b| cached(b, "missing") { runs += 1; nil } } }
puts "nil: #{runs}"
2.times { app.request("A") { |b| cached(b, "present") { runs += 1; "x" } } }
puts "filled: #{runs}"
```
```
nil: 2
filled: 3
```

The block that returns `nil` ran in both of the two calls; the block that returns a filled value ran only once. The gain of the helper exists only for the values that enter the cache. An expensive computation can also arrive at a "none" result, and that result is recomputed every time. Code that wants to prevent this writes the empty result to the cache as a separate value, for example an empty string.

## The Key Must Say Whose Value It Is

The fragment cache is persistent and shared across clients. This causes a problem if the fragment is specific to one client. If nothing in the key separates the clients, the second client receives the fragment of the first. In the measurement two clients write different user numbers into their sessions and ask for a greeting fragment with the same key.

```ruby
# shared.rb — a client-specific fragment stored under a common key
require_relative "cache"

app = App.new
app.request("A") { |b| b.session[:user_id] = 42 }
app.request("B") { |b| b.session[:user_id] = 7 }
%w[A B].each do |client|
  fragment = app.request(client) do |b|
    cached(b, "greeting") { "<p>user #{b.session[:user_id]}</p>" }
  end
  puts "#{client} #{fragment}"
end
```
```
A <p>user 42</p>
B <p>user 42</p>
```

B saw the fragment of user 42 although B is user 7. The request that produced the fragment was A's; when B's request found the same key it did not enter the block. This is the cost of the persistent stay: the value written to the cache belongs not to the request that wrote it but to the application. Adding the user number to the key solves the problem, but then the sharing gain of the fragment is divided into as many fragments as there are users. Deciding which fragment is common to everyone and which is specific to a user is the real work of designing the key. A key written without this decision can, as seen in the measurement, carry the data of one user into the response of another, and the error shows up not as an exception but only as a wrong output.

## The Bounding Measurement

If the key does not change, that is, if no version is added, what happens when the source changes? Make two calls with the same key; in the second the block produces a different value.

```ruby
# stale.rb — content goes stale if the key stays the same
require_relative "cache"

app = App.new
p app.request("A") { |b| cached(b, "k") { "old" } }
p app.request("A") { |b| cached(b, "k") { "new" } }
```
```
"old"
"old"
```

The block of the second call never ran. The cache saw the key, returned the stored value and remained unaware that the source is now `new`. This is called **stale content**. The rule measures only the stay; whether the value is still correct is not its question, and this measurement shows why the two questions are separate. The persistent stay of the fragment cache is an advantage: the computation is shared across requests. The same stay is a cost when invalidation is not performed: the stale content is served to all clients, in the same form for everyone. For every fragment that is cached, two questions must be asked together: how expensive is this computation, and how will the cache learn that its source has changed?

## Summary

- A cache stores the result of an expensive computation; a fragment cache stores a single part of a view, and its key is the identity of the computation.
- The fragment cache is in the shared store of the application; the stay of this lesson is **persistent**, and the request of another client uses the same fragment.
- In four calls with the same key the block runs once; a different key runs the block again.
- The query cache is in the **this request** stay and the fragment cache in the **persistent** stay; the same name hides two separate stays.
- As long as the key does not change, the content goes stale; adding a version to the key stops visits to the stale entry but leaves the old entry in the cache, while deleting the entry does not swell the cache but requires the deleting code to run.
- `cached` does not store a `nil` or `false` result; a user-specific fragment stored under a common key becomes visible to another user.

## Next Step

To use a persistent fragment without it going stale, one first has to know that the fragment works correctly. The next lesson measures testing: reading the instance variable of a controller from outside, and an isolation setup in which every test builds its own application.
