# `Ankusa.Edge.Fleet`
[🔗](https://github.com/jamescarr/ankusa/blob/ankusa-v0.7.0/packages/ankusa/lib/ankusa/edge/fleet.ex#L1)

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
(`c: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.

# `admitted`

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

# `admit`

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

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

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `degraded?`

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

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

# `promote`

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

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

Release a claim: nothing exists for its key.

# `reset_tenant`

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

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

# `settle`

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

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

# `state`

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

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

---

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