The compiled, in-memory view of the route table: what the guard reads on every request, and what makes a mutation take effect everywhere at once.
Both stores (Ankusa.Routes.Store.ETS and Ankusa.Routes.Store.Redis) build
their snapshots here, so a definition produces the same pattern order and
the same id lookup whichever store it lives in.
Where it lives
One named :protected ETS table per instance (ordered_set,
read_concurrency: true), written by the routes store process: readers
never take a GenServer call or a network round trip per request, and a
mutation writes the rows it changes, not the whole table. (It used to be one
:persistent_term value rebuilt and rewritten per mutation, which costs a
global GC pass and a full copy each time.) Ankusa.Routes.TableOwner
creates it and lends it to the store, and gets it back if the store dies. A
restarted store takes the table over with adopt/1 and resumes from the
routes in it, rather than starting from its config again.
Rows, all tagged with a generation:
{:meta, %{gen, version, epoch, ip_rules, trusted_proxies, count, max_prefix}}— the one row a reader starts from;{{:lit, gen, prefix, priority, id}, enabled, methods, segments}— every route, enabled or not, in the literal-prefix index:prefixis the leading run of literal segments of its pattern (at most 8), so/hooks/stripe/:idlives under["hooks", "stripe"]and/:tenant/hookunder[]. A bucket is one contiguous run of keys in priority order;{{:id, gen, id}, route};{{:tenant, gen, tenant_id, id}}— the{tenant, id}index: one key per route, so a tenant's routes come back id-ascending from a walk over its own keys, not over every tenant's;{{:conflict, gen, pattern, method}, id}— the{compiled pattern, method}index of the enabled routes (patternis the segments as one binary,hooks/:/*), so "does an enabled route already capture this" is a lookup, not a scan.
A reader reads :meta once and uses its gen for every other row.
publish/1 (boot, a Redis reload) writes a whole new generation, then flips
:meta to it, and drops the old generation a second later, so a reader that
read the old :meta just before the flip finishes its walk on rows that are
still there. mutate/3 (one route created, replaced or deleted in the ETS
store) edits the current generation in place and writes a new :meta.
Every route is a prefix-index row, enabled or not; the guard skips a disabled
one at match time, so toggling enabled is a state change, not a recompile.
Matching
A request's candidates are the buckets of every prefix of its own segments
([], [s0], [s0, s1], … up to meta.max_prefix, at most 8): a few
short ordered walks, merged in priority order, so a miss costs the handful of
patterns that share the request's leading literals, never every pattern in
the table.
Ordering
The first matching pattern wins, so the order is the priority list, most specific first:
- descending literal-segment count —
/hooks/stripebefore/hooks/:id; - patterns without a wildcard before those with one —
/hooks/abefore/hooks/*; - route id ascending, so the order is total and never depends on map iteration.
version belongs to the store: every mutation bumps it, and a write is only
applied if its writer validated against the current one (see
Ankusa.Routes.Store). epoch belongs to the snapshot: a number drawn afresh
for every change and never reused, which the decision cache
(Ankusa.Routes.Cache) keys on, so a change makes every cached decision
unreachable without a delete pass. It is separate from version because a
store's version can start over (a restarted in-memory store, a flushed Redis)
while a cache entry keyed on it would still be alive.
Summary
Functions
Take over the table a previous routes store left behind (it survives the
store through Ankusa.Routes.TableOwner): the caller becomes its owner, any
generation other than the current one is dropped, and the current one comes
back as a store's state fields. :none when no table has been published yet
(an instance's first boot).
Would count routes fit in budget bytes (routes.memory_budget_bytes)? A
store asks before it publishes a load, so a table that does not fit is
refused at boot instead of by the OOM killer.
The enabled route that already captures route's request class — the same
compiled pattern and at least one method in common, other than route
itself — or :none. One lookup per method of route, whatever the table
holds.
How many routes the current generation holds (0 with none loaded).
Delete every row of an old generation (the owner's {:drop_generation, gen}).
What count routes are estimated to cost a node, in bytes.
One route of meta's generation.
Build the boot-time routes and ip_rules for a store from config.routes,
reporting the same failures Ankusa.Routes.validate_config!/1 already raises
as error tuples a store can {:stop, reason} on.
The current :meta row, or nil before the store has written one.
Apply one change to the current generation in place — {:put, route},
{:delete, id} or :ip_rules — write a fresh :meta (new epoch, the
store's version, ip_rules and count), and emit the change telemetry.
Returns state. Called by the owning process after it updated state.
Every other route whose pattern overlaps route's — some request path both
capture (Ankusa.Routes.Matcher.overlap?/2) — whatever its methods, whether
it is enabled, and whichever tenant owns it (each returned route carries its
tenant_id, so "does another tenant already hold part of this path space"
is a filter over the answer). limit stops the walk after that many.
Write a whole new generation without telemetry (boot, seed, reload), from
the routes store (which takes the table from Ankusa.Routes.TableOwner, or
creates its own when it runs without one). The caller must handle {:drop_generation, gen} with
drop_generation/2.
Write a whole new generation, emit the change telemetry, and return state.
The caller owns state.version — the ETS store bumps it locally, the Redis
store takes it from INCR — so no bump happens here.
Every route of meta's generation, id ascending.
Every route of meta's generation, id ascending, as a lazy stream that
starts after after_id (nil for the first). It walks the :id rows from
the cursor, so its cost is the page read, not the table.
The route matching method and segments: the first enabled route in
priority order whose pattern matches and that serves the method. A path
that matches only for other methods is {:reject, :method}, nothing at all
{:reject, :no_route}; both are 404 to the sender. Walks the buckets of the
request's own literal prefixes, merged in priority order, and stops at the
first hit.
The instance's snapshot table name.
The routes tenant owns in meta's generation, id ascending, as a lazy
stream that starts after after_id (nil for the first). It walks the
{tenant, id} index, so its cost is the page read, not the table.
Types
@type meta() :: %{ gen: pos_integer(), version: pos_integer(), epoch: pos_integer(), ip_rules: %{default: :allow | :deny, rules: [Ankusa.Routes.Route.ip_rule()]}, trusted_proxies: [term()], count: non_neg_integer(), max_prefix: non_neg_integer() }
Functions
@spec adopt(atom()) :: {:ok, %{ routes: %{required(String.t()) => Ankusa.Routes.Route.t()}, ip_rules: map(), version: pos_integer() }} | :none
Take over the table a previous routes store left behind (it survives the
store through Ankusa.Routes.TableOwner): the caller becomes its owner, any
generation other than the current one is dropped, and the current one comes
back as a store's state fields. :none when no table has been published yet
(an instance's first boot).
@spec check_budget(non_neg_integer(), pos_integer()) :: :ok | {:error, {:routes_over_budget, non_neg_integer(), pos_integer()}}
Would count routes fit in budget bytes (routes.memory_budget_bytes)? A
store asks before it publishes a load, so a table that does not fit is
refused at boot instead of by the OOM killer.
@spec conflict(atom(), meta(), Ankusa.Routes.Route.t()) :: {:ok, Ankusa.Routes.Route.t()} | :none
The enabled route that already captures route's request class — the same
compiled pattern and at least one method in common, other than route
itself — or :none. One lookup per method of route, whatever the table
holds.
@spec count(atom()) :: non_neg_integer()
How many routes the current generation holds (0 with none loaded).
@spec drop_generation(atom(), pos_integer()) :: :ok
Delete every row of an old generation (the owner's {:drop_generation, gen}).
@spec estimated_bytes(non_neg_integer()) :: non_neg_integer()
What count routes are estimated to cost a node, in bytes.
@spec fetch(atom(), meta(), String.t()) :: {:ok, Ankusa.Routes.Route.t()} | :error
One route of meta's generation.
@spec initial_table(map()) :: {:ok, %{ routes: %{required(String.t()) => Ankusa.Routes.Route.t()}, ip_rules: %{ default: :allow | :deny, rules: [Ankusa.Routes.Route.ip_rule()] } }} | {:error, {:invalid_seed, non_neg_integer(), String.t(), String.t()} | {:duplicate_seed_ids, String.t(), non_neg_integer()} | {:seed_too_large, non_neg_integer(), pos_integer()} | {:seed_conflict, non_neg_integer(), non_neg_integer(), String.t(), String.t()} | {:seed_not_a_list, term()} | {:invalid_ip_rules, String.t()}}
Build the boot-time routes and ip_rules for a store from config.routes,
reporting the same failures Ankusa.Routes.validate_config!/1 already raises
as error tuples a store can {:stop, reason} on.
The check order mirrors validate_seed!: a non-list seed, then the cap, then
per-entry validation, then duplicate ids, then compiled-segment conflicts, then
the global rules.
The current :meta row, or nil before the store has written one.
@spec mutate( map(), {:put, Ankusa.Routes.Route.t()} | {:delete, String.t()} | :ip_rules, {atom(), term()} ) :: map()
Apply one change to the current generation in place — {:put, route},
{:delete, id} or :ip_rules — write a fresh :meta (new epoch, the
store's version, ip_rules and count), and emit the change telemetry.
Returns state. Called by the owning process after it updated state.
@spec overlaps(atom(), meta(), Ankusa.Routes.Route.t(), pos_integer() | :infinity) :: [ Ankusa.Routes.Route.t() ]
Every other route whose pattern overlaps route's — some request path both
capture (Ankusa.Routes.Matcher.overlap?/2) — whatever its methods, whether
it is enabled, and whichever tenant owns it (each returned route carries its
tenant_id, so "does another tenant already hold part of this path space"
is a filter over the answer). limit stops the walk after that many.
It reads the prefix index along one chain: the buckets of every proper prefix of the route's own literal prefix, and the contiguous run of buckets at or under it (list order keeps everything that extends a prefix next to it), so the cost is a seek per level plus the routes sharing the route's leading literals, not the table. A pattern that starts with a parameter or wildcard has no literal prefix and can overlap anything, so for it the run is the whole index.
@spec publish(map()) :: :ok
Write a whole new generation without telemetry (boot, seed, reload), from
the routes store (which takes the table from Ankusa.Routes.TableOwner, or
creates its own when it runs without one). The caller must handle {:drop_generation, gen} with
drop_generation/2.
Write a whole new generation, emit the change telemetry, and return state.
The caller owns state.version — the ETS store bumps it locally, the Redis
store takes it from INCR — so no bump happens here.
@spec routes(atom(), meta()) :: [Ankusa.Routes.Route.t()]
Every route of meta's generation, id ascending.
@spec routes_after(atom(), meta(), String.t() | nil) :: Enumerable.t()
Every route of meta's generation, id ascending, as a lazy stream that
starts after after_id (nil for the first). It walks the :id rows from
the cursor, so its cost is the page read, not the table.
@spec scan(atom(), meta(), String.t(), [String.t()]) :: {:match, String.t()} | {:reject, :no_route | :method}
The route matching method and segments: the first enabled route in
priority order whose pattern matches and that serves the method. A path
that matches only for other methods is {:reject, :method}, nothing at all
{:reject, :no_route}; both are 404 to the sender. Walks the buckets of the
request's own literal prefixes, merged in priority order, and stops at the
first hit.
The instance's snapshot table name.
@spec tenant_routes(atom(), meta(), String.t(), String.t() | nil) :: Enumerable.t()
The routes tenant owns in meta's generation, id ascending, as a lazy
stream that starts after after_id (nil for the first). It walks the
{tenant, id} index, so its cost is the page read, not the table.