fix(etcd): advance the watch revision only on in-stream progress#13721
fix(etcd): advance the watch revision only on in-stream progress#13721AlinsRan wants to merge 1 commit into
Conversation
apache#12514 sampled the global etcd revision with an out of band readdir before each watch, and on a watch timeout it moved watch_ctx.rev up to that sample. A timeout only means "no bytes for watch_timeout seconds", which does not distinguish an idle prefix from a watch stream that established and then died silently. In the latter case etcd had already written the pending events into the dead stream, and skipping to the sampled revision drops them for good: the worker keeps a stale configuration until the key is written again or the worker restarts, with no error logged and no reconciliation path. apache#13067 reproduces this against master. Create the watch with progress_notify instead and only advance the revision on responses read from the stream itself. etcd sends progress notifications only to fully synced watchers, so the revision one carries is a server side guarantee that every event up to it was already delivered on that same stream, and a dead stream simply stops advancing the revision. The out of band readdir is removed, along with the RPC it cost on every watch cycle. Trade-off: notifications are sent every 10 minutes by default while an idle watch stream only lives for watch_timeout (50s), so under a default etcd the revision no longer moves while the prefix is idle and behaviour falls back to what it was before apache#12514 - a compaction cancels the watch and triggers one full reload. Operators who want to keep apache#12514's benefit can set --experimental-watch-progress-notify-interval below watch_timeout; this is documented in the FAQ and configured for the CI etcd. Paying for a bounded, self-healing reload beats silently serving a stale configuration forever. The progress notification branch has to run before the "smaller revision" check: on an idle prefix watch_ctx.rev is the last event revision plus one while a notification carries the current store revision, so it is one lower and would otherwise be read as an etcd restart and force a resync on every notification. TEST 14 asserted the removed behaviour and now asserts its absence; TEST 16 covers the notification path and checks that events are still delivered after a notification moved the revision.
|
Two things a review surfaced that belong in the description of this PR. A client-driven alternative exists and should be recorded as evaluated-but-deferred. One doc nit on the trade-off section: on etcd >= 3.6 the flag is renamed Neither blocks the direction — trading a silent-staleness optimization for the correct-but-costly compaction reload is the right call. Just make the deferred option explicit. |
Fixes #13067
Blocked on
Depends on api7/lua-resty-etcd#224 being merged and released as
v1.10.7. Once it is, this PR bumpslua-resty-etcdinapisix-master-0.rockspecfrom1.10.6-0to1.10.7-0and comes out of draft. The rockspec is deliberately left untouched for now, so CI here runs against1.10.6.Why the dependency: with
progress_notifyenabled, etcd can coalesce a notification right behind an event response in a single read, andlua-resty-etcdcurrently drops the event in that case. Merging this PR against1.10.6would reintroduce the very class of silent event loss it fixes.Problem
#12514 samples the global etcd revision with an out-of-band
readdirbefore each watch, and on a watch timeout moveswatch_ctx.revup to that sample:A timeout only means "no bytes for
watch_timeoutseconds". 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 failure is silent and does not self-heal:
sync_dataonly setsneed_reloadoncompacted/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. The only trace is one info-level log line.Reproduced on master by putting a TCP proxy between APISIX and etcd that forwards short requests normally but, for the watch stream, forwards the HTTP response headers and then silently discards the body without FIN/RST. Writes made during that window are skipped and never recovered; the route stayed 404 across many healthy watch cycles afterwards while etcd still held it. The asymmetric shape matters: if the whole link goes dark the sampling
readdirfails too,latest_revisniland the guard prevents the jump. Real-world equivalents are NAT/conntrack reaping long connections while short ones pass, asymmetric packet loss, and an etcd-side watch stall withrangestill healthy.Fix
Create the watch with
progress_notifyand advance the revision only on responses read from the stream itself.etcd sends progress notifications only to watchers that are fully synced (
watchable_store.go:progressIfSyncbails out if any watcher is unsynced), so the revision a notification carries is a server-side guarantee that every event up to it was already delivered on that same stream. A stream that dies silently stops producing them, so the revision simply stops advancing — the safe direction.The out-of-band
readdiris removed rather than kept for diagnostics: its result no longer has any safe use, it costs a range RPC on every watch cycle, and keeping it around invites reintroducing the jump.The notification branch must run before the "smaller revision" check. On an idle prefix
watch_ctx.revis the last event revision plus one, while a notification carries the current store revision — one lower. Handled after the check it would be read as "etcd may be restarted" and force a full resync on every notification.Trade-off, stated up front
etcd sends notifications every 10 minutes by default, while an idle APISIX watch stream only lives for
watch_timeout(50s by default). So under a default etcd configuration an idle stream will usually not see one, the revision stops moving while the prefix is idle, and behaviour falls back to what it was before #12514: unrelated writes advance the global revision, a compaction then cancels the watch withcompacted, and APISIX does one fullreaddirreload to recover. That is the CPU spike #12167 complained about, at compaction frequency.This is deliberate. A bounded, self-healing, observable reload is a better failure mode than an unbounded, silent, permanent configuration drift. A gateway that reads etcd once more is a slower gateway; a gateway that loses configuration is a wrong one.
Operators who want #12514's benefit back can set
--experimental-watch-progress-notify-intervalbelowwatch_timeout(e.g.30s, minimum100ms) on the etcd side. Then an idle stream is notified before it times out, never times out, keeps a fresh revision, and compaction never cancels it — same outcome as #12514 but with the server-side delivery guarantee behind it. This is documented in the FAQ and configured for the CI etcd.Older etcd versions without the flag degrade to the fixed 10-minute interval, i.e. the plain "remove the jump" behaviour.
progress_notifyitself has been in the etcd v3 watch API since its first release, so there is no compatibility floor.Alternatives considered
(rev, latest]range is free of events for this prefix before jumping.readdirhas nomin_mod_revisionfilter, so the library would need changing; verify-then-jump is not atomic, so events landing after the check are still skipped; and the cost approaches a fullreaddiranyway. Not worth it over simply not jumping.WatchProgressRequest(what the k8s apiserver does — notification frequency controlled by the client, no dependency on an etcd flag). Requires writing further request frames onto an established HTTP/1.1 chunked stream; neitherlua-resty-http's request/body_reader model norlua-resty-etcd'srequest_chunksupports that. Worth doing as a follow-up, out of scope here.progress_notifycosts ~10 lines and nothing at all when the server is not asked for it.Tests
t/core/config_etcd.tTEST 14 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 test and new test are mirrors, which pins the change in both directions.watch_timeoutso the stream stays up, idle for 10s, then asserts the notification log appears and that a route created afterwards still takes effect — i.e. the notification advanced the revision without breaking delivery.ci/pod/docker-compose.common.ymlsetsETCD_EXPERIMENTAL_WATCH_PROGRESS_NOTIFY_INTERVAL: 3sso notifications land inside a stream's lifetime. Its only other effect is that idle watch streams stop being rebuilt every 50s.Not run locally; relying on CI.
Backport
Affects 3.14.0 and later. Should be backported to the 3.14 / 3.15 release branches.