Skip to main content

nodedb_lite/nodedb/collection/
kv.rs

1//! KV collection operations for Lite.
2//!
3//! Reads and writes go to the KV store in `Namespace::Kv`. KV never touches
4//! Loro. With sync on, every put and delete is also recorded in the KV
5//! outbound queue before it applies locally, and the push loop sends it to
6//! Origin. See `kv_sync`.
7//!
8//! Writes are buffered in memory and flushed as a single KV transaction
9//! on `kv_flush()` or when the buffer exceeds `KV_FLUSH_THRESHOLD`. An
10//! in-memory overlay lets reads see uncommitted writes without hitting the
11//! KV store.
12//!
13//! ## Value encoding
14//!
15//! Every value stored in the KV store is prefixed by an 8-byte little-endian
16//! u64 representing the expiry deadline in milliseconds since the Unix epoch.
17//! A value of `0` means no expiry. This prefix is transparent to callers —
18//! all public methods encode/decode it automatically.
19
20use nodedb_types::Namespace;
21use nodedb_types::error::{NodeDbError, NodeDbResult};
22
23use super::super::{LockExt, NodeDbLite};
24use crate::storage::engine::{StorageEngine, WriteOp};
25
26/// Flush the write buffer when it reaches this many operations.
27pub(super) const KV_FLUSH_THRESHOLD: usize = 1024;
28
29/// Size of the deadline prefix in bytes (u64 LE).
30const DEADLINE_PREFIX_LEN: usize = 8;
31
32/// Build the composite KV key: `{collection}\0{key}`.
33pub(super) fn kv_key(collection: &str, key: &[u8]) -> Vec<u8> {
34    let mut k = Vec::with_capacity(collection.len() + 1 + key.len());
35    k.extend_from_slice(collection.as_bytes());
36    k.push(0);
37    k.extend_from_slice(key);
38    k
39}
40
41/// Extract `(collection, key_bytes)` from a composite KV key.
42pub(super) fn split_kv_key(composite: &[u8]) -> Option<(&str, &[u8])> {
43    let sep = composite.iter().position(|&b| b == 0)?;
44    let coll = std::str::from_utf8(&composite[..sep]).ok()?;
45    let key = &composite[sep + 1..];
46    Some((coll, key))
47}
48
49/// Encode a value with a deadline prefix.
50///
51/// `deadline_ms = 0` encodes as "no expiry".
52pub(super) fn encode_value(deadline_ms: u64, value: &[u8]) -> Vec<u8> {
53    let mut encoded = Vec::with_capacity(DEADLINE_PREFIX_LEN + value.len());
54    encoded.extend_from_slice(&deadline_ms.to_le_bytes());
55    encoded.extend_from_slice(value);
56    encoded
57}
58
59/// Decode a stored value into `(deadline_ms, user_bytes)`.
60///
61/// Returns `None` if the stored bytes are too short (corrupt entry).
62pub(super) fn decode_value(stored: &[u8]) -> Option<(u64, &[u8])> {
63    if stored.len() < DEADLINE_PREFIX_LEN {
64        return None;
65    }
66    let deadline = u64::from_le_bytes(stored[..DEADLINE_PREFIX_LEN].try_into().ok()?);
67    Some((deadline, &stored[DEADLINE_PREFIX_LEN..]))
68}
69
70/// Return `true` if the deadline has passed (key is expired).
71///
72/// A deadline of `0` means no expiry and is never considered expired.
73pub(super) fn is_expired(deadline_ms: u64) -> bool {
74    deadline_ms != 0 && crate::runtime::now_millis() >= deadline_ms
75}
76
77impl<S: StorageEngine> NodeDbLite<S> {
78    /// KV PUT: store a key-value pair with no expiry.
79    ///
80    /// Buffered in memory — call `kv_flush()` to commit, or let
81    /// the auto-flush threshold handle it.
82    pub async fn kv_put(&self, collection: &str, key: &str, value: &[u8]) -> NodeDbResult<()> {
83        self.kv_put_with_deadline(collection, key, value, 0).await
84    }
85
86    /// KV PUT WITH TTL: store a key-value pair that expires after `ttl_ms` ms.
87    ///
88    /// After `ttl_ms` milliseconds, `kv_get` will return `None` for this key
89    /// and lazy-delete it. The deadline survives a database reopen.
90    pub async fn kv_put_with_ttl(
91        &self,
92        collection: &str,
93        key: &str,
94        value: &[u8],
95        ttl_ms: u64,
96    ) -> NodeDbResult<()> {
97        let deadline = crate::runtime::now_millis().saturating_add(ttl_ms);
98        self.kv_put_with_deadline(collection, key, value, deadline)
99            .await
100    }
101
102    /// Internal: write a key with an explicit deadline (0 = no expiry).
103    ///
104    /// The write is recorded for sync before it applies, under the
105    /// write-order lock.
106    async fn kv_put_with_deadline(
107        &self,
108        collection: &str,
109        key: &str,
110        value: &[u8],
111        deadline_ms: u64,
112    ) -> NodeDbResult<()> {
113        self.kv_check_pressure()?;
114        let _order = self.kv_local.write_order.lock().await;
115        if self.kv_local.is_indexed(collection) {
116            return self
117                .kv_put_indexed(collection, key, value, deadline_ms)
118                .await;
119        }
120        self.kv_record_put(collection, key.as_bytes(), value, deadline_ms)
121            .await?;
122        self.kv_buffer_put(collection, key, value, deadline_ms)
123            .await
124    }
125
126    /// Put into a collection with a key-value index: refused before it is
127    /// recorded for sync when a unique index forbids it, then committed
128    /// directly after the buffered writes ahead of it, with its entries.
129    /// Call with the write-order lock held.
130    async fn kv_put_indexed(
131        &self,
132        collection: &str,
133        key: &str,
134        value: &[u8],
135        deadline_ms: u64,
136    ) -> NodeDbResult<()> {
137        let rkey = kv_key(collection, key.as_bytes());
138        let op = WriteOp::Put {
139            ns: Namespace::Kv,
140            key: rkey.clone(),
141            value: encode_value(deadline_ms, value),
142        };
143        self.kv_local
144            .check(&*self.storage, std::slice::from_ref(&op))
145            .await
146            .map_err(NodeDbError::from)?;
147        self.kv_record_put(collection, key.as_bytes(), value, deadline_ms)
148            .await?;
149        self.kv_flush_inner().await?;
150        self.kv_local
151            .commit(&*self.storage, vec![op])
152            .await
153            .map_err(NodeDbError::from)?;
154        self.kv_local.cache.lock_or_recover().pop(&rkey);
155        Ok(())
156    }
157
158    /// Refuse a KV write while the memory governor is at Emergency pressure.
159    pub(super) fn kv_check_pressure(&self) -> NodeDbResult<()> {
160        if self.governor.worst_engine_pressure() == nodedb_mem::PressureLevel::Emergency {
161            return Err(NodeDbError::storage(
162                crate::error::LiteError::Backpressure {
163                    detail: "KV write rejected: memory governor is at Emergency pressure".into(),
164                },
165            ));
166        }
167        Ok(())
168    }
169
170    /// Buffer a put of `value` at `key` with the expiry `deadline_ms`.
171    /// Records nothing for sync.
172    pub(super) async fn kv_buffer_put(
173        &self,
174        collection: &str,
175        key: &str,
176        value: &[u8],
177        deadline_ms: u64,
178    ) -> NodeDbResult<()> {
179        let rkey = kv_key(collection, key.as_bytes());
180        let encoded = encode_value(deadline_ms, value);
181
182        // Scope all mutex work so no guard is live at the await point.
183        let should_flush = {
184            let mut buf = self.kv_local.write_buf.lock_or_recover();
185            buf.overlay.insert(rkey.clone(), Some(encoded.clone()));
186            buf.ops.push(WriteOp::Put {
187                ns: Namespace::Kv,
188                key: rkey.clone(),
189                value: encoded,
190            });
191            buf.ops.len() >= KV_FLUSH_THRESHOLD
192        };
193
194        // Invalidate any cached value for this key so subsequent reads go to storage.
195        {
196            self.kv_local.cache.lock_or_recover().pop(&rkey);
197        }
198
199        if should_flush {
200            self.kv_flush_inner().await?;
201        }
202
203        Ok(())
204    }
205
206    /// KV GET: retrieve a value by key.
207    ///
208    /// Returns `None` for missing or expired keys. Expired keys are lazily
209    /// deleted from storage on read.
210    ///
211    /// Checks the in-memory write buffer first (for uncommitted writes),
212    /// then falls through to the KV store.
213    pub async fn kv_get(&self, collection: &str, key: &str) -> NodeDbResult<Option<Vec<u8>>> {
214        let rkey = kv_key(collection, key.as_bytes());
215
216        // Always acquire the write-buffer lock to check the overlay first.
217        // This prevents torn reads that could occur if an unconditional
218        // Acquire load of a length counter raced with a concurrent writer.
219        // Scope the guard so it is not live at any await point.
220        let overlay_result: Option<Option<Vec<u8>>> = {
221            let buf = self.kv_local.write_buf.lock_or_recover();
222            buf.overlay.get(&rkey).map(|entry| match entry {
223                Some(stored) => decode_value(stored).and_then(|(deadline, user_bytes)| {
224                    if is_expired(deadline) {
225                        None
226                    } else {
227                        Some(user_bytes.to_vec())
228                    }
229                }),
230                None => None,
231            })
232        };
233        if let Some(result) = overlay_result {
234            return Ok(result);
235        }
236
237        // Cache check: look up the composite key before hitting storage.
238        // Guard scoped to the block; no await inside.
239        let cache_result: Option<Option<Vec<u8>>> = {
240            let mut cache = self.kv_local.cache.lock_or_recover();
241            if let Some(encoded) = cache.get(&rkey) {
242                match decode_value(encoded) {
243                    Some((deadline, user_bytes)) if !is_expired(deadline) => {
244                        Some(Some(user_bytes.to_vec()))
245                    }
246                    _ => {
247                        cache.pop(&rkey);
248                        None
249                    }
250                }
251            } else {
252                None
253            }
254        };
255        if let Some(result) = cache_result {
256            return Ok(result);
257        }
258
259        // Fall through to storage.
260        let stored = self
261            .storage
262            .get(Namespace::Kv, &rkey)
263            .await
264            .map_err(NodeDbError::storage)?;
265
266        match stored {
267            None => Ok(None),
268            Some(raw) => {
269                let decoded = decode_value(&raw);
270                match decoded {
271                    None => Ok(None),
272                    Some((deadline, user_bytes)) => {
273                        if is_expired(deadline) {
274                            // Lazy expiration: schedule a delete.
275                            self.kv_lazy_delete(rkey).await?;
276                            Ok(None)
277                        } else {
278                            let result = user_bytes.to_vec();
279                            // Populate cache with the raw encoded bytes before returning.
280                            // Guard scoped to block; no await follows inside this branch.
281                            {
282                                self.kv_local.cache.lock_or_recover().put(rkey, raw);
283                            }
284                            Ok(Some(result))
285                        }
286                    }
287                }
288            }
289        }
290    }
291
292    /// Internal: queue a lazy delete for an expired key.
293    async fn kv_lazy_delete(&self, rkey: Vec<u8>) -> NodeDbResult<()> {
294        let should_flush = {
295            let mut buf = self.kv_local.write_buf.lock_or_recover();
296            buf.overlay.insert(rkey.clone(), None);
297            buf.ops.push(WriteOp::Delete {
298                ns: Namespace::Kv,
299                key: rkey.clone(),
300            });
301            buf.ops.len() >= KV_FLUSH_THRESHOLD
302        };
303        // Evict the expired entry so future reads don't serve stale data.
304        {
305            self.kv_local.cache.lock_or_recover().pop(&rkey);
306        }
307        if should_flush {
308            self.kv_flush_inner().await?;
309        }
310        Ok(())
311    }
312
313    /// KV DELETE: remove a key.
314    ///
315    /// Returns `true` when a live value was present and removed, `false` when
316    /// the key was absent (or already expired) — mirroring `HashMap::remove`.
317    ///
318    /// The delete is recorded for sync even when the key is absent here,
319    /// because Origin can hold the key when this replica does not.
320    pub async fn kv_delete(&self, collection: &str, key: &str) -> NodeDbResult<bool> {
321        let _order = self.kv_local.write_order.lock().await;
322        // Capture prior presence so the returned bool means "a live value was
323        // removed" rather than an unconditional `true`.
324        let existed = self.kv_get(collection, key).await?.is_some();
325        self.kv_record_delete(collection, key.as_bytes()).await?;
326
327        let rkey = kv_key(collection, key.as_bytes());
328
329        let should_flush = {
330            let mut buf = self.kv_local.write_buf.lock_or_recover();
331            buf.overlay.insert(rkey.clone(), None);
332            buf.ops.push(WriteOp::Delete {
333                ns: Namespace::Kv,
334                key: rkey.clone(),
335            });
336            buf.ops.len() >= KV_FLUSH_THRESHOLD
337        };
338
339        // Invalidate the cache so subsequent reads don't return stale data.
340        {
341            self.kv_local.cache.lock_or_recover().pop(&rkey);
342        }
343
344        if should_flush {
345            self.kv_flush_inner().await?;
346        }
347
348        Ok(existed)
349    }
350
351    /// Flush buffered KV writes to storage as a single transaction.
352    /// Returns the number of writes flushed.
353    pub async fn kv_flush(&self) -> NodeDbResult<usize> {
354        self.kv_flush_inner().await
355    }
356
357    /// Internal: flush the write buffer to storage.
358    /// `pub(in crate::nodedb)` so the global `flush()` can drain the KV buffer.
359    pub(in crate::nodedb) async fn kv_flush_inner(&self) -> NodeDbResult<usize> {
360        self.kv_local
361            .flush_to(&*self.storage)
362            .await
363            .map_err(NodeDbError::storage)
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::config::LiteConfig;
371    use crate::storage::pagedb_storage::PagedbStorageMem;
372
373    async fn open_db() -> std::sync::Arc<NodeDbLite<PagedbStorageMem>> {
374        let storage = PagedbStorageMem::open_in_memory()
375            .await
376            .expect("open in-memory storage");
377        NodeDbLite::open(storage).await.expect("open NodeDbLite")
378    }
379
380    async fn open_db_with_cache_capacity(
381        cap: usize,
382    ) -> std::sync::Arc<NodeDbLite<PagedbStorageMem>> {
383        let storage = PagedbStorageMem::open_in_memory()
384            .await
385            .expect("open in-memory storage");
386        let config = LiteConfig {
387            kv_cache_capacity: cap,
388            ..LiteConfig::default()
389        };
390        NodeDbLite::open_with_config(storage, config)
391            .await
392            .expect("open NodeDbLite with config")
393    }
394
395    /// Two consecutive gets on the same key: both return the same value.
396    /// The second read is served from the in-process cache.
397    #[tokio::test]
398    async fn cache_hits_on_repeated_get() {
399        let db = open_db().await;
400        db.kv_put("col", "key", b"hello").await.unwrap();
401        // Prime the cache.
402        db.kv_flush().await.unwrap();
403        let v1 = db.kv_get("col", "key").await.unwrap();
404        assert_eq!(v1.as_deref(), Some(b"hello".as_ref()));
405        // Second get — served from cache.
406        let v2 = db.kv_get("col", "key").await.unwrap();
407        assert_eq!(v2.as_deref(), Some(b"hello".as_ref()));
408        // Verify the cache actually holds the entry.
409        assert_eq!(db.kv_local.cache.lock_or_recover().len(), 1);
410    }
411
412    /// After a put-get-put sequence the second get must return the new value,
413    /// not the stale cached one.
414    #[tokio::test]
415    async fn cache_invalidated_on_put() {
416        let db = open_db().await;
417        db.kv_put("col", "key", b"v1").await.unwrap();
418        db.kv_flush().await.unwrap();
419        let _ = db.kv_get("col", "key").await.unwrap(); // populate cache
420        db.kv_put("col", "key", b"v2").await.unwrap();
421        db.kv_flush().await.unwrap();
422        let v = db.kv_get("col", "key").await.unwrap();
423        assert_eq!(v.as_deref(), Some(b"v2".as_ref()));
424    }
425
426    /// After a put-get-delete sequence a subsequent get must return None.
427    #[tokio::test]
428    async fn cache_invalidated_on_delete() {
429        let db = open_db().await;
430        db.kv_put("col", "key", b"v").await.unwrap();
431        db.kv_flush().await.unwrap();
432        let _ = db.kv_get("col", "key").await.unwrap(); // populate cache
433        db.kv_delete("col", "key").await.unwrap();
434        db.kv_flush().await.unwrap();
435        let v = db.kv_get("col", "key").await.unwrap();
436        assert!(v.is_none(), "deleted key must not be returned from cache");
437    }
438
439    /// A key written with a very short TTL must not be served from cache after expiry.
440    #[tokio::test]
441    async fn expired_cached_value_evicted() {
442        let db = open_db().await;
443        // 1 ms TTL — expired almost immediately.
444        db.kv_put_with_ttl("col", "key", b"v", 1).await.unwrap();
445        db.kv_flush().await.unwrap();
446        let _ = db.kv_get("col", "key").await.unwrap(); // may or may not cache
447        // Sleep long enough that the deadline has definitely passed.
448        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
449        let v = db.kv_get("col", "key").await.unwrap();
450        assert!(v.is_none(), "expired key must return None");
451        // Cache must not hold the evicted entry.
452        assert_eq!(
453            db.kv_local.cache.lock_or_recover().len(),
454            0,
455            "expired entry must be evicted from cache"
456        );
457    }
458
459    /// The cache must not grow beyond the configured capacity.
460    #[tokio::test]
461    async fn cache_capacity_eviction() {
462        const CAP: usize = 5;
463        let db = open_db_with_cache_capacity(CAP).await;
464        let col = "cap_test";
465
466        // Write N+10 keys and flush so they land in storage.
467        for i in 0..(CAP + 10) {
468            db.kv_put(col, &i.to_string(), b"x").await.unwrap();
469        }
470        db.kv_flush().await.unwrap();
471
472        // Read all keys — each miss populates the cache.
473        for i in 0..(CAP + 10) {
474            let _ = db.kv_get(col, &i.to_string()).await.unwrap();
475        }
476
477        let cache_len = db.kv_local.cache.lock_or_recover().len();
478        assert!(
479            cache_len <= CAP,
480            "cache must not exceed capacity {CAP}, got {cache_len}"
481        );
482    }
483}