DynamicRoutes.Cache (DynamicRoutes v0.1.0)

Copy Markdown View Source

Caches resolver answers.

Without this, every request that matches no declared route costs a database query — and on a public site that is every scanner probing for /wp-login.php, forever. A cache is not an optimisation here, it is what makes the technique safe to expose to the internet.

Why it is bounded, and why that is not simply "clear when full"

The keys are paths chosen by whoever is making the request, so caching misses without a limit is a memory-exhaustion bug waiting for someone to notice it.

The obvious bound — clear everything on overflow — fails at exactly the traffic it is meant to survive. A scanner walking random paths keeps the table permanently full, so every insert is discarded and the handful of real pages are re-resolved on every request. Measured at a 1000-entry limit against 90% unique probes, ten real pages took 910 database queries for 10,000 requests instead of ten.

So there are two generations. New answers go into the current one; when it fills, it becomes the previous generation and a fresh one starts. A lookup checks both, and an answer found in the previous generation is promoted — so anything actually being requested survives rotation indefinitely, while paths seen once age out in at most two rotations.

That costs a write on a promotion rather than on every read, which is the trade an LRU makes on every single lookup.

It is not absolute. A reader arriving during the instant a rotation is swapping tables misses and resolves again — measured at well under 2% of requests even under continuous rotation with several concurrent readers, against the 91× amplification that clearing produced. And because the size check and the insert are not atomic, concurrent writers can overshoot the bound by roughly the number of them.

What the limit bounds

:cache_max_size is a count of entries per generation, so the table holds up to twice it. It bounds entries, not bytes: a path can be as long as the web server allows, and the cached value is whatever the resolver returned. Keep {:match, term} small — an id rather than a struct — if the record is large.

Paths longer than :cache_max_key_bytes are resolved but never cached, on the grounds that nothing legitimate has a four-kilobyte slug.

Configuration

config :dynamic_routes,
  cache_ttl: :timer.minutes(5),
  cache_max_size: 10_000,
  cache_max_key_bytes: 512,
  cache: true

Set cache: false to resolve on every request, which is worth doing in development so that editing a page shows up without a restart.

Summary

Functions

Returns a specification to start this module under a supervisor.

Drops every cached answer.

Drops the cached answer for one path.

Returns the cached value for a path, or computes and stores it.

How many answers are currently cached, across both generations.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear()

@spec clear() :: :ok

Drops every cached answer.

delete(path)

@spec delete([String.t()]) :: :ok

Drops the cached answer for one path.

fetch(path, fun)

@spec fetch([String.t()], (-> term())) :: term()

Returns the cached value for a path, or computes and stores it.

Falls back to calling fun directly if the cache is disabled or its process is not running, so a missing supervisor degrades to "slower" rather than "broken".

The return type is deliberately term() rather than DynamicRoutes.Resolver.resolution(): this memoises whatever it is handed and validates nothing. Narrowing it would be a promise about a resolver's behaviour that this module is in no position to make — and would tell the type checker that the caller's "a resolver returned nonsense" branch is unreachable, when it is exactly what catches a mistake in someone's code.

size()

@spec size() :: non_neg_integer()

How many answers are currently cached, across both generations.