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 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);
42
43/// ETCD Client
44#[derive(Clone)]
45pub struct Client {
46    connector: Arc<Connector>,
47    primary_lease: u64,
48    runtime: Runtime,
49    // Exclusive runtime for etcd lease keep-alive and watch tasks
50    // Avoid those tasks from being starved when the main runtime is busy
51    // WARNING: Do not await on main runtime from this runtime or deadlocks may occur
52    rt: Arc<tokio::runtime::Runtime>,
53}
54
55#[derive(Debug, Copy, Clone, Eq, PartialEq)]
56pub enum CompareAndPutOutcome {
57    Updated,
58    Missing,
59    Conflict,
60}
61
62impl std::fmt::Debug for Client {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(f, "etcd::Client primary_lease={}", self.primary_lease)
65    }
66}
67
68impl Client {
69    pub fn builder() -> ClientOptionsBuilder {
70        ClientOptionsBuilder::default()
71    }
72
73    /// Create a new discovery client
74    ///
75    /// This will establish a connection to the etcd server, create a primary lease,
76    /// and spawn a task to keep the lease alive and tie the lifetime of the [`Runtime`]
77    /// to the lease.
78    ///
79    /// If the lease expires, the [`Runtime`] will be shutdown.
80    /// If the [`Runtime`] is shutdown, the lease will be revoked.
81    pub async fn new(config: ClientOptions, runtime: Runtime) -> Result<Self> {
82        let runtime_for_lease = runtime.clone();
83
84        let ((connector, lease_id), rt) = build_in_runtime(
85            async move { Self::connect_with_startup_retry(&config, runtime_for_lease).await },
86            1,
87        )
88        .await?;
89
90        Ok(Client {
91            connector,
92            primary_lease: lease_id,
93            rt,
94            runtime,
95        })
96    }
97
98    /// Connect to etcd during startup, retrying with exponential backoff for up to 2 minutes.
99    async fn connect_with_startup_retry(
100        config: &ClientOptions,
101        runtime: Runtime,
102    ) -> Result<(Arc<Connector>, u64)> {
103        let token = runtime.primary_token();
104        let deadline = Instant::now() + STARTUP_CONNECT_TIMEOUT;
105        let mut backoff = STARTUP_CONNECT_INITIAL_BACKOFF;
106
107        loop {
108            if token.is_cancelled() {
109                anyhow::bail!("etcd startup connection cancelled");
110            }
111
112            let attempt = Self::connect_startup_attempt(config, &runtime).await;
113
114            match attempt {
115                Ok(connection) => return Ok(connection),
116                Err(err) => {
117                    let now = Instant::now();
118                    if now >= deadline {
119                        return Err(err);
120                    }
121
122                    let sleep_duration = backoff.min(deadline.saturating_duration_since(now));
123
124                    tracing::warn!(
125                        error = %err,
126                        retry_in = ?sleep_duration,
127                        remaining = ?deadline.saturating_duration_since(now),
128                        "etcd not reachable yet; retrying startup connection"
129                    );
130
131                    tokio::select! {
132                        biased;
133
134                        _ = token.cancelled() => {
135                            anyhow::bail!("etcd startup connection cancelled");
136                        }
137
138                        _ = tokio::time::sleep(sleep_duration) => {}
139                    }
140                    backoff = backoff.saturating_mul(2).min(STARTUP_CONNECT_MAX_BACKOFF);
141                }
142            }
143        }
144    }
145
146    async fn connect_startup_attempt(
147        config: &ClientOptions,
148        runtime: &Runtime,
149    ) -> Result<(Arc<Connector>, u64)> {
150        let token = runtime.primary_token();
151        let connector =
152            Connector::new(config.etcd_url.clone(), config.etcd_connect_options.clone()).await?;
153
154        let lease_id = if config.attach_lease {
155            create_lease(connector.clone(), config.lease_ttl, runtime.clone())
156                .await
157                .with_context(|| {
158                    format!(
159                        "Unable to create lease. Check etcd server status at {}",
160                        config.etcd_url.join(", ")
161                    )
162                })?
163        } else {
164            0
165        };
166
167        if token.is_cancelled() {
168            anyhow::bail!("etcd startup connection cancelled");
169        }
170
171        Ok((connector, lease_id))
172    }
173
174    /// Get a clone of the underlying [`etcd_client::Client`] instance.
175    /// This returns a clone since the client is behind an RwLock.
176    fn etcd_client(&self) -> etcd_client::Client {
177        self.connector.get_client()
178    }
179
180    /// Get the primary lease ID.
181    pub fn lease_id(&self) -> u64 {
182        self.primary_lease
183    }
184
185    /// Atomically create a key-value pair if it doesn't already exist.
186    ///
187    /// Returns:
188    /// - `Ok(None)` if the key was successfully created
189    /// - `Ok(Some(version))` if the key already exists (returns the existing version)
190    /// - `Err(...)` only on actual errors (connection failure, timeout, etc.)
191    ///
192    /// This idempotent behavior was introduced in PR #4212 (Nov 10, 2025) to align with
193    /// the StoreOutcome pattern used in KeyValueStore implementations, where both
194    /// Created and Exists are successful outcomes rather than errors. This design supports
195    /// distributed systems where multiple processes might attempt to create the same key.
196    pub async fn kv_create(
197        &self,
198        key: &str,
199        value: Vec<u8>,
200        lease_id: Option<u64>,
201    ) -> Result<Option<u64>> {
202        let id = lease_id.unwrap_or(self.lease_id());
203        let put_options = PutOptions::new().with_lease(id as i64);
204
205        // Build transaction that creates key only if it doesn't exist
206        let txn = Txn::new()
207            .when(vec![Compare::version(key, CompareOp::Equal, 0)]) // Ensure the lock does not exist
208            .and_then(vec![
209                TxnOp::put(key, value, Some(put_options)), // Create the object
210            ])
211            .or_else(vec![
212                TxnOp::get(key, None), // Key exists, get its info
213            ]);
214
215        // Execute the transaction
216        let result = self.connector.get_client().kv_client().txn(txn).await?;
217
218        // Created
219        if result.succeeded() {
220            return Ok(None);
221        }
222
223        // Already exists
224        if let Some(etcd_client::TxnOpResponse::Get(get_resp)) =
225            result.op_responses().into_iter().next()
226            && let Some(kv) = get_resp.kvs().first()
227        {
228            let version = kv.version() as u64;
229            return Ok(Some(version));
230        }
231
232        // Error
233        for resp in result.op_responses() {
234            tracing::warn!(response = ?resp, "kv_create etcd op response");
235        }
236        anyhow::bail!("Unable to create key. Check etcd server status")
237    }
238
239    /// Atomically create a key if it does not exist, or validate the values are identical if the key exists.
240    pub async fn kv_create_or_validate(
241        &self,
242        key: String,
243        value: Vec<u8>,
244        lease_id: Option<u64>,
245    ) -> Result<()> {
246        let id = lease_id.unwrap_or(self.lease_id());
247        let put_options = PutOptions::new().with_lease(id as i64);
248
249        // Build the transaction that either creates the key if it doesn't exist,
250        // or validates the existing value matches what we expect
251        let txn = Txn::new()
252            .when(vec![Compare::version(key.as_str(), CompareOp::Equal, 0)]) // Key doesn't exist
253            .and_then(vec![
254                TxnOp::put(key.as_str(), value.clone(), Some(put_options)), // Create it
255            ])
256            .or_else(vec![
257                // If key exists but values don't match, this will fail the transaction
258                TxnOp::txn(Txn::new().when(vec![Compare::value(
259                    key.as_str(),
260                    CompareOp::Equal,
261                    value.clone(),
262                )])),
263            ]);
264
265        // Execute the transaction
266        let result = self.connector.get_client().kv_client().txn(txn).await?;
267
268        // We have to enumerate the response paths to determine if the transaction succeeded
269        if result.succeeded() {
270            Ok(())
271        } else {
272            match result.op_responses().first() {
273                Some(response) => match response {
274                    TxnOpResponse::Txn(response) => match response.succeeded() {
275                        true => Ok(()),
276                        false => anyhow::bail!(
277                            "Unable to create or validate key. Check etcd server status"
278                        ),
279                    },
280                    _ => {
281                        anyhow::bail!("Unable to validate key operation. Check etcd server status")
282                    }
283                },
284                None => anyhow::bail!("Unable to create or validate key. Check etcd server status"),
285            }
286        }
287    }
288
289    pub async fn kv_put(
290        &self,
291        key: impl AsRef<str>,
292        value: impl AsRef<[u8]>,
293        lease_id: Option<u64>,
294    ) -> Result<()> {
295        let id = lease_id.unwrap_or(self.lease_id());
296        let put_options = PutOptions::new().with_lease(id as i64);
297        let _ = self
298            .connector
299            .get_client()
300            .kv_client()
301            .put(key.as_ref(), value.as_ref(), Some(put_options))
302            .await?;
303        Ok(())
304    }
305
306    pub async fn kv_put_with_options(
307        &self,
308        key: impl AsRef<str>,
309        value: impl AsRef<[u8]>,
310        options: Option<PutOptions>,
311    ) -> Result<PutResponse> {
312        let options = options
313            .unwrap_or_default()
314            .with_lease(self.lease_id() as i64);
315        self.connector
316            .get_client()
317            .kv_client()
318            .put(key.as_ref(), value.as_ref(), Some(options))
319            .await
320            .map_err(|err| err.into())
321    }
322
323    /// Replace an existing value with a compare-on-mod-revision transaction.
324    pub async fn kv_compare_and_put(
325        &self,
326        key: impl AsRef<str>,
327        expected: impl AsRef<[u8]>,
328        value: impl AsRef<[u8]>,
329        lease_id: Option<u64>,
330    ) -> Result<CompareAndPutOutcome> {
331        let key = key.as_ref();
332        let current = self
333            .connector
334            .get_client()
335            .kv_client()
336            .get(key, None)
337            .await?;
338        let Some(current) = current.kvs().first() else {
339            return Ok(CompareAndPutOutcome::Missing);
340        };
341        if current.value() != expected.as_ref() {
342            return Ok(CompareAndPutOutcome::Conflict);
343        }
344        let expected_mod_revision = current.mod_revision();
345
346        let put_options = PutOptions::new().with_lease(lease_id.unwrap_or(self.lease_id()) as i64);
347        let txn = Txn::new()
348            .when(vec![Compare::mod_revision(
349                key,
350                CompareOp::Equal,
351                expected_mod_revision,
352            )])
353            .and_then(vec![TxnOp::put(
354                key,
355                value.as_ref().to_vec(),
356                Some(put_options),
357            )])
358            .or_else(vec![TxnOp::get(key, None)]);
359
360        let result = self.connector.get_client().kv_client().txn(txn).await?;
361        if result.succeeded() {
362            return Ok(CompareAndPutOutcome::Updated);
363        }
364
365        match result.op_responses().into_iter().next() {
366            Some(TxnOpResponse::Get(response)) if response.kvs().is_empty() => {
367                Ok(CompareAndPutOutcome::Missing)
368            }
369            Some(TxnOpResponse::Get(_)) => Ok(CompareAndPutOutcome::Conflict),
370            response => {
371                tracing::warn!(?response, "unexpected compare-and-put response");
372                anyhow::bail!("Unable to compare and replace key. Check etcd server status")
373            }
374        }
375    }
376
377    pub async fn kv_get(
378        &self,
379        key: impl Into<Vec<u8>>,
380        options: Option<GetOptions>,
381    ) -> Result<Vec<KeyValue>> {
382        let mut get_response = self
383            .connector
384            .get_client()
385            .kv_client()
386            .get(key, options)
387            .await?;
388        Ok(get_response.take_kvs())
389    }
390
391    pub async fn kv_delete(
392        &self,
393        key: impl Into<Vec<u8>>,
394        options: Option<DeleteOptions>,
395    ) -> Result<u64> {
396        self.connector
397            .get_client()
398            .kv_client()
399            .delete(key, options)
400            .await
401            .map(|del_response| del_response.deleted() as u64)
402            .map_err(|err| err.into())
403    }
404
405    pub async fn kv_get_prefix(&self, prefix: impl AsRef<str>) -> Result<Vec<KeyValue>> {
406        let mut get_response = self
407            .connector
408            .get_client()
409            .kv_client()
410            .get(prefix.as_ref(), Some(GetOptions::new().with_prefix()))
411            .await?;
412
413        Ok(get_response.take_kvs())
414    }
415
416    /// Acquire a distributed lock using etcd's native lock mechanism
417    /// Returns a LockResponse that can be used to unlock later
418    pub async fn lock(
419        &self,
420        key: impl Into<Vec<u8>>,
421        lease_id: Option<u64>,
422    ) -> Result<LockResponse> {
423        let mut lock_client = self.connector.get_client().lock_client();
424        let id = lease_id.unwrap_or(self.lease_id());
425        let options = LockOptions::new().with_lease(id as i64);
426        lock_client
427            .lock(key, Some(options))
428            .await
429            .map_err(|err| err.into())
430    }
431
432    /// Release a distributed lock using the key from the LockResponse
433    pub async fn unlock(&self, lock_key: impl Into<Vec<u8>>) -> Result<()> {
434        let mut lock_client = self.connector.get_client().lock_client();
435        lock_client
436            .unlock(lock_key)
437            .await
438            .map_err(|err: etcd_client::Error| anyhow::anyhow!(err))?;
439        Ok(())
440    }
441
442    /// Like kv_get_and_watch_prefix but only for new changes, does not include existing values.
443    pub async fn kv_watch_prefix(
444        &self,
445        prefix: impl AsRef<str> + std::fmt::Display,
446    ) -> Result<PrefixWatcher> {
447        self.watch_internal(prefix, false).await
448    }
449
450    pub async fn kv_get_and_watch_prefix(
451        &self,
452        prefix: impl AsRef<str> + std::fmt::Display,
453    ) -> Result<PrefixWatcher> {
454        self.watch_internal(prefix, true).await
455    }
456
457    /// Core watch implementation that sets up a resilient watcher for a key prefix.
458    ///
459    /// Creates a background task that maintains a watch stream with automatic reconnection
460    /// on recoverable errors. If `include_existing` is true, existing keys are included
461    /// in the initial watch events.
462    async fn watch_internal(
463        &self,
464        prefix: impl AsRef<str> + std::fmt::Display,
465        include_existing: bool,
466    ) -> Result<PrefixWatcher> {
467        let (mut start_revision, existing_kvs) = self
468            .get_start_revision(prefix.as_ref(), include_existing)
469            .await?;
470
471        // Size channel to fit all existing KVs (avoids deadlock when sending before return)
472        let existing_count = existing_kvs.as_ref().map_or(0, |kvs| kvs.len());
473        let (tx, rx) = mpsc::channel(existing_count + 32);
474
475        // Send existing KVs before returning so they're immediately available to consumers
476        if let Some(kvs) = existing_kvs {
477            tracing::trace!("sending {} existing kvs", kvs.len());
478            for kv in kvs {
479                tx.send(WatchEvent::Put(kv)).await?;
480            }
481        }
482
483        // Watch for new events in background
484        let connector = self.connector.clone();
485        let prefix_str = prefix.as_ref().to_string();
486        let cancel_token = self.runtime.primary_token();
487        self.rt.spawn(async move {
488            let mut first_connect = true;
489            let mut reconnect = true;
490            while reconnect {
491                if !first_connect {
492                    let mut retry_attempt = 0u64;
493                    let mut retry_backoff = WATCH_RETRY_INITIAL_BACKOFF;
494                    while let Err(err) = Self::resync_watch_prefix(
495                        &connector,
496                        &prefix_str,
497                        &mut start_revision,
498                        &tx,
499                        &cancel_token,
500                    )
501                    .await
502                    {
503                        if tx.is_closed() || cancel_token.is_cancelled() {
504                            return;
505                        }
506
507                        retry_attempt = retry_attempt.saturating_add(1);
508                        if retry_attempt == 1 {
509                            tracing::warn!(
510                                error = %err,
511                                prefix = %prefix_str,
512                                "failed to resync etcd watch prefix after reconnect; retrying"
513                            );
514                        } else {
515                            tracing::info!(
516                                error = %err,
517                                prefix = %prefix_str,
518                                retry_attempt,
519                                backoff_ms = retry_backoff.as_millis(),
520                                "still failing to resync etcd watch prefix after reconnect; retrying"
521                            );
522                        }
523
524                        if Self::is_etcd_connection_error(&err) {
525                            let deadline = std::time::Instant::now() + Duration::from_secs(10);
526                            if let Err(err) = connector.reconnect(deadline).await {
527                                tracing::warn!(
528                                    error = %err,
529                                    prefix = %prefix_str,
530                                    "failed to reconnect to ETCD before watch resync; retrying"
531                                );
532                            }
533                        }
534
535                        tokio::select! {
536                            _ = cancel_token.cancelled() => return,
537                            _ = tokio::time::sleep(Self::watch_retry_backoff(retry_backoff)) => {}
538                        }
539                        retry_backoff =
540                            retry_backoff.saturating_mul(2).min(WATCH_RETRY_MAX_BACKOFF);
541                    }
542                }
543
544                // Start a new watch stream
545                let watch_stream =
546                    match Self::new_watch_stream(&connector, &prefix_str, start_revision).await {
547                        Ok(stream) => stream,
548                        Err(_) => return,
549                    };
550
551                first_connect = false;
552
553                // Watch the stream
554                reconnect =
555                    Self::monitor_watch_stream(watch_stream, &prefix_str, &mut start_revision, &tx)
556                        .await;
557            }
558        });
559
560        Ok(PrefixWatcher {
561            prefix: prefix.as_ref().to_string(),
562            rx,
563        })
564    }
565
566    /// Fetch the start revision and optionally return existing key-values.
567    async fn get_start_revision(
568        &self,
569        prefix: impl AsRef<str> + std::fmt::Display,
570        include_existing: bool,
571    ) -> Result<(i64, Option<Vec<KeyValue>>)> {
572        let mut kv_client = self.connector.get_client().kv_client();
573        let mut get_response = kv_client
574            .get(prefix.as_ref(), Some(GetOptions::new().with_prefix()))
575            .await?;
576
577        // Get the start revision
578        let mut start_revision = get_response
579            .header()
580            .ok_or(anyhow::anyhow!("missing header; unable to get revision"))?
581            .revision();
582        tracing::trace!("{prefix}: start_revision: {start_revision}");
583        start_revision += 1;
584
585        // Return existing KVs if requested
586        let existing_kvs = include_existing.then(|| {
587            let kvs = get_response.take_kvs();
588            tracing::trace!("initial kv count: {:?}", kvs.len());
589            kvs
590        });
591
592        Ok((start_revision, existing_kvs))
593    }
594
595    /// Fetch current prefix state after reconnect and publish it as an authoritative snapshot.
596    async fn resync_watch_prefix(
597        connector: &Arc<Connector>,
598        prefix: &str,
599        start_revision: &mut i64,
600        tx: &mpsc::Sender<WatchEvent>,
601        cancel_token: &CancellationToken,
602    ) -> Result<()> {
603        let mut kv_client = connector.get_client().kv_client();
604        let get_result = tokio::select! {
605            _ = cancel_token.cancelled() => anyhow::bail!("watch resync cancelled"),
606            result = tokio::time::timeout(
607                WATCH_RESYNC_GET_TIMEOUT,
608                kv_client.get(prefix, Some(GetOptions::new().with_prefix())),
609            ) => result,
610        };
611        let mut response = get_result
612            .with_context(|| format!("timed out fetching etcd prefix snapshot for '{prefix}'"))?
613            .with_context(|| format!("failed to fetch etcd prefix snapshot for '{prefix}'"))?;
614
615        let header = response
616            .header()
617            .ok_or_else(|| anyhow::anyhow!("missing header during watch resync for '{prefix}'"))?;
618        *start_revision = header.revision() + 1;
619
620        let kvs = response.take_kvs();
621        tracing::warn!(
622            prefix,
623            kv_count = kvs.len(),
624            start_revision = *start_revision,
625            "resyncing etcd watch prefix after reconnect"
626        );
627
628        tokio::select! {
629            _ = cancel_token.cancelled() => anyhow::bail!("watch resync cancelled"),
630            result = tx.send(WatchEvent::Resync(kvs)) => {
631                result.context("failed to send WatchEvent::Resync")
632            }
633        }
634    }
635
636    fn is_etcd_connection_error(err: &anyhow::Error) -> bool {
637        if err.chain().any(|cause| {
638            cause
639                .downcast_ref::<tokio::time::error::Elapsed>()
640                .is_some()
641        }) {
642            return true;
643        }
644
645        err.chain().any(|cause| {
646            let Some(err) = cause.downcast_ref::<etcd_client::Error>() else {
647                return false;
648            };
649
650            match err {
651                etcd_client::Error::IoError(_)
652                | etcd_client::Error::TransportError(_)
653                | etcd_client::Error::EndpointError(_) => true,
654                etcd_client::Error::GRpcStatus(status) => matches!(
655                    status.code() as i32,
656                    // tonic::Code::Cancelled
657                    1
658                    // tonic::Code::Unknown
659                    | 2
660                    // tonic::Code::DeadlineExceeded
661                    | 4
662                    // tonic::Code::Unavailable
663                    | 14
664                ),
665                _ => false,
666            }
667        })
668    }
669
670    fn watch_retry_backoff(current: Duration) -> Duration {
671        let max_ms = u64::try_from(current.as_millis()).unwrap_or(u64::MAX);
672        let min_ms = (max_ms / 2).max(1);
673        let jitter_range = max_ms.saturating_sub(min_ms).saturating_add(1);
674        Duration::from_millis(min_ms + rand::random::<u64>() % jitter_range)
675    }
676
677    /// Establish a new watch stream with automatic retry and reconnection.
678    ///
679    /// Attempts to create a watch stream, reconnecting to ETCD if necessary.
680    /// Uses a 10-second timeout for reconnection attempts before giving up.
681    async fn new_watch_stream(
682        connector: &Arc<Connector>,
683        prefix: &String,
684        start_revision: i64,
685    ) -> Result<WatchStream> {
686        loop {
687            match connector
688                .get_client()
689                .watch_client()
690                .watch(
691                    prefix.as_str(),
692                    Some(
693                        WatchOptions::new()
694                            .with_prefix()
695                            .with_start_revision(start_revision)
696                            .with_prev_key(),
697                    ),
698                )
699                .await
700            {
701                Ok((_, watch_stream)) => {
702                    tracing::debug!("Watch stream established for prefix '{prefix}'");
703                    return Ok(watch_stream);
704                }
705                Err(err) => {
706                    tracing::debug!(error = %err, "Failed to establish watch stream for prefix '{}'", prefix);
707                    let deadline = std::time::Instant::now() + Duration::from_secs(10);
708                    if let Err(err) = connector.reconnect(deadline).await {
709                        tracing::error!(
710                            "Failed to reconnect to ETCD within 10 secs for watching prefix '{}': {}",
711                            prefix,
712                            err
713                        );
714                        return Err(err);
715                    }
716                    // continue - retry establishing the watch stream
717                }
718            }
719        }
720    }
721
722    /// Monitor a watch stream and forward events to receivers.
723    ///
724    /// Returns `true` for recoverable errors (network issues, stream closure) that warrant
725    /// reconnection attempts. Returns `false` for permanent failures (protocol violations,
726    /// channel errors, no receivers) where watching should stop.
727    async fn monitor_watch_stream(
728        mut watch_stream: WatchStream,
729        prefix: &String,
730        start_revision: &mut i64,
731        tx: &mpsc::Sender<WatchEvent>,
732    ) -> bool {
733        loop {
734            tokio::select! {
735                maybe_resp = watch_stream.next() => {
736                    // Handle the watch response
737                    let response = match maybe_resp {
738                        Some(Ok(res)) => res,
739                        Some(Err(err)) => {
740                            tracing::warn!(error = %err, "Error watching stream for prefix '{}'", prefix);
741                            return true; // Exit to reconnect
742                        }
743                        None => {
744                            tracing::warn!("Watch stream unexpectedly closed for prefix '{prefix}'");
745                            return true; // Exit to reconnect
746                        }
747                    };
748
749                    // Update revision for reconnect
750                    *start_revision = match response.header() {
751                        Some(header) => header.revision() + 1,
752                        None => {
753                            tracing::error!("Missing header in watch response for prefix '{prefix}'");
754                            return false;
755                        }
756                    };
757
758                    // Process events
759                    if Self::process_watch_events(response.events(), tx).await.is_err() {
760                        return false;
761                    };
762                }
763                _ = tx.closed() => {
764                    tracing::debug!("no more receivers, stopping watcher");
765                    return false;
766                }
767            }
768        }
769    }
770
771    /// Process etcd events and forward them as Put/Delete watch events.
772    ///
773    /// Filters out events without key-values and transforms etcd events into
774    /// appropriate WatchEvent types for channel transmission.
775    async fn process_watch_events(
776        events: &[etcd_client::Event],
777        tx: &mpsc::Sender<WatchEvent>,
778    ) -> Result<()> {
779        for event in events {
780            // Extract the KeyValue if it exists
781            let Some(kv) = event.kv() else {
782                continue; // Skip events with no KV
783            };
784
785            // Handle based on event type
786            match event.event_type() {
787                etcd_client::EventType::Put => {
788                    if let Err(err) = tx.send(WatchEvent::Put(kv.clone())).await {
789                        tracing::error!("kv watcher error forwarding WatchEvent::Put: {err}");
790                        return Err(err.into());
791                    }
792                }
793                etcd_client::EventType::Delete => {
794                    if tx.send(WatchEvent::Delete(kv.clone())).await.is_err() {
795                        return Err(anyhow::anyhow!("failed to send WatchEvent::Delete"));
796                    }
797                }
798            }
799        }
800        Ok(())
801    }
802}
803
804#[derive(Dissolve)]
805pub struct PrefixWatcher {
806    prefix: String,
807    rx: mpsc::Receiver<WatchEvent>,
808}
809
810#[derive(Debug)]
811pub enum WatchEvent {
812    Put(KeyValue),
813    Delete(KeyValue),
814    /// Full prefix state after watch reconnection.
815    ///
816    /// Consumers that maintain local state should replace that state with this
817    /// authoritative snapshot before applying subsequent incremental events.
818    Resync(Vec<KeyValue>),
819}
820
821/// ETCD client configuration options
822#[derive(Debug, Clone, Builder, Validate)]
823pub struct ClientOptions {
824    #[validate(length(min = 1))]
825    pub etcd_url: Vec<String>,
826
827    #[builder(default)]
828    pub etcd_connect_options: Option<ConnectOptions>,
829
830    /// If true, the client will attach a lease to the primary [`CancellationToken`].
831    #[builder(default = "true")]
832    pub attach_lease: bool,
833
834    /// Lease TTL in seconds
835    #[builder(default = "default_lease_ttl()")]
836    pub lease_ttl: u64,
837}
838
839impl Default for ClientOptions {
840    fn default() -> Self {
841        let mut connect_options = None;
842
843        if let (Ok(username), Ok(password)) = (
844            std::env::var(env_etcd::auth::ETCD_AUTH_USERNAME),
845            std::env::var(env_etcd::auth::ETCD_AUTH_PASSWORD),
846        ) {
847            // username and password are set
848            connect_options = Some(ConnectOptions::new().with_user(username, password));
849        } else if let (Ok(ca), Ok(cert), Ok(key)) = (
850            std::env::var(env_etcd::auth::ETCD_AUTH_CA),
851            std::env::var(env_etcd::auth::ETCD_AUTH_CLIENT_CERT),
852            std::env::var(env_etcd::auth::ETCD_AUTH_CLIENT_KEY),
853        ) {
854            // TLS is set
855            connect_options = Some(
856                ConnectOptions::new().with_tls(
857                    TlsOptions::new()
858                        .ca_certificate(Certificate::from_pem(ca))
859                        .identity(Identity::from_pem(cert, key)),
860                ),
861            );
862        }
863
864        ClientOptions {
865            etcd_url: default_servers(),
866            etcd_connect_options: connect_options,
867            attach_lease: true,
868            lease_ttl: default_lease_ttl(),
869        }
870    }
871}
872
873fn default_servers() -> Vec<String> {
874    match std::env::var(env_etcd::ETCD_ENDPOINTS) {
875        Ok(possible_list_of_urls) => possible_list_of_urls
876            .split(',')
877            .map(|s| s.to_string())
878            .collect(),
879        Err(_) => vec!["http://localhost:2379".to_string()],
880    }
881}
882
883fn default_lease_ttl() -> u64 {
884    match std::env::var(env_etcd::ETCD_LEASE_TTL) {
885        Ok(raw) => match raw.parse::<u64>() {
886            Ok(ttl) if ttl > 0 => ttl,
887            Ok(_) => {
888                tracing::warn!(
889                    "{} must be >= 1; got 0. Falling back to 10.",
890                    env_etcd::ETCD_LEASE_TTL
891                );
892                10
893            }
894            Err(err) => {
895                tracing::warn!(
896                    "Invalid {}='{}' ({err}). Falling back to 10.",
897                    env_etcd::ETCD_LEASE_TTL,
898                    raw
899                );
900                10
901            }
902        },
903        Err(_) => 10,
904    }
905}
906
907/// A cache for etcd key-value pairs that watches for changes
908pub struct KvCache {
909    client: Client,
910    pub prefix: String,
911    cache: Arc<RwLock<HashMap<String, Vec<u8>>>>,
912    watcher: Option<PrefixWatcher>,
913}
914
915impl KvCache {
916    /// Create a new KV cache for the given prefix
917    pub async fn new(
918        client: Client,
919        prefix: String,
920        initial_values: HashMap<String, Vec<u8>>,
921    ) -> Result<Self> {
922        let mut cache = HashMap::new();
923
924        // First get all existing keys with this prefix
925        let existing_kvs = client.kv_get_prefix(&prefix).await?;
926        for kv in existing_kvs {
927            let key = String::from_utf8_lossy(kv.key()).to_string();
928            cache.insert(key, kv.value().to_vec());
929        }
930
931        // For any keys in initial_values that don't exist in etcd, write them
932        // TODO: proper lease handling, this requires the first process that write to a prefix atomically
933        // create a lease and write the lease to etcd. Later processes will attach to the lease and
934        // help refresh the lease.
935        for (key, value) in initial_values.iter() {
936            let full_key = format!("{}{}", prefix, key);
937            if let std::collections::hash_map::Entry::Vacant(e) = cache.entry(full_key.clone()) {
938                client.kv_put(&full_key, value.clone(), None).await?;
939                e.insert(value.clone());
940            }
941        }
942
943        // Start watching for changes
944        // we won't miss events between the initial push and the watcher starting because
945        // client.kv_get_and_watch_prefix() will get all kv pairs and put them back again
946        let watcher = client.kv_get_and_watch_prefix(&prefix).await?;
947
948        let cache = Arc::new(RwLock::new(cache));
949        let mut result = Self {
950            client,
951            prefix,
952            cache,
953            watcher: Some(watcher),
954        };
955
956        // Start the background watcher task
957        result.start_watcher().await?;
958
959        Ok(result)
960    }
961
962    /// Start the background watcher task
963    async fn start_watcher(&mut self) -> Result<()> {
964        if let Some(watcher) = self.watcher.take() {
965            let cache = self.cache.clone();
966            let prefix = self.prefix.clone();
967
968            tokio::spawn(async move {
969                let mut rx = watcher.rx;
970
971                while let Some(event) = rx.recv().await {
972                    match event {
973                        WatchEvent::Put(kv) => {
974                            let key = String::from_utf8_lossy(kv.key()).to_string();
975                            let value = kv.value().to_vec();
976
977                            tracing::trace!("KvCache update: {} = {:?}", key, value);
978                            let mut cache_write = cache.write().await;
979                            cache_write.insert(key, value);
980                        }
981                        WatchEvent::Delete(kv) => {
982                            let key = String::from_utf8_lossy(kv.key()).to_string();
983
984                            tracing::trace!("KvCache delete: {key}");
985                            let mut cache_write = cache.write().await;
986                            cache_write.remove(&key);
987                        }
988                        WatchEvent::Resync(kvs) => {
989                            let mut replacement = HashMap::with_capacity(kvs.len());
990                            for kv in kvs {
991                                let key = String::from_utf8_lossy(kv.key()).to_string();
992                                let value = kv.value().to_vec();
993                                replacement.insert(key, value);
994                            }
995
996                            tracing::warn!(
997                                prefix,
998                                new_count = replacement.len(),
999                                "KvCache replacing state from etcd watch resync"
1000                            );
1001                            let mut cache_write = cache.write().await;
1002                            *cache_write = replacement;
1003                        }
1004                    }
1005                }
1006
1007                tracing::debug!("KvCache watcher for prefix '{prefix}' stopped");
1008            });
1009        }
1010
1011        Ok(())
1012    }
1013
1014    /// Get a value from the cache
1015    pub async fn get(&self, key: &str) -> Option<Vec<u8>> {
1016        let full_key = format!("{}{}", self.prefix, key);
1017        let cache_read = self.cache.read().await;
1018        cache_read.get(&full_key).cloned()
1019    }
1020
1021    /// Get all key-value pairs in the cache
1022    pub async fn get_all(&self) -> HashMap<String, Vec<u8>> {
1023        let cache_read = self.cache.read().await;
1024        cache_read.clone()
1025    }
1026
1027    /// Update a value in both the cache and etcd
1028    pub async fn put(&self, key: &str, value: Vec<u8>, lease_id: Option<u64>) -> Result<()> {
1029        let full_key = format!("{}{}", self.prefix, key);
1030
1031        // Update etcd first
1032        self.client
1033            .kv_put(&full_key, value.clone(), lease_id)
1034            .await?;
1035
1036        // Then update local cache
1037        let mut cache_write = self.cache.write().await;
1038        cache_write.insert(full_key, value);
1039
1040        Ok(())
1041    }
1042
1043    /// Delete a key from both the cache and etcd
1044    pub async fn delete(&self, key: &str) -> Result<()> {
1045        let full_key = format!("{}{}", self.prefix, key);
1046
1047        // Delete from etcd first
1048        self.client.kv_delete(full_key.clone(), None).await?;
1049
1050        // Then remove from local cache
1051        let mut cache_write = self.cache.write().await;
1052        cache_write.remove(&full_key);
1053
1054        Ok(())
1055    }
1056}
1057
1058#[cfg(test)]
1059mod unit_tests {
1060    use super::*;
1061
1062    #[test]
1063    fn classifies_etcd_connection_errors() {
1064        let err = anyhow::Error::new(etcd_client::Error::EndpointError(
1065            "endpoint unavailable".to_string(),
1066        ));
1067        assert!(Client::is_etcd_connection_error(&err));
1068
1069        let err = anyhow::Error::new(etcd_client::Error::IoError(std::io::Error::new(
1070            std::io::ErrorKind::ConnectionRefused,
1071            "connection refused",
1072        )));
1073        assert!(Client::is_etcd_connection_error(&err));
1074
1075        let runtime = tokio::runtime::Builder::new_current_thread()
1076            .enable_time()
1077            .build()
1078            .unwrap();
1079        let elapsed = runtime.block_on(async {
1080            tokio::time::timeout(Duration::from_millis(0), std::future::pending::<()>())
1081                .await
1082                .unwrap_err()
1083        });
1084        let err = anyhow::Error::new(elapsed).context("timed out fetching etcd prefix snapshot");
1085        assert!(Client::is_etcd_connection_error(&err));
1086
1087        let err = anyhow::Error::new(etcd_client::Error::InvalidArgs("bad request".to_string()));
1088        assert!(!Client::is_etcd_connection_error(&err));
1089
1090        let err = anyhow::anyhow!("missing header during watch resync");
1091        assert!(!Client::is_etcd_connection_error(&err));
1092    }
1093}
1094
1095#[cfg(feature = "integration")]
1096#[cfg(test)]
1097mod tests {
1098    use crate::{DistributedRuntime, distributed::DistributedConfig};
1099
1100    use super::*;
1101
1102    #[test]
1103    fn test_ectd_client() {
1104        let rt = Runtime::single_threaded().unwrap();
1105        let rt_clone = rt.clone();
1106        let config = DistributedConfig::from_settings();
1107
1108        rt_clone.primary().block_on(async move {
1109            let drt = DistributedRuntime::new(rt, config).await.unwrap();
1110            test_kv_create_or_validate(drt).await.unwrap();
1111        });
1112    }
1113
1114    async fn test_kv_create_or_validate(drt: DistributedRuntime) -> Result<()> {
1115        let key = "__integration_test_key";
1116        let value = b"test_value";
1117
1118        let client = Client::new(ClientOptions::default(), drt.runtime().clone())
1119            .await
1120            .expect("etcd client should be available");
1121        let lease_id = drt.connection_id();
1122
1123        // Create the key
1124        let result = client.kv_create(key, value.to_vec(), Some(lease_id)).await;
1125        assert!(result.is_ok(), "");
1126
1127        // Try to create the key again - this should return Ok(Some(version)) indicating key already exists
1128        // Note: Prior to PR #4212 (Nov 10, 2025), kv_create returned Err when key existed.
1129        // PR #4212 changed the behavior to return Ok(Some(version)) for idempotency, matching
1130        // the StoreOutcome::Exists pattern used in the KeyValueStore abstraction.
1131        // The transaction now includes .or_else(TxnOp::get) to retrieve existing key info
1132        // instead of failing, making the operation idempotent for distributed systems.
1133        let result = client.kv_create(key, value.to_vec(), Some(lease_id)).await;
1134        assert!(
1135            result.is_ok() && result.unwrap().is_some(),
1136            "Expected Ok(Some(version)) when key already exists"
1137        );
1138
1139        // Create or validate should succeed as the values match
1140        let result = client
1141            .kv_create_or_validate(key.to_string(), value.to_vec(), Some(lease_id))
1142            .await;
1143        assert!(result.is_ok());
1144
1145        // Try to create the key with a different value
1146        let different_value = b"different_value";
1147        let result = client
1148            .kv_create_or_validate(key.to_string(), different_value.to_vec(), Some(lease_id))
1149            .await;
1150        assert!(result.is_err(), "");
1151
1152        Ok(())
1153    }
1154
1155    #[test]
1156    fn test_kv_cache() {
1157        let rt = Runtime::single_threaded().unwrap();
1158        let rt_clone = rt.clone();
1159        let config = DistributedConfig::from_settings();
1160
1161        rt_clone.primary().block_on(async move {
1162            let drt = DistributedRuntime::new(rt, config).await.unwrap();
1163            test_kv_cache_operations(drt).await.unwrap();
1164        });
1165    }
1166
1167    async fn test_kv_cache_operations(drt: DistributedRuntime) -> Result<()> {
1168        // Make the client and unwrap it
1169        let client = Client::new(ClientOptions::default(), drt.runtime().clone())
1170            .await
1171            .expect("etcd client should be available");
1172
1173        // Create a unique test prefix to avoid conflicts with other tests
1174        let test_id = uuid::Uuid::new_v4().to_string();
1175        let prefix = format!("v1/test_kv_cache_{}/", test_id);
1176
1177        // Initial values
1178        let mut initial_values = HashMap::new();
1179        initial_values.insert("key1".to_string(), b"value1".to_vec());
1180        initial_values.insert("key2".to_string(), b"value2".to_vec());
1181
1182        // Create the KV cache
1183        let kv_cache = KvCache::new(client.clone(), prefix.clone(), initial_values).await?;
1184
1185        // Test get
1186        let value1 = kv_cache.get("key1").await;
1187        assert_eq!(value1, Some(b"value1".to_vec()));
1188
1189        let value2 = kv_cache.get("key2").await;
1190        assert_eq!(value2, Some(b"value2".to_vec()));
1191
1192        // Test get_all
1193        let all_values = kv_cache.get_all().await;
1194        assert_eq!(all_values.len(), 2);
1195        assert_eq!(
1196            all_values.get(&format!("{}key1", prefix)),
1197            Some(&b"value1".to_vec())
1198        );
1199        assert_eq!(
1200            all_values.get(&format!("{}key2", prefix)),
1201            Some(&b"value2".to_vec())
1202        );
1203
1204        // Test put - using None for lease_id
1205        kv_cache.put("key3", b"value3".to_vec(), None).await?;
1206
1207        // Allow some time for the update to propagate
1208        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1209
1210        // Verify the new value
1211        let value3 = kv_cache.get("key3").await;
1212        assert_eq!(value3, Some(b"value3".to_vec()));
1213
1214        // Test update
1215        kv_cache
1216            .put("key1", b"updated_value1".to_vec(), None)
1217            .await?;
1218
1219        // Allow some time for the update to propagate
1220        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1221
1222        // Verify the updated value
1223        let updated_value1 = kv_cache.get("key1").await;
1224        assert_eq!(updated_value1, Some(b"updated_value1".to_vec()));
1225
1226        // Test external update (simulating another client updating a value)
1227        client
1228            .kv_put(
1229                &format!("{}key2", prefix),
1230                b"external_update".to_vec(),
1231                None,
1232            )
1233            .await?;
1234
1235        // Allow some time for the update to propagate
1236        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1237
1238        // Verify the cache was updated
1239        let external_update = kv_cache.get("key2").await;
1240        assert_eq!(external_update, Some(b"external_update".to_vec()));
1241
1242        // Clean up - delete the test keys
1243        let etcd_client = client.etcd_client();
1244        let _ = etcd_client
1245            .kv_client()
1246            .delete(
1247                prefix,
1248                Some(etcd_client::DeleteOptions::new().with_prefix()),
1249            )
1250            .await?;
1251
1252        Ok(())
1253    }
1254}