Fleet-scoped admission: the tenant quota (rate_limits.scope: :fleet) and the
provider event claim (dedupe.scope: :fleet), counted in fleet.store so N
edge nodes enforce one limit and one dedupe window instead of N.
admit/3 is the request path's one entry: with both scopes :node (the
default) it is the node's own Ankusa.Edge.RateLimiter.hit/3 and nothing
else. With a fleet scope it makes one store call for the hook — the
tenant's charges and the dedupe claim together
(Ankusa.RateLimiter.Store.hit/4) — ahead of the queue writer's local dedupe
check, which still runs.
A limiter outage never refuses a hook
Every fleet call has a hard budget, fleet.timeout_ms, so a slow store costs
at most that per hook, not Redis's own timeout multiplied by the request rate.
A call that fails or times out is answered from this node alone (its own
buckets, its writer's dedupe index) and counted
([:ankusa, :fleet, :fallback]). A breaker watches the call outcomes: when
fleet.breaker_failure_rate of at least fleet.breaker_min_calls calls in a
fleet.breaker_window_ms window failed, it opens and every hook is answered
node-local with no store call at all, for fleet.breaker_open_ms; then one
probe call decides whether it closes. The breaker and its window are atomics:
the request path takes no lock and sends no message.
ankusa_fleet_limiter_degraded{kind} is 1 while a kind (rate_limit,
dedupe) is answered node-local — breaker open, or the last call failed — and
a warning is logged at most once a minute while it is.
Settling claims
A claimed event key is pending until the hook is durable
(Ankusa.Dedupe.Store). settle/3 queues the outcome in an ETS table; a
process drains it in pipelined batches every few milliseconds, so promoting or
releasing a claim is never on the 2xx's critical path and costs one round
trip per batch, not per hook. The queue holds at most 50000 operations; one
more is dropped and counted ([:ankusa, :fleet, :settle_dropped]): a dropped
promote is a pending key that expires (a possible duplicate later), a dropped
release is a key that stays pending until fleet.pending_ttl_ms (the sender is
told to retry meanwhile). Neither loses a hook.
Summary
Functions
Charge the tenant's quota and claim the hook's event key, in whatever scope the
config says. {:ok, claim} admits the hook (claim is nil when nothing was
claimed: pass it to settle/3 either way); {:duplicate, hook_id} is a hook
another node already stored; {:error, {:rate_limited, ms}} and
{:error, {:dedupe_pending, ms}} are refusals the sender retries.
Returns a specification to start this module under a supervisor.
Whether hooks are being answered from this node alone right now.
Promote a claim to committed under hook_id: the hook is in every place its
durability mode promises. Ankusa.Intake.Reconciler calls it for a hook
carried to completion.
Release a claim: nothing exists for its key.
Forget tenant's fleet buckets (its limit changed). Best effort.
Queue the outcome of the commit that followed admit/3's claim: the hook is
durable ({:ok, env}, or {:duplicate, env} — this node's own index knew it)
and the key is promoted to committed under that hook's id; anything else stored
nothing, and the claim is released. nil (nothing was claimed) is a no-op.
The breaker's state: :closed, :open, or :probing (one call is testing the store).
Types
@type admitted() :: {:ok, Ankusa.RateLimiter.Store.claim() | nil} | {:duplicate, String.t()} | {:error, {:rate_limited, pos_integer()} | {:dedupe_pending, pos_integer()}}
Functions
@spec admit(atom(), Ankusa.Source.t(), Ankusa.Envelope.t()) :: admitted()
Charge the tenant's quota and claim the hook's event key, in whatever scope the
config says. {:ok, claim} admits the hook (claim is nil when nothing was
claimed: pass it to settle/3 either way); {:duplicate, hook_id} is a hook
another node already stored; {:error, {:rate_limited, ms}} and
{:error, {:dedupe_pending, ms}} are refusals the sender retries.
@spec child_spec(keyword()) :: Supervisor.child_spec()
Returns a specification to start this module under a supervisor.
See Supervisor.
Whether hooks are being answered from this node alone right now.
@spec promote(atom(), Ankusa.RateLimiter.Store.claim() | nil, String.t()) :: :ok
Promote a claim to committed under hook_id: the hook is in every place its
durability mode promises. Ankusa.Intake.Reconciler calls it for a hook
carried to completion.
@spec release(atom(), Ankusa.RateLimiter.Store.claim() | nil) :: :ok
Release a claim: nothing exists for its key.
Forget tenant's fleet buckets (its limit changed). Best effort.
@spec settle(atom(), Ankusa.RateLimiter.Store.claim() | nil, tuple()) :: :ok
Queue the outcome of the commit that followed admit/3's claim: the hook is
durable ({:ok, env}, or {:duplicate, env} — this node's own index knew it)
and the key is promoted to committed under that hook's id; anything else stored
nothing, and the claim is released. nil (nothing was claimed) is a no-op.
@spec start_link(keyword()) :: GenServer.on_start()
@spec state(atom()) :: :closed | :open | :probing | :down
The breaker's state: :closed, :open, or :probing (one call is testing the store).