Skip to main content

dynamo_runtime/storage/kv/
etcd.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::pin::Pin;
6use std::time::Duration;
7
8use crate::transports::etcd;
9use async_stream::stream;
10use async_trait::async_trait;
11use etcd_client::PutOptions;
12
13use super::{Bucket, Key, KeyValue, Store, StoreError, StoreOutcome, WatchEvent};
14
15#[derive(Clone)]
16pub struct EtcdStore {
17    client: etcd::Client,
18}
19
20impl EtcdStore {
21    pub fn new(client: etcd::Client) -> Self {
22        Self { client }
23    }
24}
25
26#[async_trait]
27impl Store for EtcdStore {
28    type Bucket = EtcdBucket;
29
30    /// A "bucket" in etcd is a path prefix
31    async fn get_or_create_bucket(
32        &self,
33        bucket_name: &str,
34        _ttl: Option<Duration>, // TODO ttl not used yet
35    ) -> Result<Self::Bucket, StoreError> {
36        Ok(EtcdBucket {
37            client: self.client.clone(),
38            bucket_name: bucket_name.to_string(),
39        })
40    }
41
42    /// A "bucket" in etcd is a path prefix. This creates an EtcdBucket object without doing
43    /// any network calls.
44    async fn get_bucket(&self, bucket_name: &str) -> Result<Option<Self::Bucket>, StoreError> {
45        Ok(Some(EtcdBucket {
46            client: self.client.clone(),
47            bucket_name: bucket_name.to_string(),
48        }))
49    }
50
51    fn connection_id(&self) -> u64 {
52        self.client.lease_id()
53    }
54
55    fn shutdown(&self) {
56        // Revoke the lease? etcd will do it for us on disconnect.
57    }
58
59    async fn check_connection(&self) -> Result<(), StoreError> {
60        self.client
61            .check_connection()
62            .await
63            .map_err(|e| StoreError::EtcdError(e.to_string()))
64    }
65}
66
67pub struct EtcdBucket {
68    client: etcd::Client,
69    bucket_name: String,
70}
71
72#[async_trait]
73impl Bucket for EtcdBucket {
74    async fn insert(
75        &self,
76        key: &Key,
77        value: bytes::Bytes,
78        // "version" in etcd speak. revision is a global cluster-wide value
79        revision: u64,
80    ) -> Result<StoreOutcome, StoreError> {
81        let version = revision;
82        if version == 0 {
83            self.create(key, value).await
84        } else {
85            self.update(key, value, version).await
86        }
87    }
88
89    async fn compare_and_replace(
90        &self,
91        key: &Key,
92        expected: bytes::Bytes,
93        value: bytes::Bytes,
94    ) -> Result<StoreOutcome, StoreError> {
95        let k = make_key(&self.bucket_name, key);
96        match self
97            .client
98            .kv_compare_and_put(k, expected, value, None)
99            .await
100            .map_err(|error| StoreError::EtcdError(error.to_string()))?
101        {
102            etcd::CompareAndPutOutcome::Updated => Ok(StoreOutcome::Created(0)),
103            etcd::CompareAndPutOutcome::Missing => Err(StoreError::MissingKey(key.to_string())),
104            etcd::CompareAndPutOutcome::Conflict => Err(StoreError::Retry),
105        }
106    }
107
108    async fn get(&self, key: &Key) -> Result<Option<bytes::Bytes>, StoreError> {
109        let k = make_key(&self.bucket_name, key);
110        tracing::trace!("etcd get: {k}");
111
112        let mut kvs = self
113            .client
114            .kv_get(k, None)
115            .await
116            .map_err(|e| StoreError::EtcdError(e.to_string()))?;
117        if kvs.is_empty() {
118            return Ok(None);
119        }
120        let (_, val) = kvs.swap_remove(0).into_key_value();
121        Ok(Some(val.into()))
122    }
123
124    async fn delete(&self, key: &Key) -> Result<(), StoreError> {
125        let k = make_key(&self.bucket_name, key);
126        tracing::trace!("etcd delete: {k}");
127        let _ = self
128            .client
129            .kv_delete(k, None)
130            .await
131            .map_err(|e| StoreError::EtcdError(e.to_string()))?;
132        Ok(())
133    }
134
135    async fn watch(
136        &self,
137    ) -> Result<Pin<Box<dyn futures::Stream<Item = WatchEvent> + Send + 'life0>>, StoreError> {
138        let prefix = make_key(&self.bucket_name, &"".into());
139        tracing::trace!("etcd watch: {prefix}");
140        let watcher = self
141            .client
142            .kv_get_and_watch_prefix(&prefix)
143            .await
144            .map_err(|e| StoreError::EtcdError(e.to_string()))?;
145        let (_, mut watch_stream) = watcher.dissolve();
146        let output = stream! {
147            while let Some(event) = watch_stream.recv().await {
148                match event {
149                    etcd::WatchEvent::Put(kv) => {
150                        let (k, v) = kv.into_key_value();
151                        let key = match String::from_utf8(k) {
152                            Ok(k) => Key::new(k),
153                            Err(err) => {
154                                tracing::error!(%err, prefix, "Invalid UTF8 in etcd key");
155                                continue;
156                            }
157                        };
158                        let item = KeyValue::new(key, v.into());
159                        yield WatchEvent::Put(item);
160                    }
161                    etcd::WatchEvent::Delete(kv) => {
162                        let (k, _) = kv.into_key_value();
163                        let key = match String::from_utf8(k) {
164                            Ok(k) => Key::new(k),
165                            Err(err) => {
166                                tracing::error!(%err, prefix, "Invalid UTF8 in etcd key");
167                                continue;
168                            }
169                        };
170                        yield WatchEvent::Delete(key);
171                    }
172                    etcd::WatchEvent::Resync(kvs) => {
173                        let mut snapshot = HashMap::with_capacity(kvs.len());
174                        for kv in kvs {
175                            let (k, v) = kv.into_key_value();
176                            let key = match String::from_utf8(k) {
177                                Ok(k) => Key::new(k),
178                                Err(err) => {
179                                    tracing::error!(%err, prefix, "Invalid UTF8 in etcd resync key");
180                                    continue;
181                                }
182                            };
183                            snapshot.insert(key, v.into());
184                        }
185                        yield WatchEvent::Resync(snapshot);
186                    }
187                }
188            }
189        };
190        Ok(Box::pin(output))
191    }
192
193    async fn entries(&self) -> Result<HashMap<Key, bytes::Bytes>, StoreError> {
194        let k = make_key(&self.bucket_name, &"".into());
195        tracing::trace!("etcd entries: {k}");
196
197        let resp = self
198            .client
199            .kv_get_prefix(k)
200            .await
201            .map_err(|e| StoreError::EtcdError(e.to_string()))?;
202        let out: HashMap<Key, bytes::Bytes> = resp
203            .into_iter()
204            .map(|kv| {
205                let (k, v) = kv.into_key_value();
206                (Key::new(String::from_utf8_lossy(&k).to_string()), v.into())
207            })
208            .collect();
209
210        Ok(out)
211    }
212}
213
214impl EtcdBucket {
215    async fn create(
216        &self,
217        key: &Key,
218        value: impl Into<Vec<u8>>,
219    ) -> Result<StoreOutcome, StoreError> {
220        let k = make_key(&self.bucket_name, key);
221        tracing::trace!("etcd create: {k}");
222
223        match self
224            .client
225            .kv_create(k.as_str(), value.into(), None)
226            .await
227            .map_err(|e| StoreError::EtcdError(e.to_string()))?
228        {
229            None => {
230                // Key was created successfully
231                Ok(StoreOutcome::Created(1)) // version of new key is always 1
232            }
233            Some(revision) => Ok(StoreOutcome::Exists(revision)),
234        }
235    }
236
237    async fn update(
238        &self,
239        key: &Key,
240        value: impl AsRef<[u8]>,
241        revision: u64,
242    ) -> Result<StoreOutcome, StoreError> {
243        let version = revision;
244        let k = make_key(&self.bucket_name, key);
245        tracing::trace!("etcd update: {k}");
246
247        let kvs = self
248            .client
249            .kv_get(k.clone(), None)
250            .await
251            .map_err(|e| StoreError::EtcdError(e.to_string()))?;
252        if kvs.is_empty() {
253            return Err(StoreError::MissingKey(key.to_string()));
254        }
255        let current_version = kvs.first().unwrap().version() as u64;
256        if current_version != version + 1 {
257            tracing::warn!(
258                current_version,
259                attempted_next_version = version,
260                %key,
261                "update: Wrong revision"
262            );
263            // NATS does a resync_update, overwriting the key anyway and getting the new revision.
264            // So we do too in etcd.
265        }
266
267        let put_options = PutOptions::new()
268            .with_lease(self.client.lease_id() as i64)
269            .with_prev_key();
270        let mut put_resp = self
271            .client
272            .kv_put_with_options(k, value, Some(put_options))
273            .await
274            .map_err(|e| StoreError::EtcdError(e.to_string()))?;
275        Ok(match put_resp.take_prev_key() {
276            // Should this be an error?
277            // The key was deleted between our get and put. We re-created it.
278            // Version of new key is always 1.
279            // <https://etcd.io/docs/v3.5/learning/data_model/>
280            None => StoreOutcome::Created(1),
281            // Expected case, success
282            Some(kv) if kv.version() as u64 == version + 1 => StoreOutcome::Created(version),
283            // Should this be an error? Something updated the version between our get and put
284            Some(kv) => StoreOutcome::Created(kv.version() as u64 + 1),
285        })
286    }
287}
288
289fn make_key(bucket_name: &str, key: &Key) -> String {
290    [bucket_name.to_string(), key.to_string()].join("/")
291}
292
293#[cfg(feature = "integration")]
294#[cfg(test)]
295mod concurrent_create_tests {
296    use super::*;
297    use crate::Runtime;
298    use crate::transports::etcd as etcd_transport;
299    use futures::StreamExt;
300    use std::sync::Arc;
301    use tokio::sync::Barrier;
302
303    #[test]
304    fn test_concurrent_etcd_create_race_condition() {
305        let rt = Runtime::single_threaded().unwrap();
306        let rt_clone = rt.clone();
307
308        rt_clone.primary().block_on(async move {
309            let etcd_client =
310                etcd_transport::Client::new(etcd_transport::ClientOptions::default(), rt)
311                    .await
312                    .unwrap();
313            let storage = crate::storage::kv::Manager::etcd(etcd_client);
314            test_concurrent_create(&storage).await.unwrap();
315        });
316    }
317
318    #[test]
319    fn delete_wins_race_with_compare_and_replace() {
320        let rt = Runtime::single_threaded().unwrap();
321        let rt_clone = rt.clone();
322
323        rt_clone.primary().block_on(async move {
324            let etcd_client =
325                etcd_transport::Client::new(etcd_transport::ClientOptions::default(), rt)
326                    .await
327                    .unwrap();
328            let storage = crate::storage::kv::Manager::etcd(etcd_client);
329            let bucket = Arc::new(
330                storage
331                    .get_or_create_bucket("test_compare_and_replace_bucket", None)
332                    .await
333                    .unwrap(),
334            );
335            let key = Key::new(format!("model_{}", uuid::Uuid::new_v4()));
336            bucket.insert(&key, "old".into(), 0).await.unwrap();
337
338            let barrier = Arc::new(Barrier::new(3));
339            let task_bucket = bucket.clone();
340            let task_key = key.clone();
341            let task_barrier = barrier.clone();
342            let update = tokio::spawn(async move {
343                task_barrier.wait().await;
344                task_bucket
345                    .compare_and_replace(&task_key, "old".into(), "new".into())
346                    .await
347            });
348            let task_bucket = bucket.clone();
349            let task_key = key.clone();
350            let task_barrier = barrier.clone();
351            let delete = tokio::spawn(async move {
352                task_barrier.wait().await;
353                task_bucket.delete(&task_key).await
354            });
355
356            barrier.wait().await;
357            let update_result = update.await.unwrap();
358            delete.await.unwrap().unwrap();
359            assert!(
360                update_result.is_ok() || matches!(update_result, Err(StoreError::MissingKey(_)))
361            );
362            assert_eq!(bucket.get(&key).await.unwrap(), None);
363        });
364    }
365
366    #[test]
367    fn watch_starts_with_one_resync_of_the_prefix() {
368        let rt = Runtime::single_threaded().unwrap();
369        let rt_clone = rt.clone();
370
371        rt_clone.primary().block_on(async move {
372            let etcd_client =
373                etcd_transport::Client::new(etcd_transport::ClientOptions::default(), rt)
374                    .await
375                    .unwrap();
376            let storage = crate::storage::kv::Manager::etcd(etcd_client);
377            let bucket_name = format!("test_watch_resync_{}", uuid::Uuid::new_v4());
378            let bucket = storage
379                .get_or_create_bucket(&bucket_name, None)
380                .await
381                .unwrap();
382            let existing = Key::new("existing".to_string());
383            bucket.insert(&existing, "1".into(), 0).await.unwrap();
384
385            let mut events = bucket.watch().await.unwrap();
386            let first = events.next().await.unwrap();
387            let WatchEvent::Resync(snapshot) = first else {
388                panic!("expected the initial resync, got {first:?}");
389            };
390            assert_eq!(
391                snapshot
392                    .get(&Key::new(make_key(&bucket_name, &existing)))
393                    .map(|value| value.as_ref()),
394                Some(b"1".as_slice())
395            );
396
397            let later = Key::new("later".to_string());
398            bucket.insert(&later, "2".into(), 0).await.unwrap();
399            let second = events.next().await.unwrap();
400            let WatchEvent::Put(item) = second else {
401                panic!("expected a put after the snapshot, got {second:?}");
402            };
403            assert_eq!(item.key_str(), make_key(&bucket_name, &later));
404
405            bucket.delete(&existing).await.unwrap();
406            bucket.delete(&later).await.unwrap();
407        });
408    }
409
410    async fn test_concurrent_create(
411        storage: &crate::storage::kv::Manager,
412    ) -> Result<(), StoreError> {
413        // Create a bucket for testing
414        let bucket = Arc::new(tokio::sync::Mutex::new(
415            storage
416                .get_or_create_bucket("test_concurrent_bucket", None)
417                .await?,
418        ));
419
420        // Number of concurrent workers
421        let num_workers = 10;
422        let barrier = Arc::new(Barrier::new(num_workers));
423
424        // Shared test data
425        let test_key: Key = Key::new(format!("concurrent_test_key_{}", uuid::Uuid::new_v4()));
426        let test_value = "test_value";
427
428        // Spawn multiple tasks that will all try to create the same key simultaneously
429        let mut handles = Vec::new();
430        let success_count = Arc::new(tokio::sync::Mutex::new(0));
431        let exists_count = Arc::new(tokio::sync::Mutex::new(0));
432
433        for worker_id in 0..num_workers {
434            let bucket_clone = bucket.clone();
435            let barrier_clone = barrier.clone();
436            let key_clone = test_key.clone();
437            let value_clone = format!("{}_from_worker_{}", test_value, worker_id);
438            let success_count_clone = success_count.clone();
439            let exists_count_clone = exists_count.clone();
440
441            let handle = tokio::spawn(async move {
442                // Wait for all workers to be ready
443                barrier_clone.wait().await;
444
445                // All workers try to create the same key at the same time
446                let result = bucket_clone
447                    .lock()
448                    .await
449                    .insert(&key_clone, value_clone.into(), 0)
450                    .await;
451
452                match result {
453                    Ok(StoreOutcome::Created(version)) => {
454                        println!(
455                            "Worker {} successfully created key with version {}",
456                            worker_id, version
457                        );
458                        let mut count = success_count_clone.lock().await;
459                        *count += 1;
460                        Ok(version)
461                    }
462                    Ok(StoreOutcome::Exists(version)) => {
463                        println!(
464                            "Worker {} found key already exists with version {}",
465                            worker_id, version
466                        );
467                        let mut count = exists_count_clone.lock().await;
468                        *count += 1;
469                        Ok(version)
470                    }
471                    Err(e) => {
472                        println!("Worker {} got error: {:?}", worker_id, e);
473                        Err(e)
474                    }
475                }
476            });
477
478            handles.push(handle);
479        }
480
481        // Wait for all workers to complete
482        let mut results = Vec::new();
483        for handle in handles {
484            let result = handle.await.unwrap();
485            if let Ok(version) = result {
486                results.push(version);
487            }
488        }
489
490        // Verify results
491        let final_success_count = *success_count.lock().await;
492        let final_exists_count = *exists_count.lock().await;
493
494        println!(
495            "Final counts - Created: {}, Exists: {}",
496            final_success_count, final_exists_count
497        );
498
499        // CRITICAL ASSERTIONS:
500        // 1. Exactly ONE worker should have successfully created the key
501        assert_eq!(
502            final_success_count, 1,
503            "Exactly one worker should create the key"
504        );
505
506        // 2. All other workers should have gotten "Exists" response
507        assert_eq!(
508            final_exists_count,
509            num_workers - 1,
510            "All other workers should see key exists"
511        );
512
513        // 3. Total successful operations should equal number of workers
514        assert_eq!(
515            results.len(),
516            num_workers,
517            "All workers should complete successfully"
518        );
519
520        // 4. Verify the key actually exists in etcd
521        let stored_value = bucket.lock().await.get(&test_key).await?;
522        assert!(stored_value.is_some(), "Key should exist in etcd");
523
524        // 5. The stored value should be from one of the workers
525        let stored_str = String::from_utf8(stored_value.unwrap().to_vec()).unwrap();
526        assert!(
527            stored_str.starts_with(test_value),
528            "Stored value should match expected prefix"
529        );
530
531        // Clean up
532        bucket.lock().await.delete(&test_key).await?;
533
534        Ok(())
535    }
536}