fix(etcd): do not advance the watch revision on a timeout, and make the recovery reload cheap - #13721
Open
AlinsRan wants to merge 5 commits into
Open
fix(etcd): do not advance the watch revision on a timeout, and make the recovery reload cheap#13721AlinsRan wants to merge 5 commits into
AlinsRan wants to merge 5 commits into
Conversation
AlinsRan
marked this pull request as ready for review
July 29, 2026 04:14
AlinsRan
marked this pull request as draft
July 29, 2026 05:57
apache#12514 samples the global etcd revision with an out-of-band readdir before each watch and, when the watch times out, moves watch_ctx.rev up to that sample. A timeout only means that no bytes arrived for watch_timeout seconds. It does not distinguish an idle prefix from a watch stream that established and then died silently. In the second case etcd has already written the pending events into the dead stream, so skipping to the sampled revision drops them permanently. The loss does not heal. sync_data only sets need_reload on compacted or restarted, and after the jump the start revision is fresh, so compaction never fires either. The worker serves a stale configuration -- deleted routes still routing, new routes and certificates never applied -- until that key is written again or the worker restarts, with nothing but one info level log line to show for it. Reproduced by putting a TCP proxy between APISIX and etcd that forwards short requests normally but, for the watch stream, forwards the response headers and then silently discards the body without FIN or RST. Writes made during that window are skipped and never recovered. The asymmetry matters: when the whole link goes dark the sampling readdir fails too, latest_rev is nil and the guard prevents the jump. Real world equivalents are NAT or conntrack reaping long connections while short ones pass, asymmetric packet loss, and an etcd side watch stall with range still healthy. Removing the jump restores the pre-apache#12514 behaviour: an idle prefix can fall behind compaction again and recover with a full readdir. That is a bounded, self-healing and observable cost, unlike the silent permanent configuration drift it replaces. Fixes apache#13067
AlinsRan
force-pushed
the
fix/etcd-watch-progress-notify
branch
from
August 3, 2026 08:22
061cc75 to
da56b54
Compare
A full reload is how APISIX recovers from a compacted watch, and today it rebuilds everything unconditionally: every item is re-validated through check_schema, the checker and the filter, and load_full_data sets `changed` as soon as any item is valid, so conf_version always moves and every router rebuilds its radixtree. The item tables are new objects too, so downstream caches keyed on them all miss. None of that is necessary when nothing actually changed, which is the common case for the deployment that suffers from this: a prefix idle enough to fall behind compaction is a prefix whose configuration did not change. Compare each key against the previous snapshot and reuse the item when the modifiedIndex matches. etcd increments mod_revision on every write, so an equal modifiedIndex means equal content. A reload that changes nothing now keeps the existing objects, leaves conf_version alone and rebuilds no routers. This is the same semantics the incremental watch path already has: sync_data re-runs the checker and filter only for the keys that changed, and leaves the other items untouched. Every filter but /plugins' only mutates fields of the item it is given, so an item that was filtered once is already in its filtered state; /plugins is single_item and is left out of the optimisation because its filter calls plugin.load(), which has global effects. Deletions need an explicit check. Keys that vanished while we were not watching leave every surviving key untouched, so `changed` would stay false, conf_version would not move, and the routers would go on serving the deleted items. Fixes apache#12167
AlinsRan
marked this pull request as ready for review
August 4, 2026 00:39
…rrors Addresses review feedback on the new coverage. TEST 14 only asserted the absence of a log line, which would also hold if the watch were broken outright. Write under the watched prefix once the timeouts have happened and assert the event is still delivered, so the test pins the behaviour from both sides. The new tests also ignored the return value of every etcd_cli:set, so a failed setup surfaced as a confusing assertion mismatch rather than an error. Check the writes and bail out with a message. Cleanup deletes only warn: a cleanup failure should not mask the result of the assertions that already ran.
CI hit 'client socket timed out' on this block, deterministically -- the rerun failed the same way. The block had no explicit --- timeout, and the added write plus wait pushed it past whatever the inherited default resolves to, so the request never returned and both assertions saw an empty body. Declare --- timeout: 20 as the other tests in this file do, drop the redundant sleep after cleanup, and poll for the delivery instead of sleeping a fixed second. A fixed wait is only a guess about how slow the etcd round trip gets on a loaded runner, and guessing low turns this into a flaky assertion failure rather than a real signal.
Several of them restated what the code says or, worse, explained code that is no longer there: the seven-line note about not advancing the revision on a timeout sat next to the start_revision assignment, forty lines away from the branch it described, and reproduced an argument the commit message and the issue already carry. Keep only what is not obvious from the code: that the reuse branch leaves `changed` alone on purpose, that this matches sync_data, and that a deletion has to be detected separately. Rename matched_prev to prev_keys_still_present so the counter explains itself instead of needing a comment.
Member
|
I opened #13777 to track the remaining etcd read pressure. It proposes using |
nic-6443
approved these changes
Aug 5, 2026
shreemaan-abhishek
approved these changes
Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #13067
Fixes #12167
Two commits, one for each issue. They belong together: the first removes an unsafe way of avoiding the recovery reload, the second makes that reload cheap enough not to need avoiding.
fix(etcd): do not advance the watch revision on a timeoutperf(etcd): reuse unchanged items on a full reloadPart 1 — #13067: silent, permanent config loss
The problem
config_etcd.luasamples the global etcd revision on a separate connection before each watch, then jumps to it if the watch times out:A timeout only means "no bytes arrived for
watch_timeoutseconds". That is equally true of an idle prefix and of a stream that established and then died silently — and the code cannot tell them apart:The root of it: the health of one connection is used to infer the delivery progress of another. Those two facts are unrelated.
It never heals
sync_dataonly setsneed_reloadoncompacted/restarted. After the jump the start revision is fresh, so compaction never fires either — nothing will ever go back for the skipped range.The worker keeps serving a stale configuration until that key is written again or the worker restarts:
Each worker has its own watcher, so some workers can be stale while others are current.
The fix
Delete the sampling and the jump.
Reproducing it
Needs an asymmetric fault: the watch stream goes dark while short requests keep working. If the whole link fails, the sampling
readdirfails too,latest_revisnil, and the existing guard already prevents the jump.Option 1 — iptables, no code needed
Result:
etcdctl get /apisix/routes/blackholereturns the route, but/blackholestays 404 — across every healthy watch cycle that follows, indefinitely.Option 2 — selectively silent TCP proxy (what I used)
Put a proxy between APISIX and etcd, point
deployment.etcd.hostat it, and have it read the first request line:POST /v3/watch→ forward the request, forward the response headers, then silently discard the body and hold the connection open (no FIN, no RST)/v3/kv/range, …) → forward normallyThen follow steps 4–5 above.
Real-world equivalents: NAT/conntrack reaping long-lived connections while short ones pass, conntrack tables being flushed (kube-proxy restart,
iptables -F, CNI rebuild), asymmetric packet loss, an etcd-side watch stall withrangestill healthy.Alternatives considered
Verify the
(rev, latest]range is event-free before jumping — killed by deletesWorth spelling out, because it looks like it should work.
RangeRequestdoes havemin_mod_revision/max_mod_revision, so the check is expressible. Non-atomicity is not the problem either: an event landing after the check carries a revision abovelatest, so the next watch starting atlatest + 1still receives it.What kills it is deletion. A key deleted inside
(rev, latest]is not in the range result at all — the check comes back empty, the jump is taken, and the delete event is lost for good. Deletes are the worst case to lose: a removed route keeps serving traffic. No range-based verification can see them.Use
progress_notifyand advance only on in-stream notifications — correct, but inert by defaultetcd only sends progress notifications to fully synced watchers, so the revision one carries is a server-side guarantee that everything up to it was already delivered on that same stream. Correct in principle.
The catch: the progress ticker is created per stream and defaults to 10 minutes, while an idle APISIX watch stream only lives for
watch_timeout(50s). Under a default etcd it never fires. It would be inert unless the operator also sets--watch-progress-notify-interval(--experimental-watch-progress-notify-intervalbefore etcd 3.6; the flag only exists since 3.4.11).An ordering-sensitive new branch, a
lua-resty-etcdversion floor and an FAQ entry, for something that does nothing out of the box — not worth it next to Part 2. Can be revisited on its own merits.Part 2 — #12167: the CPU spike this used to paper over
Removing the jump means an idle prefix on a shared etcd can fall behind compaction again and recover with a full
readdir. That is exactly what #12167 reported, so it gets fixed properly rather than avoided by guesswork.The reporter's ask is specific — "APISIX CPU usage fluctuates when 'compacted' errors occur. I want to avoid this problem." Not the log line: the CPU.
Why the reload is expensive
Recovery from
compactedrebuilds everything unconditionally:check_schema, thecheckerand thefilterload_full_datasetschangedas soon as any item is valid, soconf_versionalways moves and every router rebuilds its radixtreeAnd it happens for every resource type — routes, services, upstreams, consumers, ssls, global_rules, plugin_configs … — in every worker, because
need_reloadis per config instance andproduce_res(nil, "compacted")broadcasts to all of them.None of that work is necessary when nothing actually changed — which is precisely the case here. A prefix idle enough to fall behind compaction is a prefix whose configuration did not change.
The fix
Compare each key against the previous snapshot and reuse the item when
modifiedIndexmatches:etcd increments
mod_revisionon every write, so an equalmodifiedIndexmeans equal content. Theprev_values/get_prev_itemplumbing already exists — it was added so an item whose new data fails validation can keep serving its last valid value.Why skipping the filter is safe
The incremental watch path already works this way:
sync_datare-runs the checker and filter only for the keys that changed, and never touches the other items. This aligns the reload path with the watch path rather than inventing new semantics.Checked every filter individually:
/routeshas_domain,set_plugins_meta_parent, host lowercasing,filter_upstream/services/upstreamshas_domain,filter_upstream/consumers,/consumer_groups,/global_rules,/plugin_configsset_plugins_meta_parent/ssls/pluginsplugin.load(item)— rebuilds the global plugin registry, touches nothing on the itemThe first eight are idempotent and only mutate fields of the item they are handed, so an item filtered once is already in its filtered state.
/pluginsis the one exception and is handled by construction rather than by a special case: it is the only config in the tree declaredsingle_item(plugin.lua:893), and the reuse branch lives entirely in the multi-itemelsebranch ofload_full_data. Thesingle_itempath is untouched, soplugin.load()still runs on every reload. Reusing there would skip the registry rebuild — newly enabled plugins would not take effect and disabled ones would keep running — and it would save nothing anyway, since there is exactly one item.Deletions need an explicit check
This is the trap. Keys that vanished while we were not watching leave every surviving key untouched, so
changedwould stayfalse,conf_versionwould not move, and the routers would go on serving the deleted items:What this does not do
The
readdiritself still happens on everycompacted: without reading the full snapshot there is no way to know what was missed. The transfer and JSON parse remain. What goes away is the rebuild on top of it — the part that scales with configuration size and shows up as the spike.Tests
Every assertion below was verified to be discriminating — a test that passes either way proves nothing.
TEST 14 (Part 1) asserted exactly the removed behaviour (
etcd watch timeout, upgrade revision toappearing at least twice). Same topology, inverted assertion: the log must not appear at all. Old and new are mirrors, pinning the change in both directions.TEST 19 (Part 2) — a reload with nothing changed. Two independent probes: a tag on the
valuesarray (a reload always allocates a fresh one, so losing it proves the reload really ran) and a tag on the item inside it (which must survive). Assertsconf_versionmoved once for the incremental write that wakes the watcher, not twice. Against unpatchedmaster:TEST 20 (Part 2) — a reload whose only change is a deletion. This one passes on unpatched
master(which bumps unconditionally), so it was verified against the variant that actually matters: the reuse optimisation with the deletion check disabled:Local full-file run: the failure set is identical with and without this branch (TEST 3/4/5/6/9/12, which need a TLS etcd on :12379 that this machine does not have), except that TEST 14 and TEST 19 flip from failing to passing. TEST 16/17/18 — the existing full-reload tests — still pass.
The blackhole scenario cannot be expressed in test-nginx (it needs a selectively silent TCP proxy), so it stays the manual procedure above.
Backport
Part 1 affects 3.14.0 and later and should be backported to the 3.14 / 3.15 release branches. Part 2 is a performance change and can follow or not, as maintainers prefer — the two commits are separable if you would rather backport only the first.