Skip to main content

dynamo_runtime/transports/
etcd.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::runtime::Runtime;
5use anyhow::{Context, Result};
6
7use async_nats::jetstream::kv;
8use derive_builder::Builder;
9use derive_getters::Dissolve;
10use futures::StreamExt;
11use std::collections::HashMap;
12use std::sync::Arc;
13use tokio::sync::{RwLock, mpsc};
14use validator::Validate;
15
16use etcd_client::{
17    Certificate, Compare, CompareOp, DeleteOptions, GetOptions, Identity, LockClient, LockOptions,
18    LockResponse, PutOptions, PutResponse, TlsOptions, Txn, TxnOp, TxnOpResponse, WatchOptions,
19    WatchStream, Watcher,
20};
21pub use etcd_client::{ConnectOptions, KeyValue, LeaseClient};
22use tokio::time::{Duration, Instant, interval};
23use tokio_util::sync::CancellationToken;
24
25mod connector;
26mod lease;
27mod lock;
28
29use connector::Connector;
30use lease::*;
31pub use lock::*;
32
33use super::utils::build_in_runtime;
34use crate::config::environment_names::etcd as env_etcd;
35
36const DEFAULT_STARTUP_CONNECT_TIMEOUT: Duration = Duration::from_secs(120);
37const STARTUP_CONNECT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
38const STARTUP_CONNECT_MAX_BACKOFF: Duration = Duration::from_secs(30);
39const WATCH_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(250);
40const WATCH_RETRY_MAX_BACKOFF: Duration = Duration::from_secs(5);
41const WATCH_RESYNC_GET_TIMEOUT: Duration = Duration::from_secs(10);
42const DEFAULT_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
43const DEFAULT_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10);
44const DEFAULT_LEASE_TTL_SECS: u64 = 30;
45/// etcd's default `--grpc-keepalive-min-time`; pinging faster gets GOAWAY `too_many_pings`.
46const ETCD_SERVER_KEEPALIVE_MIN_TIME: Duration = Duration::from_secs(5);
47const WATCH_CHANNEL_CAPACITY: usize = 32;
48
49/// ETCD Client
50#[derive(Clone)]
51pub struct Client {
52    connector: Arc<Connector>,
53    primary_lease: u64,
54    runtime: Runtime,
55    // Exclusive runtime for etcd lease keep-alive and watch tasks
56    // Avoid those tasks from being starved when the main runtime is busy
57    // WARNING: Do not await on main runtime from this runtime or deadlocks may occur
58    rt: Arc<tokio::runtime::Runtime>,
59}
60
61#[derive(Debug, Copy, Clone, Eq, PartialEq)]
62pub enum CompareAndPutOutcome {
63    Updated,
64    Missing,
65    Conflict,
66}
67
68impl std::fmt::Debug for Client {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        write!(f, "etcd::Client primary_lease={}", self.primary_lease)
71    }
72}
73
74impl Client {
75    pub fn builder() -> ClientOptionsBuilder {
76        ClientOptionsBuilder::default()
77    }
78
79    /// Create a new discovery client
80    ///
81    /// This will establish a connection to the etcd server, create a primary lease,
82    /// and spawn a task to keep the lease alive and tie the lifetime of the [`Runtime`]
83    /// to the lease.
84    ///
85    /// If the lease expires, the [`Runtime`] will be shutdown.
86    /// If the [`Runtime`] is shutdown, the lease will be revoked.
87    pub async fn new(config: ClientOptions, runtime: Runtime) -> Result<Self> {
88        let runtime_for_lease = runtime.clone();
89
90        let ((connector, lease_id), rt) = build_in_runtime(
91            async move { Self::connect_with_startup_retry(&config, runtime_for_lease).await },
92            1,
93        )
94        .await?;
95
96        Ok(Client {
97            connector,
98            primary_lease: lease_id,
99            rt,
100            runtime,
101        })
102    }
103
104    /// Connect to etcd during startup, retrying with exponential backoff until the configured
105    /// startup deadline.
106    async fn connect_with_startup_retry(
107        config: &ClientOptions,
108        runtime: Runtime,
109    ) -> Result<(Arc<Connector>, u64)> {
110        let token = runtime.primary_token();
111        let timeout = config.startup_connect_timeout;
112        let deadline = Instant::now().checked_add(timeout).ok_or_else(|| {
113            anyhow::anyhow!(
114                "etcd startup connection timeout {timeout:?} exceeds the supported duration"
115            )
116        })?;
117        let mut backoff = STARTUP_CONNECT_INITIAL_BACKOFF;
118
119        loop {
120            if token.is_cancelled() {
121                anyhow::bail!("etcd startup connection cancelled");
122            }
123
124            let remaining = deadline.saturating_duration_since(Instant::now());
125            if remaining.is_zero() {
126                anyhow::bail!(
127                    "etcd startup connection timed out after {} seconds",
128                    timeout.as_secs_f64()
129                );
130            }
131
132            let attempt = tokio::select! {
133                biased;
134
135                _ = token.cancelled() => {
136                    anyhow::bail!("etcd startup connection cancelled");
137                }
138
139                result = tokio::time::timeout(
140                    remaining,
141                    Self::connect_startup_attempt(config, &runtime),
142                ) => {
143                    match result {
144                        Ok(result) => result,
145                        Err(_) => anyhow::bail!(
146                            "etcd startup connection timed out after {} seconds",
147                            timeout.as_secs_f64()
148                        ),
149                    }
150                }
151            };
152
153            match attempt {
154                Ok(connection) => return Ok(connection),
155                Err(err) => {
156                    let now = Instant::now();
157                    if now >= deadline {
158                        return Err(err);
159                    }
160
161                    let sleep_duration = backoff.min(deadline.saturating_duration_since(now));
162
163                    tracing::warn!(
164                        error = %err,
165                        retry_in = ?sleep_duration,
166                        remaining = ?deadline.saturating_duration_since(now),
167                        "etcd not reachable yet; retrying startup connection"
168                    );
169
170                    tokio::select! {
171                        biased;
172
173                        _ = token.cancelled() => {
174                            anyhow::bail!("etcd startup connection cancelled");
175                        }
176
177                        _ = tokio::time::sleep(sleep_duration) => {}
178                    }
179                    backoff = backoff.saturating_mul(2).min(STARTUP_CONNECT_MAX_BACKOFF);
180                }
181            }
182        }
183    }
184
185    async fn connect_startup_attempt(
186        config: &ClientOptions,
187        runtime: &Runtime,
188    ) -> Result<(Arc<Connector>, u64)> {
189        let token = runtime.primary_token();
190        let connect_options = config
191            .etcd_connect_options
192            .clone()
193            .or_else(|| with_default_keep_alive(None, config.lease_ttl));
194        let connector = Connector::new(config.etcd_url.clone(), connect_options).await?;
195
196        let lease_id = if config.attach_lease {
197            create_lease(connector.clone(), config.lease_ttl, runtime.clone())
198                .await
199                .with_context(|| {
200                    format!(
201                        "Unable to create lease. Check etcd server status at {}",
202                        config.etcd_url.join(", ")
203                    )
204                })?
205        } else {
206            0
207        };
208
209        if token.is_cancelled() {
210            anyhow::bail!("etcd startup connection cancelled");
211        }
212
213        Ok((connector, lease_id))
214    }
215
216    /// Get a clone of the underlying [`etcd_client::Client`] instance.
217    /// This returns a clone since the client is behind an RwLock.
218    fn etcd_client(&self) -> etcd_client::Client {
219        self.connector.get_client()
220    }
221
222    /// Get the primary lease ID.
223    pub fn lease_id(&self) -> u64 {
224        self.primary_lease
225    }
226
227    /// Probe etcd connectivity via the maintenance status RPC.
228    /// Returns immediately with no side effects; a missing key is not an error.
229    /// A follower can answer this RPC even without quorum, so we also reject
230    /// leader == 0, which indicates no elected leader and means linearizable
231    /// reads will time out.
232    pub(crate) async fn check_connection(&self) -> anyhow::Result<()> {
233        let resp = self.etcd_client().maintenance_client().status().await?;
234        anyhow::ensure!(
235            resp.leader() != 0,
236            "etcd has no elected leader (quorum lost)"
237        );
238        Ok(())
239    }
240
241    /// Atomically create a key-value pair if it doesn't already exist.
242    ///
243    /// Returns:
244    /// - `Ok(None)` if the key was successfully created
245    /// - `Ok(Some(version))` if the key already exists (returns the existing version)
246    /// - `Err(...)` only on actual errors (connection failure, timeout, etc.)
247    ///
248    /// This idempotent behavior was introduced in PR #4212 (Nov 10, 2025) to align with
249    /// the StoreOutcome pattern used in KeyValueStore implementations, where both
250    /// Created and Exists are successful outcomes rather than errors. This design supports
251    /// distributed systems where multiple processes might attempt to create the same key.
252    pub async fn kv_create(
253        &self,
254        key: &str,
255        value: Vec<u8>,
256        lease_id: Option<u64>,
257    ) -> Result<Option<u64>> {
258        let id = lease_id.unwrap_or(self.lease_id());
259        let put_options = PutOptions::new().with_lease(id as i64);
260
261        // Build transaction that creates key only if it doesn't exist
262        let txn = Txn::new()
263            .when(vec![Compare::version(key, CompareOp::Equal, 0)]) // Ensure the lock does not exist
264            .and_then(vec![
265                TxnOp::put(key, value, Some(put_options)), // Create the object
266            ])
267            .or_else(vec![
268                TxnOp::get(key, None), // Key exists, get its info
269            ]);
270
271        // Execute the transaction
272        let result = self.connector.get_client().kv_client().txn(txn).await?;
273
274        // Created
275        if result.succeeded() {
276            return Ok(None);
277        }
278
279        // Already exists
280        if let Some(etcd_client::TxnOpResponse::Get(get_resp)) =
281            result.op_responses().into_iter().next()
282            && let Some(kv) = get_resp.kvs().first()
283        {
284            let version = kv.version() as u64;
285            return Ok(Some(version));
286        }
287
288        // Error
289        for resp in result.op_responses() {
290            tracing::warn!(response = ?resp, "kv_create etcd op response");
291        }
292        anyhow::bail!("Unable to create key. Check etcd server status")
293    }
294
295    /// Atomically create a key if it does not exist, or validate the values are identical if the key exists.
296    pub async fn kv_create_or_validate(
297        &self,
298        key: String,
299        value: Vec<u8>,
300        lease_id: Option<u64>,
301    ) -> Result<()> {
302        let id = lease_id.unwrap_or(self.lease_id());
303        let put_options = PutOptions::new().with_lease(id as i64);
304
305        // Build the transaction that either creates the key if it doesn't exist,
306        // or validates the existing value matches what we expect
307        let txn = Txn::new()
308            .when(vec![Compare::version(key.as_str(), CompareOp::Equal, 0)]) // Key doesn't exist
309            .and_then(vec![
310                TxnOp::put(key.as_str(), value.clone(), Some(put_options)), // Create it
311            ])
312            .or_else(vec![
313                // If key exists but values don't match, this will fail the transaction
314                TxnOp::txn(Txn::new().when(vec![Compare::value(
315                    key.as_str(),
316                    CompareOp::Equal,
317                    value.clone(),
318                )])),
319            ]);
320
321        // Execute the transaction
322        let result = self.connector.get_client().kv_client().txn(txn).await?;
323
324        // We have to enumerate the response paths to determine if the transaction succeeded
325        if result.succeeded() {
326            Ok(())
327        } else {
328            match result.op_responses().first() {
329                Some(response) => match response {
330                    TxnOpResponse::Txn(response) => match response.succeeded() {
331                        true => Ok(()),
332                        false => anyhow::bail!(
333                            "Unable to create or validate key. Check etcd server status"
334                        ),
335                    },
336                    _ => {
337                        anyhow::bail!("Unable to validate key operation. Check etcd server status")
338                    }
339                },
340                None => anyhow::bail!("Unable to create or validate key. Check etcd server status"),
341            }
342        }
343    }
344
345    pub async fn kv_put(
346        &self,
347        key: impl AsRef<str>,
348        value: impl AsRef<[u8]>,
349        lease_id: Option<u64>,
350    ) -> Result<()> {
351        let id = lease_id.unwrap_or(self.lease_id());
352        let put_options = PutOptions::new().with_lease(id as i64);
353        let _ = self
354            .connector
355            .get_client()
356            .kv_client()
357            .put(key.as_ref(), value.as_ref(), Some(put_options))
358            .await?;
359        Ok(())
360    }
361
362    /// Put all entries atomically, using the primary lease when none is supplied.
363    pub async fn kv_put_many(
364        &self,
365        entries: Vec<(String, Vec<u8>)>,
366        lease_id: Option<u64>,
367    ) -> Result<()> {
368        let options = PutOptions::new().with_lease(lease_id.unwrap_or(self.lease_id()) as i64);
369        let operations: Vec<_> = entries
370            .into_iter()
371            .map(|(key, value)| TxnOp::put(key, value, Some(options.clone())))
372            .collect();
373        self.connector
374            .get_client()
375            .kv_client()
376            .txn(Txn::new().and_then(operations))
377            .await?;
378        Ok(())
379    }
380
381    pub async fn kv_put_with_options(
382        &self,
383        key: impl AsRef<str>,
384        value: impl AsRef<[u8]>,
385        options: Option<PutOptions>,
386    ) -> Result<PutResponse> {
387        let options = options
388            .unwrap_or_default()
389            .with_lease(self.lease_id() as i64);
390        self.connector
391            .get_client()
392            .kv_client()
393            .put(key.as_ref(), value.as_ref(), Some(options))
394            .await
395            .map_err(|err| err.into())
396    }
397
398    /// Replace an existing value with a compare-on-mod-revision transaction.
399    pub async fn kv_compare_and_put(
400        &self,
401        key: impl AsRef<str>,
402        expected: impl AsRef<[u8]>,
403        value: impl AsRef<[u8]>,
404        lease_id: Option<u64>,
405    ) -> Result<CompareAndPutOutcome> {
406        let key = key.as_ref();
407        let current = self
408            .connector
409            .get_client()
410            .kv_client()
411            .get(key, None)
412            .await?;
413        let Some(current) = current.kvs().first() else {
414            return Ok(CompareAndPutOutcome::Missing);
415        };
416        if current.value() != expected.as_ref() {
417            return Ok(CompareAndPutOutcome::Conflict);
418        }
419        let expected_mod_revision = current.mod_revision();
420
421        let put_options = PutOptions::new().with_lease(lease_id.unwrap_or(self.lease_id()) as i64);
422        let txn = Txn::new()
423            .when(vec![Compare::mod_revision(
424                key,
425                CompareOp::Equal,
426                expected_mod_revision,
427            )])
428            .and_then(vec![TxnOp::put(
429                key,
430                value.as_ref().to_vec(),
431                Some(put_options),
432            )])
433            .or_else(vec![TxnOp::get(key, None)]);
434
435        let result = self.connector.get_client().kv_client().txn(txn).await?;
436        if result.succeeded() {
437            return Ok(CompareAndPutOutcome::Updated);
438        }
439
440        match result.op_responses().into_iter().next() {
441            Some(TxnOpResponse::Get(response)) if response.kvs().is_empty() => {
442                Ok(CompareAndPutOutcome::Missing)
443            }
444            Some(TxnOpResponse::Get(_)) => Ok(CompareAndPutOutcome::Conflict),
445            response => {
446                tracing::warn!(?response, "unexpected compare-and-put response");
447                anyhow::bail!("Unable to compare and replace key. Check etcd server status")
448            }
449        }
450    }
451
452    pub async fn kv_get(
453        &self,
454        key: impl Into<Vec<u8>>,
455        options: Option<GetOptions>,
456    ) -> Result<Vec<KeyValue>> {
457        let mut get_response = self
458            .connector
459            .get_client()
460            .kv_client()
461            .get(key, options)
462            .await?;
463        Ok(get_response.take_kvs())
464    }
465
466    pub async fn kv_delete(
467        &self,
468        key: impl Into<Vec<u8>>,
469        options: Option<DeleteOptions>,
470    ) -> Result<u64> {
471        self.connector
472            .get_client()
473            .kv_client()
474            .delete(key, options)
475            .await
476            .map(|del_response| del_response.deleted() as u64)
477            .map_err(|err| err.into())
478    }
479
480    pub async fn kv_get_prefix(&self, prefix: impl AsRef<str>) -> Result<Vec<KeyValue>> {
481        let mut get_response = self
482            .connector
483            .get_client()
484            .kv_client()
485            .get(prefix.as_ref(), Some(GetOptions::new().with_prefix()))
486            .await?;
487
488        Ok(get_response.take_kvs())
489    }
490
491    /// Acquire a distributed lock using etcd's native lock mechanism
492    /// Returns a LockResponse that can be used to unlock later
493    pub async fn lock(
494        &self,
495        key: impl Into<Vec<u8>>,
496        lease_id: Option<u64>,
497    ) -> Result<LockResponse> {
498        let mut lock_client = self.connector.get_client().lock_client();
499        let id = lease_id.unwrap_or(self.lease_id());
500        let options = LockOptions::new().with_lease(id as i64);
501        lock_client
502            .lock(key, Some(options))
503            .await
504            .map_err(|err| err.into())
505    }
506
507    /// Release a distributed lock using the key from the LockResponse
508    pub async fn unlock(&self, lock_key: impl Into<Vec<u8>>) -> Result<()> {
509        let mut lock_client = self.connector.get_client().lock_client();
510        lock_client
511            .unlock(lock_key)
512            .await
513            .map_err(|err: etcd_client::Error| anyhow::anyhow!(err))?;
514        Ok(())
515    }
516
517    /// Like kv_get_and_watch_prefix but only for new changes, does not include existing values.
518    pub async fn kv_watch_prefix(
519        &self,
520        prefix: impl AsRef<str> + std::fmt::Display,
521    ) -> Result<PrefixWatcher> {
522        self.watch_internal(prefix, false).await
523    }
524
525    pub async fn kv_get_and_watch_prefix(
526        &self,
527        prefix: impl AsRef<str> + std::fmt::Display,
528    ) -> Result<PrefixWatcher> {
529        self.watch_internal(prefix, true).await
530    }
531
532    /// Core watch implementation that sets up a resilient watcher for a key prefix.
533    ///
534    /// Creates a background task that maintains a watch stream with automatic reconnection
535    /// on recoverable errors. If `include_existing` is true, the first event is one
536    /// [`WatchEvent::Resync`] that holds every existing key, possibly none.
537    async fn watch_internal(
538        &self,
539        prefix: impl AsRef<str> + std::fmt::Display,
540        include_existing: bool,
541    ) -> Result<PrefixWatcher> {
542        let (mut start_revision, existing_kvs) = self
543            .get_start_revision(prefix.as_ref(), include_existing)
544            .await?;
545
546        let (tx, rx) = mpsc::channel(WATCH_CHANNEL_CAPACITY);
547
548        if let Some(kvs) = existing_kvs {
549            tracing::trace!(
550                count = kvs.len(),
551                "sending the existing kvs as the initial snapshot"
552            );
553            tx.send(WatchEvent::Resync(kvs)).await?;
554        }
555
556        // Watch for new events in background
557        let connector = self.connector.clone();
558        let prefix_str = prefix.as_ref().to_string();
559        let cancel_token = self.runtime.primary_token();
560        self.rt.spawn(async move {
561            let mut first_connect = true;
562            let mut reconnect = true;
563            while reconnect {
564                if !first_connect {
565                    let mut retry_attempt = 0u64;
566                    let mut retry_backoff = WATCH_RETRY_INITIAL_BACKOFF;
567                    while let Err(err) = Self::resync_watch_prefix(
568                        &connector,
569                        &prefix_str,
570                        &mut start_revision,
571                        &tx,
572                        &cancel_token,
573                    )
574                    .await
575                    {
576                        if tx.is_closed() || cancel_token.is_cancelled() {
577                            return;
578                        }
579
580                        retry_attempt = retry_attempt.saturating_add(1);
581                        if retry_attempt == 1 {
582                            tracing::warn!(
583                                error = %err,
584                                prefix = %prefix_str,
585                                "failed to resync etcd watch prefix after reconnect; retrying"
586                            );
587                        } else {
588                            tracing::info!(
589                                error = %err,
590                                prefix = %prefix_str,
591                                retry_attempt,
592                                backoff_ms = retry_backoff.as_millis(),
593                                "still failing to resync etcd watch prefix after reconnect; retrying"
594                            );
595                        }
596
597                        if Self::is_etcd_connection_error(&err) {
598                            let deadline = std::time::Instant::now() + Duration::from_secs(10);
599                            if let Err(err) = connector.reconnect(deadline).await {
600                                tracing::warn!(
601                                    error = %err,
602                                    prefix = %prefix_str,
603                                    "failed to reconnect to ETCD before watch resync; retrying"
604                                );
605                            }
606                        }
607
608                        tokio::select! {
609                            _ = cancel_token.cancelled() => return,
610                            _ = tokio::time::sleep(Self::watch_retry_backoff(retry_backoff)) => {}
611                        }
612                        retry_backoff =
613                            retry_backoff.saturating_mul(2).min(WATCH_RETRY_MAX_BACKOFF);
614                    }
615                }
616
617                // Start a new watch stream. Never give up while there are receivers: exiting
618                // here freezes discovery for the lifetime of the process.
619                let mut stream_backoff = WATCH_RETRY_INITIAL_BACKOFF;
620                let watch_stream = loop {
621                    let result = tokio::select! {
622                        biased;
623                        _ = cancel_token.cancelled() => return,
624                        _ = tx.closed() => return,
625                        result = Self::new_watch_stream(&connector, &prefix_str, start_revision) => result,
626                    };
627                    match result {
628                        Ok(stream) => break stream,
629                        Err(err) => {
630                            if tx.is_closed() || cancel_token.is_cancelled() {
631                                return;
632                            }
633                            tracing::warn!(
634                                error = %err,
635                                prefix = %prefix_str,
636                                backoff_ms = stream_backoff.as_millis(),
637                                "failed to establish etcd watch stream; retrying"
638                            );
639                            tokio::select! {
640                                biased;
641                                _ = cancel_token.cancelled() => return,
642                                _ = tx.closed() => return,
643                                _ = tokio::time::sleep(Self::watch_retry_backoff(stream_backoff)) => {}
644                            }
645                            stream_backoff =
646                                stream_backoff.saturating_mul(2).min(WATCH_RETRY_MAX_BACKOFF);
647                        }
648                    }
649                };
650
651                first_connect = false;
652
653                // Watch the stream
654                reconnect =
655                    Self::monitor_watch_stream(watch_stream, &prefix_str, &mut start_revision, &tx)
656                        .await;
657            }
658        });
659
660        Ok(PrefixWatcher {
661            prefix: prefix.as_ref().to_string(),
662            rx,
663        })
664    }
665
666    /// Fetch the start revision and optionally return existing key-values.
667    async fn get_start_revision(
668        &self,
669        prefix: impl AsRef<str> + std::fmt::Display,
670        include_existing: bool,
671    ) -> Result<(i64, Option<Vec<KeyValue>>)> {
672        let mut kv_client = self.connector.get_client().kv_client();
673        let mut get_response = kv_client
674            .get(prefix.as_ref(), Some(GetOptions::new().with_prefix()))
675            .await?;
676
677        // Get the start revision
678        let mut start_revision = get_response
679            .header()
680            .ok_or(anyhow::anyhow!("missing header; unable to get revision"))?
681            .revision();
682        tracing::trace!("{prefix}: start_revision: {start_revision}");
683        start_revision += 1;
684
685        // Return existing KVs if requested
686        let existing_kvs = include_existing.then(|| {
687            let kvs = get_response.take_kvs();
688            tracing::trace!("initial kv count: {:?}", kvs.len());
689            kvs
690        });
691
692        Ok((start_revision, existing_kvs))
693    }
694
695    /// Fetch current prefix state after reconnect and publish it as an authoritative snapshot.
696    async fn resync_watch_prefix(
697        connector: &Arc<Connector>,
698        prefix: &str,
699        start_revision: &mut i64,
700        tx: &mpsc::Sender<WatchEvent>,
701        cancel_token: &CancellationToken,
702    ) -> Result<()> {
703        let mut kv_client = connector.get_client().kv_client();
704        let get_result = tokio::select! {
705            _ = cancel_token.cancelled() => anyhow::bail!("watch resync cancelled"),
706            result = tokio::time::timeout(
707                WATCH_RESYNC_GET_TIMEOUT,
708                kv_client.get(prefix, Some(GetOptions::new().with_prefix())),
709            ) => result,
710        };
711        let mut response = get_result
712            .with_context(|| format!("timed out fetching etcd prefix snapshot for '{prefix}'"))?
713            .with_context(|| format!("failed to fetch etcd prefix snapshot for '{prefix}'"))?;
714
715        let header = response
716            .header()
717            .ok_or_else(|| anyhow::anyhow!("missing header during watch resync for '{prefix}'"))?;
718        *start_revision = header.revision() + 1;
719
720        let kvs = response.take_kvs();
721        tracing::warn!(
722            prefix,
723            kv_count = kvs.len(),
724            start_revision = *start_revision,
725            "resyncing etcd watch prefix after reconnect"
726        );
727
728        tokio::select! {
729            _ = cancel_token.cancelled() => anyhow::bail!("watch resync cancelled"),
730            result = tx.send(WatchEvent::Resync(kvs)) => {
731                result.context("failed to send WatchEvent::Resync")
732            }
733        }
734    }
735
736    fn is_etcd_connection_error(err: &anyhow::Error) -> bool {
737        if err.chain().any(|cause| {
738            cause
739                .downcast_ref::<tokio::time::error::Elapsed>()
740                .is_some()
741        }) {
742            return true;
743        }
744
745        err.chain().any(|cause| {
746            let Some(err) = cause.downcast_ref::<etcd_client::Error>() else {
747                return false;
748            };
749
750            match err {
751                etcd_client::Error::IoError(_)
752                | etcd_client::Error::TransportError(_)
753                | etcd_client::Error::EndpointError(_) => true,
754                etcd_client::Error::GRpcStatus(status) => matches!(
755                    status.code() as i32,
756                    // tonic::Code::Cancelled
757                    1
758                    // tonic::Code::Unknown
759                    | 2
760                    // tonic::Code::DeadlineExceeded
761                    | 4
762                    // tonic::Code::Unavailable
763                    | 14
764                ),
765                _ => false,
766            }
767        })
768    }
769
770    fn watch_retry_backoff(current: Duration) -> Duration {
771        let max_ms = u64::try_from(current.as_millis()).unwrap_or(u64::MAX);
772        let min_ms = (max_ms / 2).max(1);
773        let jitter_range = max_ms.saturating_sub(min_ms).saturating_add(1);
774        Duration::from_millis(min_ms + rand::random::<u64>() % jitter_range)
775    }
776
777    /// Establish a new watch stream with automatic retry and reconnection.
778    ///
779    /// Attempts to create a watch stream, reconnecting to ETCD if necessary.
780    /// Uses a 10-second timeout for reconnection attempts before giving up.
781    async fn new_watch_stream(
782        connector: &Arc<Connector>,
783        prefix: &String,
784        start_revision: i64,
785    ) -> Result<WatchStream> {
786        loop {
787            match connector
788                .get_client()
789                .watch_client()
790                .watch(
791                    prefix.as_str(),
792                    Some(
793                        WatchOptions::new()
794                            .with_prefix()
795                            .with_start_revision(start_revision)
796                            .with_prev_key(),
797                    ),
798                )
799                .await
800            {
801                Ok((_, watch_stream)) => {
802                    tracing::debug!("Watch stream established for prefix '{prefix}'");
803                    return Ok(watch_stream);
804                }
805                Err(err) => {
806                    tracing::debug!(error = %err, "Failed to establish watch stream for prefix '{}'", prefix);
807                    let deadline = std::time::Instant::now() + Duration::from_secs(10);
808                    if let Err(err) = connector.reconnect(deadline).await {
809                        tracing::error!(
810                            "Failed to reconnect to ETCD within 10 secs for watching prefix '{}': {}",
811                            prefix,
812                            err
813                        );
814                        return Err(err);
815                    }
816                    // continue - retry establishing the watch stream
817                }
818            }
819        }
820    }
821
822    /// Monitor a watch stream and forward events to receivers.
823    ///
824    /// Returns `true` for recoverable errors (network issues, stream closure) that warrant
825    /// reconnection attempts. Returns `false` for permanent failures (protocol violations,
826    /// channel errors, no receivers) where watching should stop.
827    async fn monitor_watch_stream(
828        mut watch_stream: WatchStream,
829        prefix: &String,
830        start_revision: &mut i64,
831        tx: &mpsc::Sender<WatchEvent>,
832    ) -> bool {
833        loop {
834            tokio::select! {
835                maybe_resp = watch_stream.next() => {
836                    // Handle the watch response
837                    let response = match maybe_resp {
838                        Some(Ok(res)) => res,
839                        Some(Err(err)) => {
840                            tracing::warn!(error = %err, "Error watching stream for prefix '{}'", prefix);
841                            return true; // Exit to reconnect
842                        }
843                        None => {
844                            tracing::warn!("Watch stream unexpectedly closed for prefix '{prefix}'");
845                            return true; // Exit to reconnect
846                        }
847                    };
848
849                    // Update revision for reconnect
850                    *start_revision = match response.header() {
851                        Some(header) => header.revision() + 1,
852                        None => {
853                            tracing::error!("Missing header in watch response for prefix '{prefix}'");
854                            return false;
855                        }
856                    };
857
858                    // Process events
859                    if Self::process_watch_events(response.events(), tx).await.is_err() {
860                        return false;
861                    };
862                }
863                _ = tx.closed() => {
864                    tracing::debug!("no more receivers, stopping watcher");
865                    return false;
866                }
867            }
868        }
869    }
870
871    /// Process etcd events and forward them as Put/Delete watch events.
872    ///
873    /// Filters out events without key-values and transforms etcd events into
874    /// appropriate WatchEvent types for channel transmission.
875    async fn process_watch_events(
876        events: &[etcd_client::Event],
877        tx: &mpsc::Sender<WatchEvent>,
878    ) -> Result<()> {
879        for event in events {
880            // Extract the KeyValue if it exists
881            let Some(kv) = event.kv() else {
882                continue; // Skip events with no KV
883            };
884
885            // Handle based on event type
886            match event.event_type() {
887                etcd_client::EventType::Put => {
888                    if let Err(err) = tx.send(WatchEvent::Put(kv.clone())).await {
889                        tracing::error!("kv watcher error forwarding WatchEvent::Put: {err}");
890                        return Err(err.into());
891                    }
892                }
893                etcd_client::EventType::Delete => {
894                    if tx.send(WatchEvent::Delete(kv.clone())).await.is_err() {
895                        return Err(anyhow::anyhow!("failed to send WatchEvent::Delete"));
896                    }
897                }
898            }
899        }
900        Ok(())
901    }
902}
903
904#[derive(Dissolve)]
905pub struct PrefixWatcher {
906    prefix: String,
907    rx: mpsc::Receiver<WatchEvent>,
908}
909
910#[derive(Debug)]
911pub enum WatchEvent {
912    Put(KeyValue),
913    Delete(KeyValue),
914    /// Full prefix state after watch reconnection.
915    ///
916    /// Consumers that maintain local state should replace that state with this
917    /// authoritative snapshot before applying subsequent incremental events.
918    Resync(Vec<KeyValue>),
919}
920
921/// ETCD client configuration options
922#[derive(Debug, Clone, Builder, Validate)]
923pub struct ClientOptions {
924    #[validate(length(min = 1))]
925    pub etcd_url: Vec<String>,
926
927    #[builder(default)]
928    pub etcd_connect_options: Option<ConnectOptions>,
929
930    /// If true, the client will attach a lease to the primary [`CancellationToken`].
931    #[builder(default = "true")]
932    pub attach_lease: bool,
933
934    /// Lease TTL in seconds
935    #[builder(default = "default_lease_ttl()")]
936    pub lease_ttl: u64,
937
938    /// Maximum duration for the initial connection and lease creation retry loop.
939    #[builder(default = "default_startup_connect_timeout()")]
940    pub startup_connect_timeout: Duration,
941}
942
943impl Default for ClientOptions {
944    fn default() -> Self {
945        let mut connect_options = None;
946        let lease_ttl = default_lease_ttl();
947
948        if let (Ok(username), Ok(password)) = (
949            std::env::var(env_etcd::auth::ETCD_AUTH_USERNAME),
950            std::env::var(env_etcd::auth::ETCD_AUTH_PASSWORD),
951        ) {
952            // username and password are set
953            connect_options = Some(ConnectOptions::new().with_user(username, password));
954        } else if let (Ok(ca), Ok(cert), Ok(key)) = (
955            std::env::var(env_etcd::auth::ETCD_AUTH_CA),
956            std::env::var(env_etcd::auth::ETCD_AUTH_CLIENT_CERT),
957            std::env::var(env_etcd::auth::ETCD_AUTH_CLIENT_KEY),
958        ) {
959            // TLS is set
960            connect_options = Some(
961                ConnectOptions::new().with_tls(
962                    TlsOptions::new()
963                        .ca_certificate(Certificate::from_pem(ca))
964                        .identity(Identity::from_pem(cert, key)),
965                ),
966            );
967        }
968
969        // Without HTTP/2 keepalive, an etcd member that hangs without closing its TCP
970        // connections leaves watch streams open but silent forever, so deletes are never
971        // seen and the reconnect/resync path never runs.
972        connect_options = with_default_keep_alive(connect_options, lease_ttl);
973
974        ClientOptions {
975            etcd_url: default_servers(),
976            etcd_connect_options: connect_options,
977            attach_lease: true,
978            lease_ttl,
979            startup_connect_timeout: default_startup_connect_timeout(),
980        }
981    }
982}
983
984fn default_servers() -> Vec<String> {
985    match std::env::var(env_etcd::ETCD_ENDPOINTS) {
986        Ok(possible_list_of_urls) => possible_list_of_urls
987            .split(',')
988            .map(|s| s.to_string())
989            .collect(),
990        Err(_) => vec!["http://localhost:2379".to_string()],
991    }
992}
993
994fn default_lease_ttl() -> u64 {
995    match std::env::var(env_etcd::ETCD_LEASE_TTL) {
996        Ok(raw) => match raw.parse::<u64>() {
997            Ok(ttl) if ttl > 0 => ttl,
998            Ok(_) => {
999                tracing::warn!(
1000                    "{} must be >= 1; got 0. Falling back to {}.",
1001                    env_etcd::ETCD_LEASE_TTL,
1002                    DEFAULT_LEASE_TTL_SECS
1003                );
1004                DEFAULT_LEASE_TTL_SECS
1005            }
1006            Err(err) => {
1007                tracing::warn!(
1008                    "Invalid {}='{}' ({err}). Falling back to {}.",
1009                    env_etcd::ETCD_LEASE_TTL,
1010                    raw,
1011                    DEFAULT_LEASE_TTL_SECS
1012                );
1013                DEFAULT_LEASE_TTL_SECS
1014            }
1015        },
1016        Err(_) => DEFAULT_LEASE_TTL_SECS,
1017    }
1018}
1019
1020fn startup_connect_timeout_from_value(value: Option<&str>) -> Duration {
1021    match value {
1022        Some(raw) => match raw.parse::<u64>() {
1023            Ok(seconds) if seconds > 0 => Duration::from_secs(seconds),
1024            Ok(_) => {
1025                tracing::warn!(
1026                    "{} must be >= 1; got 0. Falling back to {}.",
1027                    env_etcd::ETCD_STARTUP_CONNECT_TIMEOUT_SECONDS,
1028                    DEFAULT_STARTUP_CONNECT_TIMEOUT.as_secs()
1029                );
1030                DEFAULT_STARTUP_CONNECT_TIMEOUT
1031            }
1032            Err(error) => {
1033                tracing::warn!(
1034                    "Invalid {}='{}' ({error}). Falling back to {}.",
1035                    env_etcd::ETCD_STARTUP_CONNECT_TIMEOUT_SECONDS,
1036                    raw,
1037                    DEFAULT_STARTUP_CONNECT_TIMEOUT.as_secs()
1038                );
1039                DEFAULT_STARTUP_CONNECT_TIMEOUT
1040            }
1041        },
1042        None => DEFAULT_STARTUP_CONNECT_TIMEOUT,
1043    }
1044}
1045
1046/// Resolve the etcd channel keepalive `(interval, timeout)`, or `None` when disabled
1047/// (`ETCD_KEEPALIVE_INTERVAL_SECONDS=0`).
1048fn keep_alive_from_values(
1049    interval: Option<&str>,
1050    timeout: Option<&str>,
1051) -> Option<(Duration, Duration)> {
1052    let interval = match interval {
1053        Some(raw) => match raw.parse::<u64>() {
1054            Ok(0) => return None,
1055            Ok(seconds) => Duration::from_secs(seconds),
1056            Err(error) => {
1057                tracing::warn!(
1058                    "Invalid {}='{}' ({error}). Falling back to {}.",
1059                    env_etcd::ETCD_KEEPALIVE_INTERVAL_SECONDS,
1060                    raw,
1061                    DEFAULT_KEEPALIVE_INTERVAL.as_secs()
1062                );
1063                DEFAULT_KEEPALIVE_INTERVAL
1064            }
1065        },
1066        None => DEFAULT_KEEPALIVE_INTERVAL,
1067    };
1068    if interval < ETCD_SERVER_KEEPALIVE_MIN_TIME {
1069        tracing::warn!(
1070            "{}={} is below etcd's default --grpc-keepalive-min-time ({}s); the server may \
1071             close the connection with GOAWAY too_many_pings.",
1072            env_etcd::ETCD_KEEPALIVE_INTERVAL_SECONDS,
1073            interval.as_secs(),
1074            ETCD_SERVER_KEEPALIVE_MIN_TIME.as_secs()
1075        );
1076    }
1077    let timeout = match timeout {
1078        Some(raw) => match raw.parse::<u64>() {
1079            Ok(seconds) if seconds > 0 => Duration::from_secs(seconds),
1080            Ok(_) => {
1081                tracing::warn!(
1082                    "{} must be >= 1; got 0. Falling back to {}.",
1083                    env_etcd::ETCD_KEEPALIVE_TIMEOUT_SECONDS,
1084                    DEFAULT_KEEPALIVE_TIMEOUT.as_secs()
1085                );
1086                DEFAULT_KEEPALIVE_TIMEOUT
1087            }
1088            Err(error) => {
1089                tracing::warn!(
1090                    "Invalid {}='{}' ({error}). Falling back to {}.",
1091                    env_etcd::ETCD_KEEPALIVE_TIMEOUT_SECONDS,
1092                    raw,
1093                    DEFAULT_KEEPALIVE_TIMEOUT.as_secs()
1094                );
1095                DEFAULT_KEEPALIVE_TIMEOUT
1096            }
1097        },
1098        None => DEFAULT_KEEPALIVE_TIMEOUT,
1099    };
1100    Some((interval, timeout))
1101}
1102
1103fn default_keep_alive() -> Option<(Duration, Duration)> {
1104    keep_alive_from_values(
1105        std::env::var(env_etcd::ETCD_KEEPALIVE_INTERVAL_SECONDS)
1106            .ok()
1107            .as_deref(),
1108        std::env::var(env_etcd::ETCD_KEEPALIVE_TIMEOUT_SECONDS)
1109            .ok()
1110            .as_deref(),
1111    )
1112}
1113
1114fn with_default_keep_alive(
1115    connect_options: Option<ConnectOptions>,
1116    lease_ttl: u64,
1117) -> Option<ConnectOptions> {
1118    let Some((interval, timeout)) = default_keep_alive() else {
1119        return connect_options;
1120    };
1121    if interval.saturating_add(timeout) >= Duration::from_secs(lease_ttl) {
1122        tracing::warn!(
1123            interval_secs = interval.as_secs(),
1124            timeout_secs = timeout.as_secs(),
1125            lease_ttl_secs = lease_ttl,
1126            "etcd keepalive detection can take as long as the primary lease TTL"
1127        );
1128    }
1129    Some(
1130        connect_options
1131            .unwrap_or_default()
1132            .with_keep_alive(interval, timeout)
1133            .with_keep_alive_while_idle(false),
1134    )
1135}
1136
1137fn default_startup_connect_timeout() -> Duration {
1138    startup_connect_timeout_from_value(
1139        std::env::var(env_etcd::ETCD_STARTUP_CONNECT_TIMEOUT_SECONDS)
1140            .ok()
1141            .as_deref(),
1142    )
1143}
1144
1145/// A cache for etcd key-value pairs that watches for changes
1146pub struct KvCache {
1147    client: Client,
1148    pub prefix: String,
1149    cache: Arc<RwLock<HashMap<String, Vec<u8>>>>,
1150    watcher: Option<PrefixWatcher>,
1151}
1152
1153impl KvCache {
1154    /// Create a new KV cache for the given prefix
1155    pub async fn new(
1156        client: Client,
1157        prefix: String,
1158        initial_values: HashMap<String, Vec<u8>>,
1159    ) -> Result<Self> {
1160        let mut cache = HashMap::new();
1161
1162        // First get all existing keys with this prefix
1163        let existing_kvs = client.kv_get_prefix(&prefix).await?;
1164        for kv in existing_kvs {
1165            let key = String::from_utf8_lossy(kv.key()).to_string();
1166            cache.insert(key, kv.value().to_vec());
1167        }
1168
1169        // For any keys in initial_values that don't exist in etcd, write them
1170        // TODO: proper lease handling, this requires the first process that write to a prefix atomically
1171        // create a lease and write the lease to etcd. Later processes will attach to the lease and
1172        // help refresh the lease.
1173        for (key, value) in initial_values.iter() {
1174            let full_key = format!("{}{}", prefix, key);
1175            if let std::collections::hash_map::Entry::Vacant(e) = cache.entry(full_key.clone()) {
1176                client.kv_put(&full_key, value.clone(), None).await?;
1177                e.insert(value.clone());
1178            }
1179        }
1180
1181        // Start watching for changes
1182        // we won't miss events between the initial push and the watcher starting because
1183        // client.kv_get_and_watch_prefix() will get all kv pairs and put them back again
1184        let watcher = client.kv_get_and_watch_prefix(&prefix).await?;
1185
1186        let cache = Arc::new(RwLock::new(cache));
1187        let mut result = Self {
1188            client,
1189            prefix,
1190            cache,
1191            watcher: Some(watcher),
1192        };
1193
1194        // Start the background watcher task
1195        result.start_watcher().await?;
1196
1197        Ok(result)
1198    }
1199
1200    /// Start the background watcher task
1201    async fn start_watcher(&mut self) -> Result<()> {
1202        if let Some(watcher) = self.watcher.take() {
1203            let cache = self.cache.clone();
1204            let prefix = self.prefix.clone();
1205
1206            tokio::spawn(async move {
1207                let mut rx = watcher.rx;
1208                // The first resync is the initial snapshot; a later one means the watch reconnected.
1209                let mut is_established = false;
1210
1211                while let Some(event) = rx.recv().await {
1212                    match event {
1213                        WatchEvent::Put(kv) => {
1214                            let key = String::from_utf8_lossy(kv.key()).to_string();
1215                            let value = kv.value().to_vec();
1216
1217                            tracing::trace!("KvCache update: {} = {:?}", key, value);
1218                            let mut cache_write = cache.write().await;
1219                            cache_write.insert(key, value);
1220                        }
1221                        WatchEvent::Delete(kv) => {
1222                            let key = String::from_utf8_lossy(kv.key()).to_string();
1223
1224                            tracing::trace!("KvCache delete: {key}");
1225                            let mut cache_write = cache.write().await;
1226                            cache_write.remove(&key);
1227                        }
1228                        WatchEvent::Resync(kvs) => {
1229                            let mut replacement = HashMap::with_capacity(kvs.len());
1230                            for kv in kvs {
1231                                let key = String::from_utf8_lossy(kv.key()).to_string();
1232                                let value = kv.value().to_vec();
1233                                replacement.insert(key, value);
1234                            }
1235
1236                            if is_established {
1237                                tracing::warn!(
1238                                    prefix,
1239                                    new_count = replacement.len(),
1240                                    "KvCache replacing state from etcd watch resync"
1241                                );
1242                            } else {
1243                                tracing::debug!(
1244                                    prefix,
1245                                    count = replacement.len(),
1246                                    "KvCache loaded the initial snapshot"
1247                                );
1248                            }
1249                            is_established = true;
1250                            let mut cache_write = cache.write().await;
1251                            *cache_write = replacement;
1252                        }
1253                    }
1254                }
1255
1256                tracing::debug!("KvCache watcher for prefix '{prefix}' stopped");
1257            });
1258        }
1259
1260        Ok(())
1261    }
1262
1263    /// Get a value from the cache
1264    pub async fn get(&self, key: &str) -> Option<Vec<u8>> {
1265        let full_key = format!("{}{}", self.prefix, key);
1266        let cache_read = self.cache.read().await;
1267        cache_read.get(&full_key).cloned()
1268    }
1269
1270    /// Get all key-value pairs in the cache
1271    pub async fn get_all(&self) -> HashMap<String, Vec<u8>> {
1272        let cache_read = self.cache.read().await;
1273        cache_read.clone()
1274    }
1275
1276    /// Update a value in both the cache and etcd
1277    pub async fn put(&self, key: &str, value: Vec<u8>, lease_id: Option<u64>) -> Result<()> {
1278        let full_key = format!("{}{}", self.prefix, key);
1279
1280        // Update etcd first
1281        self.client
1282            .kv_put(&full_key, value.clone(), lease_id)
1283            .await?;
1284
1285        // Then update local cache
1286        let mut cache_write = self.cache.write().await;
1287        cache_write.insert(full_key, value);
1288
1289        Ok(())
1290    }
1291
1292    /// Delete a key from both the cache and etcd
1293    pub async fn delete(&self, key: &str) -> Result<()> {
1294        let full_key = format!("{}{}", self.prefix, key);
1295
1296        // Delete from etcd first
1297        self.client.kv_delete(full_key.clone(), None).await?;
1298
1299        // Then remove from local cache
1300        let mut cache_write = self.cache.write().await;
1301        cache_write.remove(&full_key);
1302
1303        Ok(())
1304    }
1305}
1306
1307#[cfg(test)]
1308mod unit_tests {
1309    use super::*;
1310
1311    #[test]
1312    fn parses_startup_connect_timeout() {
1313        assert_eq!(
1314            startup_connect_timeout_from_value(None),
1315            DEFAULT_STARTUP_CONNECT_TIMEOUT
1316        );
1317        assert_eq!(
1318            startup_connect_timeout_from_value(Some("45")),
1319            Duration::from_secs(45)
1320        );
1321        assert_eq!(
1322            startup_connect_timeout_from_value(Some("0")),
1323            DEFAULT_STARTUP_CONNECT_TIMEOUT
1324        );
1325        assert_eq!(
1326            startup_connect_timeout_from_value(Some("invalid")),
1327            DEFAULT_STARTUP_CONNECT_TIMEOUT
1328        );
1329    }
1330
1331    #[test]
1332    fn parses_keep_alive() {
1333        let defaults = Some((DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_KEEPALIVE_TIMEOUT));
1334        assert_eq!(keep_alive_from_values(None, None), defaults);
1335        assert_eq!(keep_alive_from_values(Some("0"), None), None);
1336        assert_eq!(keep_alive_from_values(Some("0"), Some("3")), None);
1337        assert_eq!(
1338            keep_alive_from_values(Some("30"), Some("10")),
1339            Some((Duration::from_secs(30), Duration::from_secs(10)))
1340        );
1341        assert_eq!(
1342            keep_alive_from_values(Some("invalid"), Some("junk")),
1343            defaults
1344        );
1345        assert_eq!(
1346            keep_alive_from_values(Some("20"), Some("0")),
1347            Some((Duration::from_secs(20), DEFAULT_KEEPALIVE_TIMEOUT))
1348        );
1349    }
1350
1351    #[test]
1352    fn rejects_unrepresentable_startup_connect_timeout() {
1353        let runtime = Runtime::single_threaded().unwrap();
1354        let runtime_for_connect = runtime.clone();
1355        let options = Client::builder()
1356            .etcd_url(vec!["http://127.0.0.1:1".to_string()])
1357            .startup_connect_timeout(Duration::from_secs(u64::MAX))
1358            .build()
1359            .unwrap();
1360
1361        let result = runtime
1362            .primary()
1363            .block_on(Client::connect_with_startup_retry(
1364                &options,
1365                runtime_for_connect,
1366            ));
1367        let error = match result {
1368            Ok(_) => panic!("unrepresentable startup timeout unexpectedly succeeded"),
1369            Err(error) => error,
1370        };
1371
1372        assert!(
1373            error.to_string().contains("exceeds the supported duration"),
1374            "unexpected error: {error:#}"
1375        );
1376    }
1377
1378    #[test]
1379    fn startup_connection_respects_configured_timeout() {
1380        let runtime = Runtime::single_threaded().unwrap();
1381        let runtime_for_connect = runtime.clone();
1382        let options = Client::builder()
1383            .etcd_url(vec!["http://127.0.0.1:1".to_string()])
1384            .startup_connect_timeout(Duration::from_millis(50))
1385            .build()
1386            .unwrap();
1387
1388        let started = Instant::now();
1389        let result = runtime
1390            .primary()
1391            .block_on(Client::connect_with_startup_retry(
1392                &options,
1393                runtime_for_connect,
1394            ));
1395        let error = match result {
1396            Ok(_) => panic!("etcd startup connection unexpectedly succeeded"),
1397            Err(error) => error,
1398        };
1399
1400        assert!(
1401            error
1402                .to_string()
1403                .contains("etcd startup connection timed out after 0.05 seconds"),
1404            "unexpected error: {error:#}"
1405        );
1406        assert!(
1407            started.elapsed() < Duration::from_secs(1),
1408            "configured startup timeout was not respected"
1409        );
1410    }
1411
1412    #[test]
1413    fn classifies_etcd_connection_errors() {
1414        let err = anyhow::Error::new(etcd_client::Error::EndpointError(
1415            "endpoint unavailable".to_string(),
1416        ));
1417        assert!(Client::is_etcd_connection_error(&err));
1418
1419        let err = anyhow::Error::new(etcd_client::Error::IoError(std::io::Error::new(
1420            std::io::ErrorKind::ConnectionRefused,
1421            "connection refused",
1422        )));
1423        assert!(Client::is_etcd_connection_error(&err));
1424
1425        let runtime = tokio::runtime::Builder::new_current_thread()
1426            .enable_time()
1427            .build()
1428            .unwrap();
1429        let elapsed = runtime.block_on(async {
1430            tokio::time::timeout(Duration::from_millis(0), std::future::pending::<()>())
1431                .await
1432                .unwrap_err()
1433        });
1434        let err = anyhow::Error::new(elapsed).context("timed out fetching etcd prefix snapshot");
1435        assert!(Client::is_etcd_connection_error(&err));
1436
1437        let err = anyhow::Error::new(etcd_client::Error::InvalidArgs("bad request".to_string()));
1438        assert!(!Client::is_etcd_connection_error(&err));
1439
1440        let err = anyhow::anyhow!("missing header during watch resync");
1441        assert!(!Client::is_etcd_connection_error(&err));
1442    }
1443}
1444
1445#[cfg(feature = "integration")]
1446#[cfg(test)]
1447mod tests {
1448    use crate::{DistributedRuntime, distributed::DistributedConfig};
1449
1450    use super::*;
1451
1452    #[test]
1453    fn test_ectd_client() {
1454        let rt = Runtime::single_threaded().unwrap();
1455        let rt_clone = rt.clone();
1456        let config = DistributedConfig::from_settings();
1457
1458        rt_clone.primary().block_on(async move {
1459            let drt = DistributedRuntime::new(rt, config).await.unwrap();
1460            test_kv_create_or_validate(drt).await.unwrap();
1461        });
1462    }
1463
1464    async fn test_kv_create_or_validate(drt: DistributedRuntime) -> Result<()> {
1465        let key = "__integration_test_key";
1466        let value = b"test_value";
1467
1468        let client = Client::new(ClientOptions::default(), drt.runtime().clone())
1469            .await
1470            .expect("etcd client should be available");
1471        let lease_id = drt.connection_id();
1472
1473        // Create the key
1474        let result = client.kv_create(key, value.to_vec(), Some(lease_id)).await;
1475        assert!(result.is_ok(), "");
1476
1477        // Try to create the key again - this should return Ok(Some(version)) indicating key already exists
1478        // Note: Prior to PR #4212 (Nov 10, 2025), kv_create returned Err when key existed.
1479        // PR #4212 changed the behavior to return Ok(Some(version)) for idempotency, matching
1480        // the StoreOutcome::Exists pattern used in the KeyValueStore abstraction.
1481        // The transaction now includes .or_else(TxnOp::get) to retrieve existing key info
1482        // instead of failing, making the operation idempotent for distributed systems.
1483        let result = client.kv_create(key, value.to_vec(), Some(lease_id)).await;
1484        assert!(
1485            result.is_ok() && result.unwrap().is_some(),
1486            "Expected Ok(Some(version)) when key already exists"
1487        );
1488
1489        // Create or validate should succeed as the values match
1490        let result = client
1491            .kv_create_or_validate(key.to_string(), value.to_vec(), Some(lease_id))
1492            .await;
1493        assert!(result.is_ok());
1494
1495        // Try to create the key with a different value
1496        let different_value = b"different_value";
1497        let result = client
1498            .kv_create_or_validate(key.to_string(), different_value.to_vec(), Some(lease_id))
1499            .await;
1500        assert!(result.is_err(), "");
1501
1502        Ok(())
1503    }
1504
1505    #[test]
1506    fn test_kv_cache() {
1507        let rt = Runtime::single_threaded().unwrap();
1508        let rt_clone = rt.clone();
1509        let config = DistributedConfig::from_settings();
1510
1511        rt_clone.primary().block_on(async move {
1512            let drt = DistributedRuntime::new(rt, config).await.unwrap();
1513            test_kv_cache_operations(drt).await.unwrap();
1514        });
1515    }
1516
1517    async fn test_kv_cache_operations(drt: DistributedRuntime) -> Result<()> {
1518        // Make the client and unwrap it
1519        let client = Client::new(ClientOptions::default(), drt.runtime().clone())
1520            .await
1521            .expect("etcd client should be available");
1522
1523        // Create a unique test prefix to avoid conflicts with other tests
1524        let test_id = uuid::Uuid::new_v4().to_string();
1525        let prefix = format!("v1/test_kv_cache_{}/", test_id);
1526
1527        // Initial values
1528        let mut initial_values = HashMap::new();
1529        initial_values.insert("key1".to_string(), b"value1".to_vec());
1530        initial_values.insert("key2".to_string(), b"value2".to_vec());
1531
1532        // Create the KV cache
1533        let kv_cache = KvCache::new(client.clone(), prefix.clone(), initial_values).await?;
1534
1535        // Test get
1536        let value1 = kv_cache.get("key1").await;
1537        assert_eq!(value1, Some(b"value1".to_vec()));
1538
1539        let value2 = kv_cache.get("key2").await;
1540        assert_eq!(value2, Some(b"value2".to_vec()));
1541
1542        // Test get_all
1543        let all_values = kv_cache.get_all().await;
1544        assert_eq!(all_values.len(), 2);
1545        assert_eq!(
1546            all_values.get(&format!("{}key1", prefix)),
1547            Some(&b"value1".to_vec())
1548        );
1549        assert_eq!(
1550            all_values.get(&format!("{}key2", prefix)),
1551            Some(&b"value2".to_vec())
1552        );
1553
1554        // Test put - using None for lease_id
1555        kv_cache.put("key3", b"value3".to_vec(), None).await?;
1556
1557        // Allow some time for the update to propagate
1558        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1559
1560        // Verify the new value
1561        let value3 = kv_cache.get("key3").await;
1562        assert_eq!(value3, Some(b"value3".to_vec()));
1563
1564        // Test update
1565        kv_cache
1566            .put("key1", b"updated_value1".to_vec(), None)
1567            .await?;
1568
1569        // Allow some time for the update to propagate
1570        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1571
1572        // Verify the updated value
1573        let updated_value1 = kv_cache.get("key1").await;
1574        assert_eq!(updated_value1, Some(b"updated_value1".to_vec()));
1575
1576        // Test external update (simulating another client updating a value)
1577        client
1578            .kv_put(
1579                &format!("{}key2", prefix),
1580                b"external_update".to_vec(),
1581                None,
1582            )
1583            .await?;
1584
1585        // Allow some time for the update to propagate
1586        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1587
1588        // Verify the cache was updated
1589        let external_update = kv_cache.get("key2").await;
1590        assert_eq!(external_update, Some(b"external_update".to_vec()));
1591
1592        // Clean up - delete the test keys
1593        let etcd_client = client.etcd_client();
1594        let _ = etcd_client
1595            .kv_client()
1596            .delete(
1597                prefix,
1598                Some(etcd_client::DeleteOptions::new().with_prefix()),
1599            )
1600            .await?;
1601
1602        Ok(())
1603    }
1604}