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 with its original expiry — so anything actually being requested survives rotation for as long as its TTL allows, while paths seen once age out in at most two rotations. Promotion deliberately does not refresh the TTL: that would let a hot key be served stale forever.
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: trueSet 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
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec clear() :: :ok
Drops every cached answer.
@spec delete([String.t()]) :: :ok
Drops the cached answer for one path.
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.
@spec size() :: non_neg_integer()
How many answers are currently cached, across both generations.