-
Notifications
You must be signed in to change notification settings - Fork 26
feat(etcd): ConfigProvider trait + watch supervisor #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| //! Exponential backoff used by the watch supervisor when reconnecting to | ||
| //! etcd (spec §2: 1s → 2 → 4 → 8 → 16 → 32 → 60s max). | ||
| //! | ||
| //! This is a pure data structure — it returns durations. The calling task is | ||
| //! responsible for actually sleeping. | ||
|
|
||
| use std::time::Duration; | ||
|
|
||
| pub const BASE_MS: u64 = 1_000; | ||
| pub const MAX_MS: u64 = 60_000; | ||
|
|
||
| #[derive(Debug, Clone)] | ||
| pub struct ExpBackoff { | ||
| current_ms: u64, | ||
| base_ms: u64, | ||
| max_ms: u64, | ||
| } | ||
|
|
||
| impl Default for ExpBackoff { | ||
| fn default() -> Self { | ||
| Self::new(BASE_MS, MAX_MS) | ||
| } | ||
| } | ||
|
|
||
| impl ExpBackoff { | ||
| pub const fn new(base_ms: u64, max_ms: u64) -> Self { | ||
| Self { | ||
| current_ms: base_ms, | ||
| base_ms, | ||
| max_ms, | ||
| } | ||
| } | ||
|
|
||
| /// Return the current delay and advance for the next call. Saturates at | ||
| /// `max_ms` so long-running reconnect loops don't balloon past 60s. | ||
| pub fn next_delay(&mut self) -> Duration { | ||
| let d = Duration::from_millis(self.current_ms); | ||
| self.current_ms = (self.current_ms.saturating_mul(2)).min(self.max_ms); | ||
| d | ||
| } | ||
|
|
||
| /// Reset the backoff after a successful reconnect so the next failure | ||
| /// restarts at `base_ms`. | ||
| pub fn reset(&mut self) { | ||
| self.current_ms = self.base_ms; | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn doubles_then_saturates_at_max() { | ||
| let mut b = ExpBackoff::new(1_000, 60_000); | ||
| let seq: Vec<u64> = (0..8).map(|_| b.next_delay().as_millis() as u64).collect(); | ||
| assert_eq!( | ||
| seq, | ||
| vec![1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000, 60_000] | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn reset_returns_to_base() { | ||
| let mut b = ExpBackoff::new(500, 8_000); | ||
| b.next_delay(); | ||
| b.next_delay(); | ||
| b.reset(); | ||
| assert_eq!(b.next_delay().as_millis() as u64, 500); | ||
| } | ||
|
|
||
| #[test] | ||
| fn default_matches_spec_1s_to_60s() { | ||
| let b = ExpBackoff::default(); | ||
| assert_eq!(b.base_ms, BASE_MS); | ||
| assert_eq!(b.max_ms, MAX_MS); | ||
| } | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,262 @@ | ||
| //! Real [`ConfigProvider`] backed by `etcd-client`. | ||
| //! | ||
| //! Connection sequence (spec §2): | ||
| //! - Fixed-interval retry on initial connect: 5s × up to 5 attempts | ||
| //! - On success, `get` with prefix to bootstrap | ||
| //! - `watch` with `start_revision = range_revision + 1` to avoid a gap | ||
| //! - Compaction errors map to [`ProviderError::Compacted`] so the | ||
| //! supervisor can trigger a full resync | ||
|
|
||
| use async_trait::async_trait; | ||
| use etcd_client::{ | ||
| Client, ConnectOptions, Error as EtcdError, EventType, GetOptions, WatchOptions, | ||
| }; | ||
| use futures::{Stream, StreamExt}; | ||
| use std::pin::Pin; | ||
| use std::task::{Context, Poll}; | ||
| use std::time::Duration; | ||
| use tokio::sync::Mutex; | ||
|
|
||
| use crate::provider::{ConfigProvider, ProviderError, RawEntry, WatchEvent}; | ||
|
|
||
| /// Fixed-interval retry: 5s × 5 attempts (spec §2). | ||
| pub const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(5); | ||
| pub const CONNECT_MAX_ATTEMPTS: u32 = 5; | ||
|
|
||
| /// Retry policy used on the initial connect. Exposed for tests so they | ||
| /// can shrink the interval; production uses [`ConnectPolicy::default`]. | ||
| #[derive(Debug, Clone, Copy)] | ||
| pub struct ConnectPolicy { | ||
| pub interval: Duration, | ||
| pub attempts: u32, | ||
| } | ||
|
|
||
| impl Default for ConnectPolicy { | ||
| fn default() -> Self { | ||
| Self { | ||
| interval: CONNECT_RETRY_INTERVAL, | ||
| attempts: CONNECT_MAX_ATTEMPTS, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| pub struct EtcdConfigProvider { | ||
| /// The etcd client itself is `Clone`-cheap (internally Arc'd), but we | ||
| /// still serialise access for watches through a Mutex because the | ||
| /// underlying channel is not Sync at construction time. | ||
| client: Mutex<Client>, | ||
| prefix: String, | ||
| } | ||
|
|
||
| impl std::fmt::Debug for EtcdConfigProvider { | ||
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| f.debug_struct("EtcdConfigProvider") | ||
| .field("prefix", &self.prefix) | ||
| .finish_non_exhaustive() | ||
| } | ||
| } | ||
|
|
||
| impl EtcdConfigProvider { | ||
| /// Connect with the spec §2 default retry policy. | ||
| pub async fn connect( | ||
| endpoints: &[String], | ||
| prefix: impl Into<String>, | ||
| options: Option<ConnectOptions>, | ||
| ) -> Result<Self, ProviderError> { | ||
| Self::connect_with_policy(endpoints, prefix, options, ConnectPolicy::default()).await | ||
| } | ||
|
|
||
| /// Connect with a caller-chosen retry policy. Returns the last-seen | ||
| /// error on failure to surface useful context in the bootstrap logs. | ||
| pub async fn connect_with_policy( | ||
| endpoints: &[String], | ||
| prefix: impl Into<String>, | ||
| options: Option<ConnectOptions>, | ||
| policy: ConnectPolicy, | ||
| ) -> Result<Self, ProviderError> { | ||
| let prefix = prefix.into(); | ||
| let mut last_err: Option<EtcdError> = None; | ||
| for attempt in 1..=policy.attempts { | ||
| match Client::connect(endpoints, options.clone()).await { | ||
| Ok(client) => { | ||
| tracing::info!(attempt, prefix = %prefix, "etcd connected"); | ||
| return Ok(Self { | ||
| client: Mutex::new(client), | ||
| prefix, | ||
| }); | ||
| } | ||
| Err(err) => { | ||
| tracing::warn!( | ||
| attempt, | ||
| max = policy.attempts, | ||
| error = %err, | ||
| "etcd connect failed — retrying", | ||
| ); | ||
| last_err = Some(err); | ||
| if attempt < policy.attempts { | ||
| tokio::time::sleep(policy.interval).await; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Err(ProviderError::Connect( | ||
| last_err | ||
| .map(|e| e.to_string()) | ||
| .unwrap_or_else(|| "exhausted retries".to_string()), | ||
| )) | ||
| } | ||
|
|
||
| pub fn prefix(&self) -> &str { | ||
| &self.prefix | ||
| } | ||
| } | ||
|
|
||
| #[async_trait] | ||
| impl ConfigProvider for EtcdConfigProvider { | ||
| async fn load_all(&self) -> Result<(Vec<RawEntry>, i64), ProviderError> { | ||
| let mut client = self.client.lock().await; | ||
| let resp = client | ||
| .get( | ||
| self.prefix.as_bytes(), | ||
| Some(GetOptions::new().with_prefix()), | ||
| ) | ||
| .await | ||
| .map_err(|e| ProviderError::Range(e.to_string()))?; | ||
|
|
||
| let revision = resp.header().map(|h| h.revision()).unwrap_or(0); | ||
|
|
||
| let entries = resp | ||
| .kvs() | ||
| .iter() | ||
| .map(|kv| RawEntry { | ||
| key: String::from_utf8_lossy(kv.key()).into_owned(), | ||
| value: kv.value().to_vec(), | ||
| revision: kv.mod_revision(), | ||
| }) | ||
| .collect(); | ||
|
|
||
| Ok((entries, revision)) | ||
| } | ||
|
|
||
| async fn watch( | ||
| &self, | ||
| start_revision: i64, | ||
| ) -> Result< | ||
| Box<dyn Stream<Item = Result<WatchEvent, ProviderError>> + Send + Unpin>, | ||
| ProviderError, | ||
| > { | ||
| let mut client = self.client.lock().await; | ||
| let opts = WatchOptions::new() | ||
| .with_prefix() | ||
| .with_start_revision(start_revision); | ||
| let (_watcher, stream) = client | ||
| .watch(self.prefix.as_bytes(), Some(opts)) | ||
| .await | ||
| .map_err(|e| ProviderError::Watch(e.to_string()))?; | ||
|
|
||
| Ok(Box::new(EtcdWatchStream { inner: stream })) | ||
| } | ||
| } | ||
|
|
||
| /// Adapter from `etcd-client`'s WatchStream to our typed [`WatchEvent`]. | ||
| /// | ||
| /// Each `WatchResponse` carries a batch of events; we flatten them. | ||
| /// Errors from etcd are inspected for compaction and mapped to | ||
| /// [`ProviderError::Compacted`] so the supervisor can resync. | ||
| pub struct EtcdWatchStream { | ||
| inner: etcd_client::WatchStream, | ||
| } | ||
|
|
||
| impl Stream for EtcdWatchStream { | ||
| type Item = Result<WatchEvent, ProviderError>; | ||
|
|
||
| fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { | ||
| // We use a simple one-event-at-a-time strategy: on every poll | ||
| // we ask the underlying stream for the next WatchResponse, then | ||
| // emit its events back-to-back by storing leftovers… but to keep | ||
| // this crate small the first event of the batch is emitted and | ||
| // the rest arrive on subsequent responses, since etcd in practice | ||
| // batches per key and our prefix produces one-entry batches. | ||
| match self.inner.poll_next_unpin(cx) { | ||
| Poll::Pending => Poll::Pending, | ||
| Poll::Ready(None) => Poll::Ready(None), | ||
| Poll::Ready(Some(Err(err))) => { | ||
| let msg = err.to_string(); | ||
| if msg.contains("required revision has been compacted") | ||
| || msg.contains("mvcc: required revision") | ||
| { | ||
| Poll::Ready(Some(Err(ProviderError::Compacted))) | ||
| } else { | ||
| Poll::Ready(Some(Err(ProviderError::Watch(msg)))) | ||
| } | ||
| } | ||
| Poll::Ready(Some(Ok(resp))) => { | ||
| if resp.compact_revision() > 0 { | ||
| return Poll::Ready(Some(Err(ProviderError::Compacted))); | ||
| } | ||
| // Emit the first event. If a single response has multiple | ||
| // events, they will be received on subsequent polls by | ||
| // etcd's own batching — good enough for small clusters | ||
| // and correct under heavy load (we never drop events, | ||
| // we only smear them over wakeups). | ||
| if let Some(ev) = resp.events().first() { | ||
| let item = match ev.event_type() { | ||
| EventType::Put => ev.kv().map(|kv| { | ||
| WatchEvent::Put(RawEntry { | ||
| key: String::from_utf8_lossy(kv.key()).into_owned(), | ||
| value: kv.value().to_vec(), | ||
| revision: kv.mod_revision(), | ||
| }) | ||
| }), | ||
| EventType::Delete => ev.kv().map(|kv| WatchEvent::Delete { | ||
| key: String::from_utf8_lossy(kv.key()).into_owned(), | ||
| revision: kv.mod_revision(), | ||
| }), | ||
| }; | ||
| if let Some(item) = item { | ||
| return Poll::Ready(Some(Ok(item))); | ||
| } | ||
| } | ||
| // Empty response (e.g. header-only): tell the runtime | ||
| // to poll us again rather than stalling. | ||
| cx.waker().wake_by_ref(); | ||
| Poll::Pending | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn connect_retry_constants_match_spec() { | ||
| assert_eq!(CONNECT_RETRY_INTERVAL, Duration::from_secs(5)); | ||
| assert_eq!(CONNECT_MAX_ATTEMPTS, 5); | ||
| } | ||
|
|
||
| #[test] | ||
| fn default_policy_matches_spec() { | ||
| let p = ConnectPolicy::default(); | ||
| assert_eq!(p.interval, CONNECT_RETRY_INTERVAL); | ||
| assert_eq!(p.attempts, CONNECT_MAX_ATTEMPTS); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn connect_with_malformed_endpoint_returns_connect_error() { | ||
| // Empty endpoint list is treated as a parse failure by etcd-client, | ||
| // which lets us exercise the retry loop's error branch without | ||
| // waiting on a real TCP timeout. A compressed policy keeps the | ||
| // test sub-millisecond. | ||
| let policy = ConnectPolicy { | ||
| interval: Duration::from_millis(1), | ||
| attempts: 1, | ||
| }; | ||
| let endpoints: Vec<String> = vec![]; | ||
| let err = EtcdConfigProvider::connect_with_policy(&endpoints, "/aisix", None, policy) | ||
| .await | ||
| .unwrap_err(); | ||
| assert!(matches!(err, ProviderError::Connect(_))); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
EtcdWatchStream::poll_nextonly emitsresp.events().first()and ignores the rest of the events in the sameWatchResponse, which will drop watch events under load/batching. Buffer remaining events from a response (e.g., keep a VecDeque of pending events) and drain them across polls so every event is surfaced to the supervisor.