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