Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion crates/aisix-core/src/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,22 @@ impl<T: Resource> ResourceTable<T> {
/// `SnapshotHandle<S>` is the type actually stored in axum state — consumers
/// call [`SnapshotHandle::load`] on every request to get the current `Arc<S>`
/// without any locking.
#[derive(Debug, Clone)]
///
/// The manual `Clone` impl deliberately does *not* require `S: Clone` — the
/// handle only clones its inner `Arc`, the `S` is never duplicated.
#[derive(Debug)]
pub struct SnapshotHandle<S> {
inner: Arc<ArcSwap<S>>,
}

impl<S> Clone for SnapshotHandle<S> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}

impl<S> SnapshotHandle<S> {
pub fn new(initial: S) -> Self {
Self {
Expand Down
78 changes: 78 additions & 0 deletions crates/aisix-etcd/src/backoff.rs
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);
}
}
262 changes: 262 additions & 0 deletions crates/aisix-etcd/src/etcd_provider.rs
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
}
Comment on lines +173 to +224

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EtcdWatchStream::poll_next only emits resp.events().first() and ignores the rest of the events in the same WatchResponse, 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.

Copilot uses AI. Check for mistakes.
}
}
}

#[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(_)));
}
}
Loading
Loading