AlinsRan opened a new pull request, #13941:
URL: https://github.com/apache/apisix/pull/13941

   ### Description
   
   A node that joins an Upstream today takes its full share of traffic 
immediately. For a JVM that has not JIT-compiled yet, a service with an empty 
local cache, or one still filling its connection pools, the scale-out meant to 
add capacity is what drives its latency up or knocks it over. Tuning node 
weights by hand does not survive autoscaling or rolling releases.
   
   This adds `warm_up_conf` to the Upstream: a node the gateway observes for 
the first time takes a reduced share of the traffic and ramps back to its 
configured weight over `slow_start_time_seconds`.
   
   ```json
   {
     "type": "roundrobin",
     "nodes": [{"host": "10.0.0.10", "port": 8080, "weight": 100}],
     "warm_up_conf": {
       "slow_start_time_seconds": 300,
       "min_weight_percent": 1,
       "interval": 1,
       "aggression": 1,
       "startup_grace_period_seconds": 180
     }
   }
   ```
   
   Without `warm_up_conf` nothing changes: no state is created, the picker 
cache key is untouched, and no shared dict is written.
   
   #### Relation to #12991
   
   #12991 proposed the same feature with the same four core fields and the same 
weight curve, which this keeps. What it does not keep is where the start of a 
ramp comes from. There, the Admin API wrote `update_time` onto each node and 
persisted it to etcd. That makes the ramp depend on the clock and the honesty 
of whoever writes the configuration, pollutes the declarative config with 
runtime state, and does nothing for nodes that never pass through the Admin API 
- every node from service discovery.
   
   Here the data plane decides on its own. `apisix/slow_start.lua` compares the 
node set of each picker build against the one the previous build recorded in a 
new `upstream-slow-start` shared dict, and generates every ramp start locally 
with `ngx.now()`. Nothing is read from the node configuration, and nothing is 
written back to etcd.
   
   #### Lifecycle
   
   - **Baseline.** The node set an Upstream has when the gateway first builds a 
picker for it is mature. That covers a cold start, a full resync, and turning 
`warm_up_conf` on for an Upstream that is already serving.
   - **New nodes** added afterwards ramp from `min_weight_percent` over 
`slow_start_time_seconds`. A node an active health check keeps out of the 
picker starts its ramp when it first becomes pickable, not when the 
configuration first mentioned it.
   - **Tombstone.** A node that leaves the Upstream keeps its state for one 
window, so coming back within it resumes the ramp. Coming back later, or being 
kept out of the picker by a health check for longer than a window, starts over 
- a different process is probably answering on that address.
   - **Identity** is host and port; a domain node is identified by its 
configured hostname, so weight, priority, metadata, order, and DNS rotation do 
not restart anything.
   - **`startup_grace_period_seconds`** keeps nodes that arrive late during a 
restart mature, so start-up ordering does not manufacture new nodes.
   
   #### Weight curve
   
   Same as #12991 and Envoy's `SlowStartConfig`:
   
   ```
   elapsed      = clamp(now - ramp_start, 0, slow_start_time_seconds)
   time_factor  = max(elapsed, 1) / slow_start_time_seconds
   ratio        = max(min_weight_percent / 100, time_factor ^ (1 / aggression))
   weight       = floor(configured_weight * ratio)   -- 0 stays 0, otherwise at 
least 1
   ```
   
   #### Request path
   
   While any node ramps, the picker cache key carries the current `interval` 
bucket, so a worker rebuilds the picker once per bucket rather than doing 
per-node work per request. Once every node is mature the key settles on a 
stable suffix, and the rebuild that causes is the one that restores the full 
weights. The hot path does a single `shdict:get` of an aggregate deadline. A 
request and its retries keep the same picker.
   
   #### Consistency across workers
   
   Workers reconcile in parallel and the shared dict has no compare-and-set, so 
state is never read and then written back blindly:
   
   - **Presence and eligibility are tracked separately.** Presence comes from 
the configuration, identical in every worker, and drives the snapshot and the 
tombstone. Eligibility is each worker's own health view, and only decides when 
a ramp starts or is interrupted. Tombstoning off the eligible set would let one 
worker's transient health opinion drop a node the others still serve.
   - **A ramp start is only created with `add`**, and only replaced through an 
election - an `add` on a key naming the start being replaced, whose winner 
every other worker adopts. Refreshes use `expire`, which leaves the value 
alone. This avoids a lock on purpose: `create_server_picker` can be reached 
from the balancer phase on a retry, where a sleeping lock is not allowed.
   
   #### Scope
   
   This first step ramps HTTP `roundrobin` Upstreams whose nodes share one 
priority. Anything else is rejected at the Admin API instead of being accepted 
and silently ignored:
   
   - another balancer type;
   - nodes with different priorities - the highest tier is drained first, so a 
ramp inside one tier cannot hold traffic back;
   - `interval` greater than `slow_start_time_seconds`;
   - a stream route that reaches such an Upstream, directly, through an 
embedded one, or through a service - plus the reverse check when `warm_up_conf` 
is added to an Upstream a stream route already reaches;
   - the Upstreams of the `traffic-split` plugin, which are rebuilt per request 
and have no stable scope.
   
   These checks run on the configuration entry points only. A configuration 
written to etcd directly, or embedded in a route or a service, never runs them, 
and must not take a whole Upstream out of service over a field that only shapes 
a ramp. At runtime the module therefore fails open: in the stream subsystem, 
with mixed priorities, or without the shared dict, nodes keep their configured 
weights and one error is logged per Upstream.
   
   #### Worth knowing
   
   - A picker rebuild restarts the round-robin cursor, which favours the 
heaviest node for its first picks. It averages out over a bucket with real 
traffic; on an Upstream seeing only a handful of requests per `interval`, a 
ramping node can get even less than its weight asks for.
   - A ramp only moves traffic between nodes. A single-node Upstream, or one 
whose nodes are all new, still sends every request to them.
   - Every APISIX instance ramps from the moment it observed the node; 
instances are not synchronised.
   - `upstream-slow-start` defaults to 10m. Its size is bounded by the nodes of 
Upstreams that enable slow start, and eviction fails open: a node whose state 
is lost keeps its configured weight.
   
   #### Which issue(s) this PR fixes:
   
   Related to #7992 and #10832, and supersedes #12991.
   
   ### Checklist
   
   - [x] I have explained the need for this PR and the problem it solves
   - [x] I have explained the changes or the new features added to this PR
   - [x] I have added tests corresponding to this change
   - [x] I have updated the documentation to reflect this change
   - [x] I have verified that this change is backward compatible (If not, 
please discuss on the [APISIX mailing 
list](https://github.com/apache/apisix/tree/master#community) first)
   
   #### Tests
   
   - `t/node/upstream-slow-start.t`: the weight curve and its clamping; a node 
added to a running Upstream ramping while the existing one stays at full 
weight; the ramp ending at the configured weights; an unrelated change not 
restarting it; the tombstone resuming a ramp; a route-embedded Upstream as its 
own scope; the picker key moving once per `interval` and then settling; a node 
an active health check sidelines keeping its lifecycle; a sidelined node 
starting its window on first entry; and two workers racing on the same state, 
replayed deterministically by injecting another worker's write between a read 
and the write that follows it.
   - `t/admin/upstream-slow-start.t`: field ranges, defaults, every rejection 
above in both directions, an unreadable etcd reference failing validation 
rather than the request, declarative validation through 
`/apisix/admin/configs/validate`, the data plane keeping an Upstream whose 
`warm_up_conf` it cannot honour, and enabling `warm_up_conf` in a cluster with 
no stream route at all.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to