Ankusa.Routes.Store behaviour (ankusa v0.7.0)

Copy Markdown View Source

Where route definitions live. This module is both the behaviour every store implements and the instance-scoped facade the context calls, the same split as Ankusa.SourceStore: the facade resolves config.routes.store's {module, opts} and delegates.

Two stores

  • Ankusa.Routes.Store.ETS — the default. Definitions are node-local and vanish on restart; config.routes.seed is how a standalone deployment gets a durable starting point, and it is applied on every boot.
  • Ankusa.Routes.Store.Redis (in ankusa_redis) — definitions live in Redis and a version counter plus pub/sub invalidates every node. The guard still reads the in-memory snapshot, never Redis.

Definitions, never evictions

A store holds definitions, so insert/3 is capped by config.routes.max_routes and returns {:error, :too_many_routes} past it — nothing is silently evicted. An evicted definition would turn into a rejected webhook, and a rejected webhook is an operator's outage, not a cache miss.

Writes are version-checked

Ankusa.Routes validates a write — a free id, no colliding enabled route — against the snapshot it just read, so a store may only apply it if that snapshot is still current. insert/3 and replace/3 therefore take the version of the snapshot the caller validated against, and answer {:error, :stale}, having changed nothing, when the table has moved on. Before answering :stale a store MUST have published the newer snapshot, so the caller's retry validates against what is really there.

A store shared by several nodes checks that version in the shared store, atomically with the write, not against its own copy: a node whose copy lags would otherwise happily validate against a table another node already changed, and two nodes could both create the same id.

delete/3 and put_ip_rules/2 carry no cross-route validation, so they take no version: a delete answers {:error, :not_found} from the store's own authoritative state, and replacing the global rules is a whole-value write. A delete made on behalf of one tenant passes it as tenant, and the store answers {:error, :not_found} for a route another tenant owns, checked in the same step as the delete, so a route that was re-created under another tenant between the caller's read and the delete is never removed; nil deletes whoever owns it.

{:error, :store_unavailable} is the transient failure — Redis is down, or the store process did not answer, which the facade reports the same way. The in-memory store never returns it itself.

Summary

Callbacks

Called by Ankusa.Instance with [instance: instance]; read the store's own options from Ankusa.config(instance).routes.store.

Types

error()

@type error() :: :too_many_routes | :not_found | :stale | :store_unavailable

Callbacks

delete(atom, t, arg3)

@callback delete(atom(), String.t(), String.t() | nil) :: :ok | {:error, error()}

insert(atom, t, pos_integer)

@callback insert(atom(), Ankusa.Routes.Route.t(), pos_integer()) ::
  :ok | {:error, error()}

put_ip_rules(atom, map)

@callback put_ip_rules(atom(), map()) :: :ok | {:error, error()}

replace(atom, t, pos_integer)

@callback replace(atom(), Ankusa.Routes.Route.t(), pos_integer()) ::
  :ok | {:error, error()}

start_link(keyword)

@callback start_link(keyword()) :: GenServer.on_start()

Called by Ankusa.Instance with [instance: instance]; read the store's own options from Ankusa.config(instance).routes.store.

Functions

delete(instance, id, tenant)

@spec delete(atom(), String.t(), String.t() | nil) :: :ok | {:error, error()}

insert(instance, route, version)

@spec insert(atom(), Ankusa.Routes.Route.t(), pos_integer()) ::
  :ok | {:error, error()}

put_ip_rules(instance, ip_rules)

@spec put_ip_rules(atom(), map()) :: :ok | {:error, error()}

replace(instance, route, version)

@spec replace(atom(), Ankusa.Routes.Route.t(), pos_integer()) ::
  :ok | {:error, error()}