Ankusa.Routes.Snapshot (ankusa v0.7.0)

Copy Markdown View Source

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.

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

meta()

@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

adopt(instance)

@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).

check_budget(count, budget)

@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(instance, meta, route)

@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(instance)

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

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

drop_generation(instance, gen)

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

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

estimated_bytes(count)

@spec estimated_bytes(non_neg_integer()) :: non_neg_integer()

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

fetch(instance, map, id)

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

One route of meta's generation.

initial_table(map)

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

meta(instance)

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

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

mutate(state, change, arg)

@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(instance, meta, route, limit \\ :infinity)

@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(state)

@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(state, arg)

@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(instance, map)

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

Every route of meta's generation, id ascending.

routes_after(instance, map, after_id)

@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(instance, map, method, segments)

@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(instance)

@spec table(atom()) :: atom()

The instance's snapshot table name.

tenant_routes(instance, map, tenant, after_id)

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