matrix-axon

ADR 0122 — A load gate between axon’s background work and the homeserver

Status: accepted (#749); steps 1 and 2 implemented, as amended below

Context

On 2026-10-17 two axon instances starved a production Synapse for about nine hours (#741). ADR 0121 (PR #746) fixes the backfill loop that did it. This ADR is about the general case: nothing in axon stops any background caller from doing the same thing.

The incident data shows why a request-rate limit would not have helped:

What has to be bounded is the work the homeserver is doing on axon’s behalf, and the only signal axon has for that is how the homeserver responds: latency, timeouts, 5xx and 429.

Who calls the homeserver without a user waiting

Inventory of axon’s own background requests as of this ADR, each with its own local bound and none aware of the others:

Caller Request Local bound today
Backfill (backfill::run, ADR 0043/0121) GET /messages One page at a time per account; 30 s page timeout
Upstream room probe (reconcile_upstream_rooms, ADR 0090) GET /state/m.room.create 8 rooms per sweep; 15 s timeout; SDK retries left on
Re-decryption sweep (redecrypt::sweep_pending_utds) key backup download per room Sequential; only the SDK’s default 30 s timeout
Known-contact DM auto-join (join_candidate_invites, ADR 0040) POST /join Sequential; rare

Out of axon’s reach: the sync long-poll and the requests matrix-sdk makes internally (key upload and query, to-device, backup bookkeeping). Requests a user is waiting on — sends, receipts, leave, room-entry, media downloads — are interactive; media downloads already share a 16-slot semaphore.

Standard patterns

Decision

1. One gate per homeserver, for background requests only

axon-sync gains a HomeserverGate, one per homeserver, shared by every account in the process that uses that homeserver. Every request in the inventory above takes a permit from it before sending:

let permit = gate.acquire(&cancel).await?;   // waits; cancellation-aware
let result = send(...).await;                 // the caller's own timeout
permit.finish(Outcome::of(&result, elapsed)); // feeds the limit

Requests a user is waiting on never wait on the gate. Where axon already times them out (the gateway’s timed_out() sites, ADR 0121 decision 5), they report their outcome, with the time the request started, so that a struggling homeserver slows background work first. Their effect is capped:

The media proxy’s downloads do not report: a slow remote media server says nothing about the homeserver’s load.

2. The limit adapts (AIMD)

Each completed gated request is classified by its outcome:

Outcome Effect
Success in under half the request’s own timeout counts toward growth
Slower success none
Timeout, 5xx, 429, transport error halve the limit
Any other 4xx (the request’s problem, not load’s) none

Admission: a request is admitted when the slots in use (active permits plus held slots, decision 4) are fewer than the limit. When a halving leaves more slots in use than the new limit, nothing is cancelled; nothing new is admitted until the count falls below the limit.

3. At zero, a circuit breaker

The breaker has three states, and every outcome has a defined transition:

Probe outcome Next state
Success, at any speed Closed at a limit of 1; INFO
Any other 4xx Closed at a limit of 1: the homeserver answered promptly
Timeout Open, n + 1; the probe’s slot is held (decision 4)
5xx, 429, transport error Open, n + 1
Dropped without finish Open with its period already over: the next waiter probes

Because the probe also waits for held slots to clear, after timeouts the recovery time is set by TIMEOUT_HOLD, not COOLDOWN. COOLDOWN governs only when the breaker opened on 5xx, 429 or transport errors, which hold no slot.

4. A timed-out request keeps its slot

When a gated request times out, its permit is not returned. The slot stays occupied for TIMEOUT_HOLD, on the assumption that the homeserver is still working on the request. The incident log measures how long that is. Every one of the 1,016 abandoned requests was dropped by axon at its 30 s timeout, and Synapse then kept working on it for a further:

A client that frees its slot on timeout keeps adding work at exactly the moment the homeserver is overloaded; that is what happened on 2026-10-17. No fixed hold matches that distribution. A 15-minute TIMEOUT_HOLD outlasts the remaining work of 43 % of the abandoned requests. Holding longer buys little: the distribution is flat past the median, so 20 minutes would cover 51 % and 30 minutes only 56 %, while every extra minute of hold delays recovery from a short slowdown. The longer tail is bounded by the breaker instead: with held slots and a doubling cooldown, an unrecovered homeserver gets at most a few probes an hour.

5. Constants

In keeping with ADR 0121 decision 3, these are constants, not configuration:

Constant Value Why
INITIAL_LIMIT 2 Backfill already pages one room at a time per account; 2 lets another caller run beside it
MAX_LIMIT 4 Background work is never urgent; a small ceiling bounds the worst case per instance
GROW_AFTER 10 Slow growth, so a recovering homeserver is not hit with the full limit at once
FAST_FRACTION ½ A success counts toward growth in under half its own timeout (decision 2)
TIMEOUT_HOLD 15 min Outlasts 43 % of the incident’s post-timeout work; 30 min would cover only 56 % (decision 4)
COOLDOWN 1 min Applies only to an open caused by 5xx/429/transport errors; matches ADR 0121’s retry base
COOLDOWN_MAX 30 min Recovery is noticed within half an hour of an outage ending

6. Visible in GET /v1/status

Status gains a homeservers array. Each entry has:

This is in-memory state, so it adds no query to an endpoint that is already expensive (see the backfill_progress caveat on /v1/status).

Consequences

Alternatives rejected

Delivery

Each step is one PR in one silo, after #746 merges:

  1. sync: the HomeserverGate with deterministic tests under paused tokio time: limit growth, the start-after-last-decrease rule, admission when slots in use exceed the limit, every row of the probe table, the cooldown reset on close, the timeout hold, and the floor of 1 for interactive reports. Backfill, the upstream probe, the re-decryption sweep and DM auto-join acquire it. GET /v1/status gains homeservers (an additive OpenAPI change, carried in the same PR as ADR 0121’s rooms_parked).
  2. server: the gateway’s interactive timeouts and 5xx feed the gate. This lands after ADR 0121’s server step, which changes the same timed_out() sites.

Amendment — step 1 as built (2026-10-24)

Building step 1 settled three things the decision above left open.

A third kind of caller: long-polls

ADR 0124 added a background caller after this ADR: the image-pack transport, a state-only Sliding Sync long-poll (30 s server-side, 90 s outer timeout). It cannot take a permit. A poll is meant to sit at the homeserver for its whole server-side timeout, so it would occupy one of the two starting slots all the time and would never count as a fast success. It is a long-poll caller instead:

Recover and manual re-decryption are interactive

The key-backup download runs in the boot re-decryption sweep, and also in the recover and manual re-decrypt sweeps, which a user is waiting on. Those two are interactive: they never wait for the gate, and report a timeout or 5xx under decision 1’s cap. All three put the 30 s outer timeout around each room’s download, so a call that used to retry under the SDK for minutes now stops at 30 s and leaves that room’s rows undecryptable until the next sweep.

A download can also be a no-op. matrix-sdk’s download_room_keys_for_room returns success without sending anything when the crypto store has no backup decryption key or version, and does not say which happened. Read as fast successes, such no-ops raised the shared limit without contacting the homeserver, and a no-op probe could close the breaker on a false recovery signal. So a download’s success teaches the gate nothing: it neither counts toward growth nor closes the breaker, and a probe that ends that way passes to the next waiter. While backups are not enabled (the usual reason for the no-op), the boot sweep’s downloads do not wait for a permit either, and are reported like interactive requests. Timeouts and server errors still count, since only a request that was sent can end in one. are_enabled matches the SDK’s precondition except at boot, before matrix-sdk has resumed backups from the store: a download sent in that window goes out without a permit. The exact precondition sits in the crypto store, which is not public API.

No caller blocks the account’s sync loop on the gate

A first cut gave these two callers a permit only when one was free at once and nobody was queued. Backfill keeps the queue non-empty for as long as it runs, which on a large account is hours, so they would have starved behind it.

Transport errors

A reqwest error is classified by kind. A connection refused or reset, or a body cut off, is overload, as the decisions above say. A request axon could not build teaches nothing, and a redirect loop or a body axon could not decode is refused. Axon’s own network failing reads as overload too, since a refused connection looks the same from axon either way; a short blip opens the breaker for at most the first one-minute cooldown.

GET /v1/status reports long-polls waiting at the gate in waiting, beside background requests.

Amendment — step 2 as built (2026-10-27)