The one process every store read of a :claim_check node passes through,
so the gateway stays fast while an object store throttles or hangs:
- Coalescing — concurrent reads of one key (a pack's index, a claim's bytes, one range of them) share one store request, and every caller gets its answer.
- An adaptive limit — per store, at most
limitstore requests run at once. It starts atclaim_check.read_max_inflight, halves when the store throttles (429,503) or cannot answer — at most once per 100 ms, so a wave of requests failing together halves it once — and grows by one afterlimitsuccesses, back up to that ceiling (AIMD). A missing key is a success; a403changes nothing. - A bounded queue — a read that finds no free slot waits for one, up
to
claim_check.read_queue_maxreads per store; past that it is shed at once with{:unavailable, :overloaded}. - A deadline — every caller passes one (
claim_check.read_deadline_msafter its request started) and gets{:unavailable, :deadline}once it passes; a queued read whose deadline passed is dropped without a store request. A store request that outlives its callers still runs to the end and fillsAnkusa.ClaimCheck.Cache, so the client's retry finds it. - A node-wide request gate —
admit/2andrelease/1bound how many gateway requests the node serves at once (claim_check.max_concurrent_reads), cache hits included, so a flood of cheap reads can't starve the node. The gateway answers503+Retry-Afterpast it, before it locates anything.
The limit is per store, not per key prefix. S3 throttles per prefix, and
claim_check.key_layout: :sharded is what spreads a tenant's packs over
more of them; this bounds what one node sends a store as a whole. Every
answer is charged to the store a fetch was addressed to: an index fetch
searches every store (Ankusa.ClaimCheck.Stores.read_range/6) but is
charged to the pack's home (Ankusa.ClaimCheck.Stores.home/2).
Only a :claim_check node runs it, beside the gateway. A read anywhere
else — a dispatch node calling Ankusa.ClaimCheck.redeem/3 — goes straight
to the store, bounded only by the store's read timeout.
Emits [:ankusa, :claim_check, :read] once per caller (see fetch/5),
[:ankusa, :claim_check, :read_limit] on every change of a store's limit,
and [:ankusa, :claim_check, :read_state] every second per store it has
read from (see Ankusa.Telemetry).
Summary
Functions
Count the calling process as one of the gateway's requests in flight, unless
max already are (:shed; the caller answers 503 and does not release/1).
max is claim_check.max_concurrent_reads; 0 admits everything, and so
does a node whose reader is not running, which has no gate to count at.
Returns a specification to start this module under a supervisor.
Run fun — one read of key from claim store store — under the store's
limit, or join a fetch of key already running. deadline is an absolute
System.monotonic_time(:millisecond): past it the caller gets
{:unavailable, :deadline}, whatever the fetch is doing; the fetch it
started or joined still runs to the end and fills the cache.
Give back the slot admit/2 counted for the calling process.
Each store's limit, store requests running, and reads queued.
Types
@type key() :: {:index, tenant_id :: String.t(), pack_id :: String.t()} | {:manifest, String.t(), String.t()} | {:bytes, String.t(), String.t(), index :: non_neg_integer()} | {:range, String.t(), String.t(), non_neg_integer(), first :: non_neg_integer(), length :: pos_integer()}
What a fetch reads: coalescing is by key.
@type stats() :: %{ required(non_neg_integer()) => %{ limit: pos_integer(), inflight: non_neg_integer(), queued: non_neg_integer() } }
Functions
@spec admit(atom(), non_neg_integer()) :: :ok | :shed
Count the calling process as one of the gateway's requests in flight, unless
max already are (:shed; the caller answers 503 and does not release/1).
max is claim_check.max_concurrent_reads; 0 admits everything, and so
does a node whose reader is not running, which has no gate to count at.
The gate is a set of request processes, not a counter, so a request process that is killed before it releases is dropped by the reader's next tick instead of leaking a slot for good.
@spec child_spec(keyword()) :: Supervisor.child_spec()
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec fetch(atom(), key(), non_neg_integer(), (-> result), integer()) :: result | {:error, {:unavailable, :overloaded | :deadline | {:exit, term()}}} when result: {:ok, term()} | {:error, term()}
Run fun — one read of key from claim store store — under the store's
limit, or join a fetch of key already running. deadline is an absolute
System.monotonic_time(:millisecond): past it the caller gets
{:unavailable, :deadline}, whatever the fetch is doing; the fetch it
started or joined still runs to the end and fills the cache.
Every caller is counted once on [:ankusa, :claim_check, :read]: the
reader counts the caller whose fetch went to the store (:store, when the
fetch finishes, whether or not that caller is still waiting); every other
caller counts itself, as :coalesced when it got the fetch's answer and
:shed when it got none (the queue was full, its deadline passed, or the
reader is down).
@spec release(atom()) :: :ok
Give back the slot admit/2 counted for the calling process.
@spec start_link(keyword()) :: GenServer.on_start()
Each store's limit, store requests running, and reads queued.