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