Skip to main content

dynamo_runtime/storage/
kv.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Interface to a traditional key-value store such as etcd.
5//! "key_value_store" spelt out because in AI land "KV" means something else.
6
7use std::borrow::Cow;
8use std::pin::Pin;
9use std::str::FromStr;
10use std::sync::Arc;
11use std::time::Duration;
12use std::{collections::HashMap, path::PathBuf};
13use std::{env, fmt};
14
15use crate::CancellationToken;
16use crate::transports::etcd as etcd_transport;
17use async_trait::async_trait;
18use futures::StreamExt;
19use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, percent_encode};
20use serde::{Deserialize, Serialize};
21use tokio_util::task::AbortOnDropHandle;
22
23mod mem;
24#[cfg(test)]
25pub(crate) use mem::MEMORY_EVENT_BUFFER_CAPACITY;
26pub use mem::MemoryStore;
27mod nats;
28pub use nats::NATSStore;
29mod etcd;
30pub use etcd::EtcdStore;
31mod file;
32pub use file::FileStore;
33
34/// String we use as the Key in a key-value storage operation. Simple String wrapper
35/// that can encode / decode a string.
36#[derive(Debug, Clone, PartialEq, Eq, Hash)]
37pub struct Key(String);
38
39impl Key {
40    pub fn new(s: String) -> Key {
41        Key(s)
42    }
43
44    /// Takes a URL-safe percent-encoded string and creates a Key from it by decoding first.
45    /// dynamo%2Fbackend%2Fgenerate%2F17216e63492ef21f becomes dynamo/backend/generate/17216e63492ef21f
46    pub fn from_url_safe(s: &str) -> Key {
47        Key(percent_decode_str(s).decode_utf8_lossy().to_string())
48    }
49
50    /// A URL-safe percent-encoded representation of this key.
51    /// e.g.  dynamo/backend/generate/17216e63492ef21f becomes dynamo%2Fbackend%2Fgenerate%2F17216e63492ef21f
52    pub fn url_safe(&self) -> Cow<'_, str> {
53        percent_encode(self.0.as_bytes(), NON_ALPHANUMERIC).into()
54    }
55}
56
57impl From<&str> for Key {
58    fn from(s: &str) -> Key {
59        Key::new(s.to_string())
60    }
61}
62
63impl fmt::Display for Key {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        write!(f, "{}", self.0)
66    }
67}
68
69impl AsRef<str> for Key {
70    fn as_ref(&self) -> &str {
71        &self.0
72    }
73}
74
75impl From<&Key> for String {
76    fn from(k: &Key) -> String {
77        k.0.clone()
78    }
79}
80
81#[derive(Debug, Clone, PartialEq)]
82pub struct KeyValue {
83    key: Key,
84    value: bytes::Bytes,
85}
86
87impl KeyValue {
88    pub fn new(key: Key, value: bytes::Bytes) -> Self {
89        KeyValue { key, value }
90    }
91
92    pub fn key(&self) -> String {
93        self.key.clone().to_string()
94    }
95
96    pub fn key_str(&self) -> &str {
97        self.key.as_ref()
98    }
99
100    pub fn value(&self) -> &[u8] {
101        &self.value
102    }
103
104    pub fn value_str(&self) -> anyhow::Result<&str> {
105        std::str::from_utf8(self.value()).map_err(From::from)
106    }
107}
108
109/// One change reported by a [`Bucket::watch`] stream.
110#[derive(Debug, Clone, PartialEq)]
111pub enum WatchEvent {
112    /// A key was created or its value changed after the last snapshot.
113    Put(KeyValue),
114    /// A key was deleted after the last snapshot.
115    Delete(Key),
116    /// The full bucket at one moment: the first event of every stream, possibly empty, and again
117    /// after the backend fell behind. It replaces any state held from earlier events. The NATS
118    /// store is the exception: it sends no initial `Resync` and replays existing keys as `Put`.
119    Resync(HashMap<Key, bytes::Bytes>),
120}
121
122#[async_trait]
123pub trait Store: Send + Sync {
124    type Bucket: Bucket + Send + Sync + 'static;
125
126    async fn get_or_create_bucket(
127        &self,
128        bucket_name: &str,
129        // auto-delete items older than this
130        ttl: Option<Duration>,
131    ) -> Result<Self::Bucket, StoreError>;
132
133    async fn get_bucket(&self, bucket_name: &str) -> Result<Option<Self::Bucket>, StoreError>;
134
135    fn connection_id(&self) -> u64;
136
137    fn shutdown(&self);
138
139    async fn check_connection(&self) -> Result<(), StoreError> {
140        Ok(())
141    }
142}
143
144#[derive(Clone, Debug, Default)]
145pub enum Selector {
146    // Box it because it is significantly bigger than the other variants
147    Etcd(Box<etcd_transport::ClientOptions>),
148    File(PathBuf),
149    #[default]
150    Memory,
151    // Nats not listed because likely we want to remove that impl. It is not currently used and not well tested.
152}
153
154impl fmt::Display for Selector {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        match self {
157            Selector::Etcd(opts) => {
158                let urls = opts.etcd_url.join(",");
159                write!(f, "Etcd({urls})")
160            }
161            Selector::File(path) => write!(f, "File({})", path.display()),
162            Selector::Memory => write!(f, "Memory"),
163        }
164    }
165}
166
167impl FromStr for Selector {
168    type Err = anyhow::Error;
169
170    fn from_str(s: &str) -> anyhow::Result<Selector> {
171        match s {
172            "etcd" => Ok(Self::Etcd(Box::default())),
173            "file" => {
174                let root = env::var("DYN_FILE_KV")
175                    .map(PathBuf::from)
176                    .unwrap_or_else(|_| env::temp_dir().join("dynamo_store_kv"));
177                Ok(Self::File(root))
178            }
179            "mem" => Ok(Self::Memory),
180            x => anyhow::bail!("Unknown key-value store type '{x}'"),
181        }
182    }
183}
184
185impl TryFrom<String> for Selector {
186    type Error = anyhow::Error;
187
188    fn try_from(s: String) -> anyhow::Result<Selector> {
189        s.parse()
190    }
191}
192
193#[allow(clippy::large_enum_variant)]
194enum KeyValueStoreEnum {
195    Memory(MemoryStore),
196    Nats(NATSStore),
197    Etcd(EtcdStore),
198    File(FileStore),
199}
200
201impl KeyValueStoreEnum {
202    async fn get_or_create_bucket(
203        &self,
204        bucket_name: &str,
205        // auto-delete items older than this
206        ttl: Option<Duration>,
207    ) -> Result<Box<dyn Bucket>, StoreError> {
208        use KeyValueStoreEnum::*;
209        Ok(match self {
210            Memory(x) => Box::new(x.get_or_create_bucket(bucket_name, ttl).await?),
211            Nats(x) => Box::new(x.get_or_create_bucket(bucket_name, ttl).await?),
212            Etcd(x) => Box::new(x.get_or_create_bucket(bucket_name, ttl).await?),
213            File(x) => Box::new(x.get_or_create_bucket(bucket_name, ttl).await?),
214        })
215    }
216
217    async fn get_bucket(&self, bucket_name: &str) -> Result<Option<Box<dyn Bucket>>, StoreError> {
218        use KeyValueStoreEnum::*;
219        let maybe_bucket: Option<Box<dyn Bucket>> = match self {
220            Memory(x) => x
221                .get_bucket(bucket_name)
222                .await?
223                .map(|b| Box::new(b) as Box<dyn Bucket>),
224            Nats(x) => x
225                .get_bucket(bucket_name)
226                .await?
227                .map(|b| Box::new(b) as Box<dyn Bucket>),
228            Etcd(x) => x
229                .get_bucket(bucket_name)
230                .await?
231                .map(|b| Box::new(b) as Box<dyn Bucket>),
232            File(x) => x
233                .get_bucket(bucket_name)
234                .await?
235                .map(|b| Box::new(b) as Box<dyn Bucket>),
236        };
237        Ok(maybe_bucket)
238    }
239
240    fn connection_id(&self) -> u64 {
241        use KeyValueStoreEnum::*;
242        match self {
243            Memory(x) => x.connection_id(),
244            Etcd(x) => x.connection_id(),
245            Nats(x) => x.connection_id(),
246            File(x) => x.connection_id(),
247        }
248    }
249
250    fn shutdown(&self) {
251        use KeyValueStoreEnum::*;
252        match self {
253            Memory(x) => x.shutdown(),
254            Etcd(x) => x.shutdown(),
255            Nats(x) => x.shutdown(),
256            File(x) => x.shutdown(),
257        }
258    }
259
260    async fn check_connection(&self) -> Result<(), StoreError> {
261        use KeyValueStoreEnum::*;
262        match self {
263            Memory(_) | File(_) => Ok(()),
264            Etcd(x) => x.check_connection().await,
265            Nats(x) => x.check_connection().await,
266        }
267    }
268}
269
270#[derive(Clone)]
271pub struct Manager(Arc<KeyValueStoreEnum>);
272
273impl Default for Manager {
274    fn default() -> Self {
275        Manager::memory()
276    }
277}
278
279impl Manager {
280    /// In-memory KeyValueStoreManager for testing
281    pub fn memory() -> Self {
282        Self::new(KeyValueStoreEnum::Memory(MemoryStore::new()))
283    }
284
285    pub fn etcd(etcd_client: crate::transports::etcd::Client) -> Self {
286        Self::new(KeyValueStoreEnum::Etcd(EtcdStore::new(etcd_client)))
287    }
288
289    pub fn file<P: Into<PathBuf>>(cancel_token: CancellationToken, root: P) -> Self {
290        Self::new(KeyValueStoreEnum::File(FileStore::new(cancel_token, root)))
291    }
292
293    fn new(s: KeyValueStoreEnum) -> Manager {
294        Manager(Arc::new(s))
295    }
296
297    pub async fn get_or_create_bucket(
298        &self,
299        bucket_name: &str,
300        // auto-delete items older than this
301        ttl: Option<Duration>,
302    ) -> Result<Box<dyn Bucket>, StoreError> {
303        self.0.get_or_create_bucket(bucket_name, ttl).await
304    }
305
306    pub async fn get_bucket(
307        &self,
308        bucket_name: &str,
309    ) -> Result<Option<Box<dyn Bucket>>, StoreError> {
310        self.0.get_bucket(bucket_name).await
311    }
312
313    pub fn connection_id(&self) -> u64 {
314        self.0.connection_id()
315    }
316
317    pub async fn check_connection(&self) -> Result<(), StoreError> {
318        self.0.check_connection().await
319    }
320
321    pub async fn load<T: for<'a> Deserialize<'a>>(
322        &self,
323        bucket: &str,
324        key: &Key,
325    ) -> Result<Option<T>, StoreError> {
326        let Some(bucket) = self.0.get_bucket(bucket).await? else {
327            // No bucket means no cards
328            return Ok(None);
329        };
330        Ok(match bucket.get(key).await? {
331            Some(card_bytes) => {
332                let card: T = serde_json::from_slice(card_bytes.as_ref())?;
333                Some(card)
334            }
335            None => None,
336        })
337    }
338
339    async fn forward_watch_event(
340        tx: &tokio::sync::mpsc::Sender<WatchEvent>,
341        event: WatchEvent,
342        cancel_token: &CancellationToken,
343        bucket_name: &str,
344    ) -> bool {
345        tokio::select! {
346            _ = cancel_token.cancelled() => false,
347            result = tx.send(event) => {
348                if let Err(error) = result {
349                    tracing::error!(
350                        bucket_name,
351                        %error,
352                        "KeyValueStoreManager.watch receiver closed"
353                    );
354                    false
355                } else {
356                    true
357                }
358            }
359        }
360    }
361
362    /// Returns a receiver for one snapshot of a bucket, and then for every later change.
363    ///
364    /// The first event is one [`WatchEvent::Resync`] with every existing key, empty when the
365    /// bucket is empty. The NATS store is the exception: it sends no initial `Resync` and replays
366    /// existing keys as [`WatchEvent::Put`] events.
367    ///
368    /// This method establishes the watch before it returns: [`Bucket::watch`] has captured the
369    /// initial snapshot, so every change that follows reaches the receiver, as its own event or
370    /// inside a later [`WatchEvent::Resync`] that replaces all earlier state. A caller that also
371    /// reads the bucket directly must read it after this returns. A read before the watch can
372    /// show a key that a delete removes before the snapshot, and the receiver never reports that
373    /// delete.
374    ///
375    /// The spawned task runs until `cancel_token` cancels, the receiver drops, or the backend
376    /// stream ends.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error when the bucket is unavailable, when the backend cannot establish the
381    /// watch, or when `cancel_token` cancels first.
382    pub async fn watch(
383        self: Arc<Self>,
384        bucket_name: &str,
385        bucket_ttl: Option<Duration>,
386        cancel_token: CancellationToken,
387    ) -> Result<
388        (
389            tokio::task::JoinHandle<()>,
390            tokio::sync::mpsc::Receiver<WatchEvent>,
391        ),
392        StoreError,
393    > {
394        let bucket_name = bucket_name.to_string();
395        // Backpressure is intentional: discovery state events must never be dropped.
396        let (tx, rx) = tokio::sync::mpsc::channel(16384);
397        let (established_tx, established_rx) = tokio::sync::oneshot::channel();
398        // Until the watch is established, a caller that stops waiting aborts the task rather than
399        // detaching it: nobody reads the bucket it would create or the backend watch it would hold.
400        let watch_task = AbortOnDropHandle::new(tokio::spawn({
401            let bucket_name = bucket_name.clone();
402            let cancel_token = cancel_token.clone();
403            async move {
404                // Start listening for changes but don't poll this yet
405                let bucket = match self.0.get_or_create_bucket(&bucket_name, bucket_ttl).await {
406                    Ok(bucket) => bucket,
407                    Err(error) => {
408                        let _ = established_tx.send(Err(error));
409                        return;
410                    }
411                };
412                // Bucket::watch atomically establishes its initial snapshot and incremental
413                // stream. A separate entries() read here could replay an older buffered update
414                // after a newer snapshot.
415                let mut stream = match bucket.watch().await {
416                    Ok(stream) => stream,
417                    Err(error) => {
418                        let _ = established_tx.send(Err(error));
419                        return;
420                    }
421                };
422                // The caller stopped waiting, so no receiver reconciles against this snapshot.
423                if established_tx.send(Ok(())).is_err() {
424                    return;
425                }
426
427                loop {
428                    let event = tokio::select! {
429                        () = cancel_token.cancelled() => break,
430                        // A quiet bucket would otherwise park the task until its next change.
431                        () = tx.closed() => break,
432                        result = stream.next() => match result {
433                            Some(event) => event,
434                            None => break,
435                        }
436                    };
437                    if !Self::forward_watch_event(&tx, event, &cancel_token, &bucket_name).await {
438                        break;
439                    }
440                }
441            }
442        }));
443
444        let established = tokio::select! {
445            biased;
446            // Await the abort, so the task cannot create the bucket after this returns.
447            () = cancel_token.cancelled() => {
448                watch_task.abort();
449                let _ = watch_task.await;
450                return Err(StoreError::WatchCancelled(bucket_name));
451            }
452            established = established_rx => established,
453        };
454        established.map_err(|_| {
455            StoreError::ProviderError(format!(
456                "the watch task for bucket '{bucket_name}' ended before it established the watch"
457            ))
458        })??;
459
460        Ok((watch_task.detach(), rx))
461    }
462
463    pub async fn publish<T: Serialize + Versioned + Send + Sync>(
464        &self,
465        bucket_name: &str,
466        bucket_ttl: Option<Duration>,
467        key: &Key,
468        obj: &mut T,
469    ) -> anyhow::Result<StoreOutcome> {
470        let obj_json = serde_json::to_vec(obj)?;
471        let bucket = self.0.get_or_create_bucket(bucket_name, bucket_ttl).await?;
472
473        let outcome = bucket.insert(key, obj_json.into(), obj.revision()).await?;
474
475        match outcome {
476            StoreOutcome::Created(revision) | StoreOutcome::Exists(revision) => {
477                obj.set_revision(revision);
478            }
479        }
480        Ok(outcome)
481    }
482
483    /// Cleanup any temporary state.
484    /// TODO: Should this be async? Take &mut self?
485    pub fn shutdown(&self) {
486        self.0.shutdown()
487    }
488}
489
490/// An online storage for key-value config values.
491#[async_trait]
492pub trait Bucket: Send + Sync {
493    /// A bucket is a collection of key/value pairs.
494    /// Insert a value into a bucket, if it doesn't exist already
495    /// The Key should be the name of the item, not including the bucket name.
496    async fn insert(
497        &self,
498        key: &Key,
499        value: bytes::Bytes,
500        revision: u64,
501    ) -> Result<StoreOutcome, StoreError>;
502
503    /// Fetch an item from the key-value storage
504    /// The Key should be the name of the item, not including the bucket name.
505    async fn get(&self, key: &Key) -> Result<Option<bytes::Bytes>, StoreError>;
506
507    /// Replace an existing item only if its current value matches `expected`.
508    ///
509    /// Implementations must perform the comparison and replacement atomically.
510    /// A missing key returns [`StoreError::MissingKey`] and must never be created;
511    /// a value changed by another writer returns [`StoreError::Retry`].
512    /// A successful [`StoreOutcome`] revision is backend-specific and must not be
513    /// compared across backends or treated as a globally monotonic version.
514    async fn compare_and_replace(
515        &self,
516        key: &Key,
517        expected: bytes::Bytes,
518        value: bytes::Bytes,
519    ) -> Result<StoreOutcome, StoreError>;
520
521    /// Delete an item from the bucket
522    /// The Key should be the name of the item, not including the bucket name.
523    async fn delete(&self, key: &Key) -> Result<(), StoreError>;
524
525    /// An atomic initial snapshot followed by changes newer than that snapshot.
526    ///
527    /// Implementations must establish the snapshot and incremental watch without a gap and must
528    /// never emit an incremental value older than a value already emitted in the initial snapshot.
529    /// The first event is exactly one [`WatchEvent::Resync`] with every existing entry, empty for
530    /// an empty bucket. Later events are changes that follow it, or a further `Resync` after the
531    /// backend fell behind.
532    ///
533    /// The NATS store is the exception: it sends no initial `Resync` and replays existing keys as
534    /// [`WatchEvent::Put`] events. Discovery does not select it.
535    async fn watch(
536        &self,
537    ) -> Result<Pin<Box<dyn futures::Stream<Item = WatchEvent> + Send + '_>>, StoreError>;
538
539    /// The entries in this bucket.
540    /// The Key includes the full path including the bucket name.
541    /// That means you cannot directory get a Key from `entries` and pass it to `get` or `delete`.
542    async fn entries(&self) -> Result<HashMap<Key, bytes::Bytes>, StoreError>;
543}
544
545#[derive(Debug, Copy, Clone, Eq, PartialEq)]
546pub enum StoreOutcome {
547    /// The operation succeeded and created a new entry with this revision.
548    /// Note that "create" also means update, because each new revision is a "create".
549    Created(u64),
550    /// The operation did not do anything, the value was already present, with this revision.
551    Exists(u64),
552}
553impl fmt::Display for StoreOutcome {
554    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
555        match self {
556            StoreOutcome::Created(revision) => write!(f, "Created at {revision}"),
557            StoreOutcome::Exists(revision) => write!(f, "Exists at {revision}"),
558        }
559    }
560}
561
562#[derive(thiserror::Error, Debug)]
563pub enum StoreError {
564    #[error("Could not find bucket '{0}'")]
565    MissingBucket(String),
566
567    #[error("Could not find key '{0}'")]
568    MissingKey(String),
569
570    #[error("Internal storage error: '{0}'")]
571    ProviderError(String),
572
573    #[error("Internal NATS error: {0}")]
574    NATSError(String),
575
576    #[error("Internal etcd error: {0}")]
577    EtcdError(String),
578
579    #[error("Internal filesystem error: {0}")]
580    FilesystemError(String),
581
582    #[error("Key Value Error: {0} for bucket '{1}'")]
583    KeyValueError(String, String),
584
585    /// The caller cancelled before the backend established the watch, so no snapshot exists to
586    /// reconcile against.
587    #[error("Watch on bucket '{0}' was cancelled before it was established")]
588    WatchCancelled(String),
589
590    #[error("Error decoding bytes: {0}")]
591    JSONDecodeError(#[from] serde_json::error::Error),
592
593    #[error("Race condition, retry the call")]
594    Retry,
595}
596
597/// A trait allowing to get/set a revision on an object.
598/// NATS uses this to ensure atomic updates.
599pub trait Versioned {
600    fn revision(&self) -> u64;
601    fn set_revision(&mut self, r: u64);
602}
603
604#[cfg(test)]
605mod tests {
606    use std::sync::Arc;
607
608    use super::*;
609    use futures::{FutureExt, StreamExt, pin_mut};
610
611    const BUCKET_NAME: &str = "v1/mdc";
612
613    /// Convert the value returned by `watch()` into a broadcast stream that multiple
614    /// clients can listen to.
615    #[allow(dead_code)]
616    pub struct TappableStream {
617        tx: tokio::sync::broadcast::Sender<WatchEvent>,
618    }
619
620    #[allow(dead_code)]
621    impl TappableStream {
622        async fn new<T>(stream: T, max_size: usize) -> Self
623        where
624            T: futures::Stream<Item = WatchEvent> + Send + 'static,
625        {
626            let (tx, _) = tokio::sync::broadcast::channel(max_size);
627            let tx2 = tx.clone();
628            tokio::spawn(async move {
629                pin_mut!(stream);
630                while let Some(x) = stream.next().await {
631                    let _ = tx2.send(x);
632                }
633            });
634            TappableStream { tx }
635        }
636
637        fn subscribe(&self) -> tokio::sync::broadcast::Receiver<WatchEvent> {
638            self.tx.subscribe()
639        }
640    }
641
642    fn init() {
643        crate::logging::init();
644    }
645
646    #[tokio::test]
647    async fn manager_watch_emits_initial_snapshot_once_before_updates() {
648        let manager = Arc::new(Manager::memory());
649        let bucket = manager
650            .get_or_create_bucket(BUCKET_NAME, None)
651            .await
652            .unwrap();
653        let key = Key::new("ns/worker/generate/1".to_string());
654        bucket.insert(&key, "old".into(), 1).await.unwrap();
655
656        let cancel_token = CancellationToken::new();
657        let (watch_task, mut rx) = manager
658            .clone()
659            .watch(BUCKET_NAME, None, cancel_token.clone())
660            .await
661            .unwrap();
662
663        let first = tokio::time::timeout(Duration::from_secs(1), rx.recv())
664            .await
665            .unwrap()
666            .unwrap();
667        let WatchEvent::Resync(snapshot) = first else {
668            panic!("expected the initial resync, got {first:?}");
669        };
670        assert_eq!(
671            snapshot.get(&key).map(|value| value.as_ref()),
672            Some(b"old".as_slice())
673        );
674
675        bucket.insert(&key, "new".into(), 2).await.unwrap();
676        let second = tokio::time::timeout(Duration::from_secs(1), rx.recv())
677            .await
678            .unwrap()
679            .unwrap();
680        let WatchEvent::Put(second) = second else {
681            panic!("expected updated put");
682        };
683        assert_eq!(
684            second.value(),
685            b"new",
686            "the initial value must not be replayed after the snapshot"
687        );
688
689        cancel_token.cancel();
690        watch_task.await.unwrap();
691    }
692
693    #[tokio::test]
694    async fn manager_watch_reports_a_delete_that_follows_establishment() {
695        let manager = Arc::new(Manager::memory());
696        let bucket = manager
697            .get_or_create_bucket(BUCKET_NAME, None)
698            .await
699            .unwrap();
700        let key = Key::new("ns/worker/generate/1".to_string());
701        bucket.insert(&key, "value".into(), 1).await.unwrap();
702
703        let cancel_token = CancellationToken::new();
704        let (watch_task, mut rx) = manager
705            .clone()
706            .watch(BUCKET_NAME, None, cancel_token.clone())
707            .await
708            .unwrap();
709        bucket.delete(&key).await.unwrap();
710
711        let first = tokio::time::timeout(Duration::from_secs(1), rx.recv())
712            .await
713            .unwrap()
714            .unwrap();
715        let WatchEvent::Resync(snapshot) = first else {
716            panic!("expected the existing key in the initial snapshot, got {first:?}");
717        };
718        assert!(
719            snapshot.contains_key(&key),
720            "snapshot {snapshot:?} lacks {key}"
721        );
722
723        let second = tokio::time::timeout(Duration::from_secs(1), rx.recv())
724            .await
725            .unwrap()
726            .unwrap();
727        assert_eq!(
728            second,
729            WatchEvent::Delete(key),
730            "a delete after establishment must reach the receiver"
731        );
732
733        cancel_token.cancel();
734        watch_task.await.unwrap();
735    }
736
737    #[tokio::test]
738    async fn manager_watch_reports_an_unavailable_bucket() {
739        // A file as the store root makes every bucket directory unreachable.
740        let root = tempfile::NamedTempFile::new().unwrap();
741        let cancel_token = CancellationToken::new();
742        let manager = Arc::new(Manager::file(cancel_token.clone(), root.path()));
743
744        let error = manager
745            .watch(BUCKET_NAME, None, cancel_token.clone())
746            .await
747            .expect_err("a bucket under a file cannot be created");
748
749        assert!(
750            matches!(error, StoreError::FilesystemError(_)),
751            "expected a filesystem error, got {error:?}"
752        );
753        cancel_token.cancel();
754    }
755
756    #[tokio::test]
757    async fn manager_watch_reports_cancellation_before_establishment() {
758        let manager = Arc::new(Manager::memory());
759        let cancel_token = CancellationToken::new();
760        cancel_token.cancel();
761
762        let error = manager
763            .clone()
764            .watch(BUCKET_NAME, None, cancel_token)
765            .await
766            .expect_err("a cancelled watch establishes no snapshot");
767
768        assert!(
769            matches!(&error, StoreError::WatchCancelled(bucket) if bucket == BUCKET_NAME),
770            "expected a cancelled watch, got {error:?}"
771        );
772        // Give a task that outlived the failed watch its chance to run.
773        tokio::task::yield_now().await;
774        assert!(
775            manager.get_bucket(BUCKET_NAME).await.unwrap().is_none(),
776            "a cancelled watch created the bucket it gave up on"
777        );
778    }
779
780    #[tokio::test]
781    async fn manager_watch_abandoned_before_establishment_creates_no_bucket() {
782        let manager = Arc::new(Manager::memory());
783
784        let watch = manager
785            .clone()
786            .watch(BUCKET_NAME, None, CancellationToken::new());
787        assert!(
788            watch.now_or_never().is_none(),
789            "the watch completed on its first poll, before its setup task could run"
790        );
791
792        tokio::task::yield_now().await;
793        assert!(
794            manager.get_bucket(BUCKET_NAME).await.unwrap().is_none(),
795            "an abandoned watch created the bucket nobody waits for"
796        );
797    }
798
799    #[tokio::test]
800    async fn manager_watch_task_ends_when_its_receiver_drops() {
801        let manager = Arc::new(Manager::memory());
802        let (watch_task, rx) = manager
803            .watch(BUCKET_NAME, None, CancellationToken::new())
804            .await
805            .unwrap();
806
807        drop(rx);
808
809        tokio::time::timeout(Duration::from_secs(1), watch_task)
810            .await
811            .expect("the watch task outlived its receiver on a quiet bucket")
812            .unwrap();
813    }
814
815    #[tokio::test]
816    async fn saturated_watch_channel_delivers_final_taint_state() {
817        let cancel_token = CancellationToken::new();
818        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
819        let first = WatchEvent::Put(KeyValue::new(
820            Key::new("v1/mdc/ns/worker/generate/1".to_string()),
821            br#"{"runtime_config":{"taints":["slow"]}}"#[..].into(),
822        ));
823        assert!(Manager::forward_watch_event(&tx, first.clone(), &cancel_token, BUCKET_NAME).await);
824
825        let final_event = WatchEvent::Put(KeyValue::new(
826            Key::new("v1/mdc/ns/worker/generate/1".to_string()),
827            br#"{"runtime_config":{"taints":["fast"]}}"#[..].into(),
828        ));
829        let final_event_for_send = final_event.clone();
830        let tx_clone = tx.clone();
831        let cancel_clone = cancel_token.clone();
832        let send_task = tokio::spawn(async move {
833            Manager::forward_watch_event(
834                &tx_clone,
835                final_event_for_send,
836                &cancel_clone,
837                BUCKET_NAME,
838            )
839            .await
840        });
841
842        tokio::task::yield_now().await;
843        assert!(
844            !send_task.is_finished(),
845            "send must wait for channel capacity"
846        );
847        assert_eq!(rx.recv().await, Some(first));
848        assert!(send_task.await.unwrap());
849        assert_eq!(rx.recv().await, Some(final_event));
850    }
851
852    #[tokio::test]
853    async fn test_memory_storage() -> anyhow::Result<()> {
854        init();
855
856        let s = Arc::new(MemoryStore::new());
857        let s2 = Arc::clone(&s);
858
859        let bucket = s.get_or_create_bucket(BUCKET_NAME, None).await?;
860        let res = bucket.insert(&"test1".into(), "value1".into(), 0).await?;
861        assert_eq!(res, StoreOutcome::Created(0));
862
863        let expected = [
864            WatchEvent::Resync(HashMap::from([(Key::new("test1".into()), "value1".into())])),
865            WatchEvent::Put(KeyValue::new(Key::new("test2".into()), "value2".into())),
866            WatchEvent::Put(KeyValue::new(
867                Key::new("test2".into()),
868                "value2-updated".into(),
869            )),
870            WatchEvent::Put(KeyValue::new(Key::new("test3".into()), "value3".into())),
871        ];
872
873        let (got_first_tx, got_first_rx) = tokio::sync::oneshot::channel();
874        let ingress = tokio::spawn(async move {
875            let b2 = s2.get_or_create_bucket(BUCKET_NAME, None).await?;
876            let mut stream = b2.watch().await?;
877
878            // Put in before starting the watch-all
879            let v = stream.next().await.unwrap();
880            assert_eq!(v, expected[0]);
881
882            got_first_tx.send(()).unwrap();
883
884            // Put in after
885            let v = stream.next().await.unwrap();
886            assert_eq!(v, expected[1]);
887
888            let v = stream.next().await.unwrap();
889            assert_eq!(v, expected[2]);
890
891            let v = stream.next().await.unwrap();
892            assert_eq!(v, expected[3]);
893
894            Ok::<_, StoreError>(())
895        });
896
897        // MemoryStore uses a HashMap with no inherent ordering, so we must ensure test1 is
898        // fetched before test2 is inserted, otherwise they can come out in any order, and we
899        // wouldn't be testing the watch behavior.
900        got_first_rx.await?;
901
902        let res = bucket.insert(&"test2".into(), "value2".into(), 0).await?;
903        assert_eq!(res, StoreOutcome::Created(0));
904
905        // Repeat a key and revision. Ignored.
906        let res = bucket.insert(&"test2".into(), "value2".into(), 0).await?;
907        assert_eq!(res, StoreOutcome::Exists(0));
908
909        // Increment revision
910        let res = bucket
911            .insert(&"test2".into(), "value2-updated".into(), 1)
912            .await?;
913        assert_eq!(res, StoreOutcome::Created(1));
914
915        let res = bucket.insert(&"test3".into(), "value3".into(), 0).await?;
916        assert_eq!(res, StoreOutcome::Created(0));
917
918        // ingress exits once it has received all values
919        let _ = ingress.await?;
920
921        Ok(())
922    }
923
924    #[tokio::test]
925    async fn test_broadcast_stream() -> anyhow::Result<()> {
926        init();
927
928        let s: &'static _ = Box::leak(Box::new(MemoryStore::new()));
929        let bucket: &'static _ =
930            Box::leak(Box::new(s.get_or_create_bucket(BUCKET_NAME, None).await?));
931
932        let res = bucket.insert(&"test1".into(), "value1".into(), 0).await?;
933        assert_eq!(res, StoreOutcome::Created(0));
934
935        let stream = bucket.watch().await?;
936        let tap = TappableStream::new(stream, 10).await;
937
938        let mut rx1 = tap.subscribe();
939        let mut rx2 = tap.subscribe();
940
941        let item = WatchEvent::Put(KeyValue::new(Key::new("test1".to_string()), "GK".into()));
942        let item_clone = item.clone();
943        let handle1 = tokio::spawn(async move {
944            let b = rx1.recv().await.unwrap();
945            assert_eq!(b, item_clone);
946        });
947        let handle2 = tokio::spawn(async move {
948            let b = rx2.recv().await.unwrap();
949            assert_eq!(b, item);
950        });
951
952        bucket.insert(&"test1".into(), "GK".into(), 1).await?;
953
954        let _ = futures::join!(handle1, handle2);
955        Ok(())
956    }
957}