Ankusa.Edge.Fleet (ankusa v0.7.0)

Copy Markdown View Source

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

admitted()

@type admitted() ::
  {:ok, Ankusa.RateLimiter.Store.claim() | nil}
  | {:duplicate, String.t()}
  | {:error, {:rate_limited, pos_integer()} | {:dedupe_pending, pos_integer()}}

Functions

admit(instance, source, env)

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

child_spec(init_arg)

@spec child_spec(keyword()) :: Supervisor.child_spec()

Returns a specification to start this module under a supervisor.

See Supervisor.

degraded?(instance)

@spec degraded?(atom()) :: boolean()

Whether hooks are being answered from this node alone right now.

promote(instance, claim, id)

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

release(instance, claim)

@spec release(atom(), Ankusa.RateLimiter.Store.claim() | nil) :: :ok

Release a claim: nothing exists for its key.

reset_tenant(instance, tenant)

@spec reset_tenant(atom(), String.t()) :: :ok

Forget tenant's fleet buckets (its limit changed). Best effort.

settle(instance, claim, arg3)

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

start_link(opts)

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

state(instance)

@spec state(atom()) :: :closed | :open | :probing | :down

The breaker's state: :closed, :open, or :probing (one call is testing the store).