Skip to content

Invariant 2 overstates what a cache can do: a revisit is free only inside the freshness window #49

Closed

Description

@sotashimozono

The claim

README.md, invariant 2:

A revisit costs zero remote round trips; conditional GET is answered inside localhost.

Measured

e2e/probe.mjs against real sites on a host over SSH, loading a page and then reloading it immediately:

site first load revisit
Documenter output (QAtlas.jl docs) 26 13
Pinax board, 24 figures 52 23
Pinax board, 547 figures 55 30
small report, 9 requests 18 0

Only the one that loads in under a second is free.

Cause

cache::DEFAULT_TTL is two seconds. Those pages take 2.5–3.6 s to load. The listings fetched at the start of a load have expired before the load finishes, so the reload re-fetches all of them.

Confirmed by changing only that constant. With the TTL at 120 s and nothing else touched:

site first load revisit
Documenter output 26 0
Pinax board, 24 figures 47 0
small report 21 0

The first-load cost barely moves, so this is entirely about the second visit. The invariant is exactly true with a longer TTL and false with this one.

The file bodies are not the problem — they are cached by (backend, path, mtime, size) and the conditional GET is answered locally as the README says. It is the listings that expire, and a listing is needed to resolve a path at all.

The trade

Two seconds was presumably chosen so that a file changed on the remote shows up almost at once — which is the right feel for "I just saved this, show me". A longer TTL is the right feel for "this is a built site, let me read it". Both are real, and the current value silently picks the first while the README promises the second.

Options, roughly in order of how much they cost to build:

  1. Raise the default. Simplest, and makes the documented invariant true. Costs the live-editing feel, and the number is still a guess.
  2. Make it configurable, with the default still to be chosen — so this does not answer the question, only moves it.
  3. Revalidate instead of expiring. Keep the listing indefinitely and check it against the remote only when a request would otherwise miss. A directory's own mtime changes when an entry is added or removed, so one stat per directory can confirm a whole listing — and that is a round trip, which is what the invariant is counting. Cheaper than re-listing, not free.
  4. Tie freshness to the reader. A reload is a deliberate act and could be the thing that invalidates, rather than the clock.

My recommendation is (1) for now with a number chosen against the measurement rather than by feel, and (3) as the real answer — but the freshness/consistency trade is a product decision, not a bug with one right fix.

Whichever is chosen

The invariant has no test against a real transport. a_revisit_costs_no_remote_round_trips passes today because its fixture loads instantly, so the TTL never expires during it. A check that reloads after a delay longer than the TTL would have caught this from the start.

Activity

  1. sotashimozono commented on Sep 13, 2026

    @sotashimozono
    MemberAuthor

    Correction: the cause I gave was wrong, and so were two of the numbers

    I dug into this and most of what I wrote above does not survive. Recording it properly rather than quietly editing.

    The daemon's cache is not the problem

    Driven directly, with no browser, it does exactly what invariant 2 says:

    index.html 1st  ->  6 trips, 200
    index.html 2nd  ->  0 trips, 200
    pdf        1st  ->  6 trips, 200
    pdf        2nd  ->  0 trips, 200
    pdf range       ->  0 trips, 206
    conditional     ->  0 trips, 304
    

    Same through the alias Host and through the loopback fallback. Repeat reads cost nothing, ranges are sliced out of the held body, and the conditional GET is answered locally.

    Two of my measurements were instrument error

    page.reload() is not a revisit. A reload tells the browser to re-fetch. I should have navigated away and back. (Both turn out to give similar numbers here, but the first measurement was not of the thing being claimed.)

    "0 conditional requests" was false. I read request.headers(), which is the pre-flight snapshot and does not include headers the browser adds later. Conditional requests are being sent — the responses include 304s, which cannot happen otherwise.

    Most of the request count was not ours. Of 412 browser requests on the Pinax board, 49 went to our origin. The other 342 are Chrome's own PDF viewer loading its internals (pdf_embedder.css, mojo/.../bindings.js) once per embedded PDF. The board has 24 PDFs in it.

    What the revisit cost actually depends on

    Not the page, and not the site. How long you wait before revisiting.

    revisit, straight after revisit after ~3 s idle
    small report 0 10
    Documenter output 5 17
    Pinax board, 24 PDFs 22 21

    That is the freshness window behaving exactly as designed. Once a listing is not trusted, "is this still the current version?" cannot be answered without asking — so a revisit outside the window costs a listing refresh, by construction.

    So the real problem is the claim, not the code

    A revisit costs zero remote round trips; conditional GET is answered inside localhost.

    The second half is true and verified. The first half is true inside the freshness window and cannot be true outside it. As written it promises something no cache can deliver, and I should have read it that way before filing this as a bug.

    What I tried, and am not proposing

    I built the listing TTL as an idle window (fresh until 2 s after last use, capped at 30 s absolute), so that a page loading for four seconds cannot expire its own listings halfway through. It is a sound idea and it passed neutered tests.

    It made no measurable difference. Load cost on the Pinax board was 48–50 round trips either way; on the Documenter page it moved from 26 to 34, in the wrong direction and inside the run-to-run spread. I have reverted it. A behaviour change to a public API on the strength of an argument that the measurement does not support is not worth shipping, and this repository's whole method is the other way round.

    What is actually worth doing

    1. Fix the README so invariant 2 says what is true: zero within the freshness window, and one batched listing refresh outside it. That is still a strong claim and it is the one that holds.
    2. Test it against a real transport. a_revisit_costs_no_remote_round_trips passes because its fixture loads instantly. A check that revisits inside the window, over SSH, would pin the part that is real.
    3. Leave the PDF case alone. Chrome's PDF viewer costing 342 requests to its own internals is not something this daemon can or should do anything about.

    Retitling would be fair — this is "the README overstates invariant 2", not "a revisit is not free".

  2. changed the title [-]A revisit is not free on a real site: the listing TTL is shorter than a page load[/-] [+]Invariant 2 overstates what a cache can do: a revisit is free only inside the freshness window[/+] on Sep 13, 2026
  3. sotashimozono commented on Sep 13, 2026

    @sotashimozono
    MemberAuthor

    Closed by #51.

    The README now says invariant 2 in the form that is true — zero inside the freshness window, one batched listing refresh outside it — and the e2e checks it over the real SFTP transport, where the claim is exact: a second read costs nothing, a range out of a held body costs nothing, and the browser's validator is answered locally with a 304. All three fail against a zero TTL; the unit tests pass unchanged under the same neutering, which is why both exist.

    The bug I originally filed here did not exist. What did exist was an overstated claim and three measurements that disturbed what they measured. Both are fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions