# `Ankusa.Routes.Snapshot`
[🔗](https://github.com/jamescarr/ankusa/blob/ankusa-v0.7.0/packages/ankusa/lib/ankusa/routes/snapshot.ex#L1)

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*: `prefix` is the
    leading run of literal segments of its pattern (at most 8), so
    `/hooks/stripe/:id` lives under `["hooks", "stripe"]` and `/:tenant/hook`
    under `[]`. 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 (`pattern` is 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:

  1. descending literal-segment count — `/hooks/stripe` before `/hooks/:id`;
  2. patterns without a wildcard before those with one — `/hooks/a` before
     `/hooks/*`;
  3. 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.

# `meta`

```elixir
@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()
}
```

# `adopt`

```elixir
@spec adopt(atom()) ::
  {:ok,
   %{
     routes: %{required(String.t()) =&gt; 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).

# `check_budget`

```elixir
@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.

# `conflict`

```elixir
@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.

# `count`

```elixir
@spec count(atom()) :: non_neg_integer()
```

How many routes the current generation holds (0 with none loaded).

# `drop_generation`

```elixir
@spec drop_generation(atom(), pos_integer()) :: :ok
```

Delete every row of an old generation (the owner's `{:drop_generation, gen}`).

# `estimated_bytes`

```elixir
@spec estimated_bytes(non_neg_integer()) :: non_neg_integer()
```

What `count` routes are estimated to cost a node, in bytes.

# `fetch`

```elixir
@spec fetch(atom(), meta(), String.t()) :: {:ok, Ankusa.Routes.Route.t()} | :error
```

One route of `meta`'s generation.

# `initial_table`

```elixir
@spec initial_table(map()) ::
  {:ok,
   %{
     routes: %{required(String.t()) =&gt; 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.

# `meta`

```elixir
@spec meta(atom()) :: meta() | nil
```

The current `:meta` row, or `nil` before the store has written one.

# `mutate`

```elixir
@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`.

# `overlaps`

```elixir
@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.

# `publish`

```elixir
@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`.

# `publish`

```elixir
@spec publish(map(), {atom(), String.t() | nil}) :: map()
```

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.

# `routes`

```elixir
@spec routes(atom(), meta()) :: [Ankusa.Routes.Route.t()]
```

Every route of `meta`'s generation, id ascending.

# `routes_after`

```elixir
@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.

# `scan`

```elixir
@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.

# `table`

```elixir
@spec table(atom()) :: atom()
```

The instance's snapshot table name.

# `tenant_routes`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
