Skip to content

[RES-01-A] Resilience policy, admission primitive, and HTTP proxy integration #283

Description

@victornife

Note

Sub-issue of #141, which is the umbrella contract. Authority:
ADR 0017
(#116). Where this issue and the ADR disagree, the ADR wins.

Depends on: nothing. This is the foundation for 141-B, 141-C and 141-D, which are independent of
each other and may proceed in any order once this lands.

Scope

The resilience policy object, the admission primitive, and HTTP proxy integration. Nothing else.

Public configuration

Pool-scoped only; setting any of these in a location that targets a named upstream is a validation
error
.

[upstreams.resilience]
max_active_requests    = 1000   # 0 = unlimited (default)
max_active_per_backend = 0      # 0 = unlimited (default)
max_pending_requests   = 0      # 0 = NO QUEUE, reject immediately (default)
pending_timeout        = "0s"   # 0 = bounded only by the request context

max_pending_requests = 0 means no queue, not unlimited — "unlimited pending" is the unbounded-memory
failure this exists to prevent, so it must be unrepresentable.

Deliverables

  1. resilience.Policy — immutable, resolved once at load time, mirroring backendtls.Policy. Held on
    the live Pool in an atomic.Pointer. Must not enter upstreamMeta. The hot path does one pointer
    load and reads pre-parsed scalars: no tree traversal, no duration parsing, no allocation.
  2. admission — in internal/upstream, a standalone type with no *Pool dependency. This is
    an import-graph commitment: internal/stream, internal/handler and later internal/auth must all
    reach it without importing a sibling.
    • atomic.Int64 CAS fast path while active < limit; no lock is taken below the limit.
    • Slow path under mu: reject if the FIFO is full, else append a waiter and select on the waiter
      channel, the request context, and pending_timeout.
    • Release with direct handoff: if a waiter exists and active-1 < limit, hand the slot to the
      FIFO head without decrementing. Strict FIFO, no barging, monotonic recovery after a limit decrease.
    • Zero new goroutines. The parked goroutine is the inbound net/http goroutine.
    • Admit returns a release closure guarded by sync.Once, capturing the exact counter it
      incremented. No generation tags.
  3. proxyHandler.ServeHTTP — a new method; the type currently promotes ServeHTTP from the embedded
    *httputil.ReverseProxy. Admission is innermost, so a cache hit or a WAF-blocked request never
    consumes a slot, and background cache revalidation correctly does.
  4. Per-backend limit as a selection filter, not a second queue: a backend at max_active_per_backend
    is not eligible in pickExcluding. Nested admission is a deadlock generator.
  5. Lifecycle registry entries for all four paths, classified hot, plus generated mirrors. Under
    [HR-00] Make lifecycle classification granular and correct proven misclassifications #89's closed-world authority an unregistered path fails closed, so this ships here, not in 141-E.

Rejected implementations (do not reintroduce)

Counting channel (capacity is fixed at creation, and the limit is hot-reloadable);
x/sync/semaphore (no resize, unbounded waiter list); sync.Cond (cannot select on ctx.Done()); a
custom lock-free ring (no contention evidence); a timing wheel (live timers are bounded by
max_pending_requests).

Acceptance criteria

  • Exactly-once acquire/release across success, transport failure, retry, cancellation, timeout, queue
    cancellation, body close, 101 upgrade and panic.
  • Forced generation retirement wakes and rejects parked waiters. A parked request holds a
    handlerGen in-flight reference so the generation cannot retire gracefully, but retirement is also
    bounded by a forced grace timeout after which the transport is closed — without an explicit wakeup a
    parked request could be admitted onto a closed transport. pending_timeout is validated against the
    retirement grace.
  • Reload 1000 → 100 with 500 active: the 500 complete (admission is an entry control), new arrivals
    queue or get proxy_overloaded, recovery is monotonic.
  • Validation errors: stateful control in a location targeting a named upstream;
    max_pending_requests > 0 while max_active_requests = 0.
  • Validation warnings: max_active_per_backend * len(servers) < max_active_requests (static lists
    only — the pool limit is then unreachable while the queue sits empty);
    max_pending_requests > 0 && pending_timeout == 0.
  • Defaults reproduce current behaviour exactly.
  • Lifecycle registry, generated mirrors, docs and changelog for what this slice ships.

Invariants

0 <= Q_p <= max_pending_requests
0 <= A_p <= max_active_requests + delta   (delta >= 0 only after a decrease, non-increasing)
sum(A_b) <= k * A_p                       (k = 1: a request holds pool admission during backoff
                                           and selection, but a backend slot only during an attempt)

Required tests

  • Exact cap boundaries; queue full; cancel; deadline; shutdown.
  • -race acquire/release/cancel against concurrent policy swaps; assert active == 0 && pending == 0
    at quiesce and delta non-increasing.
  • The handoff-versus-cancel race, run repeatedly: a waiter granted a slot as it cancels must consume
    and release the grant, never leak it.
  • Forced-retirement waiter wakeup.
  • Reload 1000 → 100 asserted at every drain step.
  • FIFO ordering and starvation freedom.

Non-goals for this slice

Connection bounds and gRPC (141-B); FastCGI/uWSGI and Backend.Network (141-C); L4 (141-D);
cross-protocol soak and benchmarks (141-E); retry (#142); circuit (#143).

Completion evidence

- PR:
- Race + handoff/cancel runs:
- Forced-retirement wakeup test:
- 1000->100 reload test:
- Lifecycle registry + mirrors:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions