Internals: decision records
ADR-0049: Upstream pools and run-time members
Status: accepted, 2026-10-14
Context
A proxy runtime took one upstream address. There were no pools of members, no load
balancing, no session affinity, no passive health tracking, no retries to another member
and no weighted split between backends; a plain load balancer in front of several
application servers needed another product. Kubernetes phase 2 makes ScalWS an ingress
controller (phase 2b: Ingress, phase 2c: Gateway API). In a cluster, pod addresses change
all the time, and the only way to change an address was a full configuration reload, which
rebuilds every changed proxy and used to reset per-application rate-limit state. Phase 2a
adds the data-plane features both phases need, without any Kubernetes code.
Decision
- Pools by name. A top-level
upstreamsmap defines pools (names follow the tenant and application name rule). A proxy runtime uses one withupstream: {pool: name}, or several withbackends: [{pool, weight}](weights 0 to 1000, at most 64, at least one above 0, chosen per request by smooth weighted round robin).upstreamandbackendsare mutually exclusive. The single-address form (http://,https://,unix:) keeps the existingProxyHandlerpath: no retries, no ejection, no new allocations per request.preserve_host, the timeouts andwebsocketstay on the runtime;clientis ignored on pool routes with a warning (members are shared between routes, so they useserver.upstream_client), andtls_ca_fileis refused there (pools havetls). - Members.
ip:port,[ipv6]:port,host:port(resolved at connect time) orunix:/path(only as the single member of a plain-HTTP pool); weight 1 to 1000; no duplicates; at most 10,000 per pool. Each member has its own connection pool (keepaliveidle connections, default 32, at most 1024). members_from.file(default): the configuration’smembers.api: the member list is replaced at run time through the admin API; the file’smembersonly seed a new pool.- Selection.
round_robinis smooth weighted round robin (nginx).least_connpicks the fewest in-flight requests divided by weight, ties round robin.hashis weighted rendezvous hashing (weight / -ln(u),ufrom a stable 64-bit hash of key and member), so removing a member moves only its keys; the key is an affinity cookie, a request header or the client address aftertrusted_proxies. A missing key falls back to round robin. - Affinity cookie. The value is the first 16 bytes of HMAC-SHA256 of the member’s
canonical address under a random key generated per process, in lower-case hex. It reveals
nothing and cannot name an arbitrary member. Attributes:
Path=/; HttpOnly; SameSite=Lax,Max-Agefromcookie_max_age(0: session cookie),Secureon HTTPS requests. Set only when a member answered and the request did not already hold it; appended next to the member’s own cookies. - Passive health. A failure is a connect error or timeout, no response head within
response_timeout, a connection broken after the request was written, or a502,503or504. Failures caused by the client’s request body (over the body limit, a body read timeout, an aborted upload, or an upload still waiting for client data when the response timeout passes) are neutral: no health change, no retry. Afterfailuresconsecutive failures (default 3, 0 turns it off, at most 100) the member is ejected forcooldown(default 10 s, 1 ms to 1 h), then it is on probation: one success restores it, one failure ejects it again. Results of requests that were in flight when the member was ejected are ignored, so an ejection is never extended or ended by stale results. When every member is ejected, the one ejected longest ago is used: passive health alone never refuses traffic. No active checks. - Retries. At most
retries(default 1, 0 to 5) further attempts, each on a member not tried yet, and only when the pool has more than one member, nothing was sent to the client, and either the connection failed before the request was written (any method), or the method is idempotent (GET, HEAD, OPTIONS, PUT, DELETE) and the member answered502,503or504or timed out. The body must be replayable: bodies with aContent-Lengthof at most 64 KiB are kept in memory; larger bodies and bodies without a length (also short chunked ones) are sent once. A connection broken after the request was written counts for health but is not retried. WebSocket upgrades go to one member, without retry. - Run-time members.
PUT /v1/upstreams/{name}with{"members": [...]}on the admin socket or the mTLS listener replaces the list of anapipool:404unknown pool,409file pool,400invalid list (nothing changes),413body over 2 MiB,200with the pool’s state. It takes the same lock as reloads (Control.lock), so a reload never plans member changes against a set aPUTis replacing. A new member set is built beside the current one and swapped in atomically; members whose canonical address stays are the same objects, with their connections, load and health. Planning (new members’ connectors) and pruning metric series run on the blocking pool while the lock is held;httpsmembers of a pool share one TLS client configuration, so the system roots are read once per pool, not per member.GET /v1/upstreamsandGET /v1/upstreams/{name}show pools, members, weights and state; these views are not served on the unauthenticated metrics listener. - Reload. Pools are matched by name. A pool whose settings (everything except
members) are unchanged is kept; a kept file pool gets its new member list planned during prepare and installed after the generation swap, under the reload lock. A pool whose settings changed is rebuilt; an api pool keeps its run-time members in either case (also when it switches fromfiletoapi). Per-application rate-limit and quota state is kept when the limit’s definition is unchanged. After a process restart an api pool starts with the file’smembers, or empty. - Metrics.
scalws_upstream_requests_total{pool, member, class},scalws_upstream_request_duration_seconds{pool},scalws_upstream_member_ejected{pool, member},scalws_upstream_pool_retries_total{pool}andscalws_upstream_members{pool}. The retries family is not namedscalws_upstream_retries_total: that name already counts stale-connection retries by tenant, application and protocol (ADR-0043). Series of removed members and pools, and of pools a refused reload built, are dropped; member series are bounded by the 10,000 member limit. The access log gainsupstream_memberandupstream_attempts. - Tools.
scalwsctl upstreams,upstreams show <pool>andupstreams set <pool> addr[=weight]...; a read-only Upstreams view in the console.
Consequences
- The affinity secret is per process: a restart moves every cookie client once, and
several instances behind
SO_REUSEPORTor scalws-controller do not share affinity, balancing or health state.by: headerandby: client_ipgive the same member on every instance. - Under scalws-controller (
listener_mode: inheritedorreuseport) thePUTanswers501, on the instances and on the controller’s control socket, because the instances of a generation would disagree about the members; file pools work. The ingress pods of phase 2b runscalwsdalone. Sharing run-time members across instances is left for a later phase. - Members of api pools are lost on restart; whatever maintains them must set them again.
- A request body of up to 64 KiB is buffered when a retry is possible; nothing is buffered
for single-member pools or
retries: 0. - A configuration that keeps the single-address form behaves and performs as before.