Skip to main content

kaspa_database/
access.rs

1use crate::{cache::CachePolicy, db::DB, errors::StoreError};
2
3use super::prelude::{Cache, DbKey, DbWriter};
4use kaspa_utils::mem_size::MemSizeEstimator;
5use rocksdb::{Direction, IterateBounds, IteratorMode, ReadOptions};
6use serde::{Serialize, de::DeserializeOwned};
7use std::{collections::hash_map::RandomState, error::Error, hash::BuildHasher, marker::PhantomData, sync::Arc};
8
9/// A concurrent DB store access with typed caching.
10///
11/// # Versioned stores
12///
13/// `CachedDbAccess` supports an opt-in "versioned key" mode used by stores
14/// whose on-disk value layout changed across a hardfork (currently only
15/// `DbUtxoDiffsStore` across Toccata). A version-aware store is constructed
16/// with [`CachedDbAccess::new_with_version_suffix`].
17///
18/// When a version suffix is configured, every write appends that byte to the
19/// DB key and every read uses a `rocksdb::PrefixRange` scan on
20/// `[store_prefix || logical_key]` so pre-fork (unversioned) and post-fork
21/// (versioned) rows can coexist on disk under the same logical key. Legacy
22/// rows decode through a caller-provided shadow type `TLegacy` and are
23/// converted into the live `TData` via `TLegacy: Into<TData>`. When
24/// `version_suffix` is `None` (the default for every existing store) each
25/// method falls through to the pre-fork point-get path unchanged — zero
26/// functional impact on non-versioned stores.
27///
28/// The `TLegacy` generic defaults to `TData`, so existing callers that spell
29/// only two or three type parameters (`TKey, TData[, S]`) continue to
30/// compile unchanged: `TData: Into<TData>` is supplied by the blanket
31/// `impl<T> From<T> for T`.
32#[derive(Clone)]
33pub struct CachedDbAccess<TKey, TData, S = RandomState, TLegacy = TData>
34where
35    TKey: Clone + std::hash::Hash + Eq + Send + Sync,
36    TData: Clone + Send + Sync + MemSizeEstimator,
37{
38    db: Arc<DB>,
39
40    // Cache
41    cache: Cache<TKey, TData, S>,
42
43    // DB bucket/path
44    prefix: Vec<u8>,
45
46    /// When `Some(v)`, this store is version-aware: writes append `v` to the
47    /// DB key and reads use a prefix scan that also matches legacy
48    /// (unversioned) rows. See the struct-level docs for full semantics.
49    version_suffix: Option<u8>,
50
51    /// Marker for the legacy-decoder type. Only consulted inside
52    /// `read_versioned` when a legacy row is found on disk.
53    _legacy: PhantomData<fn() -> TLegacy>,
54}
55
56pub type KeyDataResult<TData> = Result<(Box<[u8]>, TData), Box<dyn Error>>;
57
58impl<TKey, TData, S, TLegacy> CachedDbAccess<TKey, TData, S, TLegacy>
59where
60    TKey: Clone + std::hash::Hash + Eq + Send + Sync,
61    TData: Clone + Send + Sync + MemSizeEstimator,
62    S: BuildHasher + Default,
63{
64    pub fn new(db: Arc<DB>, cache_policy: CachePolicy, prefix: Vec<u8>) -> Self {
65        Self { db, cache: Cache::new(cache_policy), prefix, version_suffix: None, _legacy: PhantomData }
66    }
67
68    /// Constructs a version-aware store. Writes append `version_suffix` to
69    /// every DB key. Reads use a `rocksdb::PrefixRange` scan on
70    /// `[store_prefix || logical_key]` and dispatch on the tail byte:
71    ///
72    /// - no tail byte → legacy (pre-fork) row, decoded as `TLegacy` and
73    ///   converted into `TData` via `TLegacy: Into<TData>`;
74    /// - tail byte == `version_suffix` → current row, decoded directly as
75    ///   `TData`;
76    /// - any other tail → [`StoreError::DataInconsistency`].
77    ///
78    /// The caller is responsible for picking a `TLegacy` type whose derived
79    /// `Deserialize` matches the pre-fork byte layout, and for providing a
80    /// `From<TLegacy> for TData` conversion. See
81    /// `consensus/core/src/utxo/pre_toccata.rs` for the current example.
82    pub fn new_with_version_suffix(db: Arc<DB>, cache_policy: CachePolicy, prefix: Vec<u8>, version_suffix: u8) -> Self {
83        Self { db, cache: Cache::new(cache_policy), prefix, version_suffix: Some(version_suffix), _legacy: PhantomData }
84    }
85
86    pub fn read_from_cache(&self, key: TKey) -> Option<TData>
87    where
88        TKey: Copy + AsRef<[u8]>,
89    {
90        self.cache.get(&key)
91    }
92
93    pub fn has(&self, key: TKey) -> Result<bool, StoreError>
94    where
95        TKey: Clone + AsRef<[u8]>,
96    {
97        if self.cache.contains_key(&key) {
98            return Ok(true);
99        }
100        if self.version_suffix.is_some() {
101            // Version-aware existence check: any row (legacy or current) with
102            // the scan prefix counts as "has". We do not need to decode the
103            // value or validate the suffix byte — presence alone is enough.
104            // This is the guard that upholds `read_versioned`'s invariant (1):
105            // `insert`/`insert_batch` call `has` first and refuse to overwrite,
106            // so legacy rows cannot be shadowed by new versioned writes.
107            let scan_prefix = DbKey::new(&self.prefix, key);
108            let mut read_opts = ReadOptions::default();
109            read_opts.set_iterate_range(rocksdb::PrefixRange(scan_prefix.as_ref()));
110            let mut iter = self.db.iterator_opt(IteratorMode::From(scan_prefix.as_ref(), Direction::Forward), read_opts);
111            return match iter.next() {
112                Some(Ok(_)) => Ok(true),
113                Some(Err(e)) => Err(e.into()),
114                None => Ok(false),
115            };
116        }
117        Ok(self.db.get_pinned(DbKey::new(&self.prefix, key))?.is_some())
118    }
119
120    pub fn read(&self, key: TKey) -> Result<TData, StoreError>
121    where
122        TKey: Clone + AsRef<[u8]> + ToString,
123        TData: DeserializeOwned,
124        TLegacy: DeserializeOwned + Into<TData>,
125    {
126        if let Some(data) = self.cache.get(&key) {
127            return Ok(data);
128        }
129        if let Some(suffix) = self.version_suffix {
130            return self.read_versioned(key, suffix);
131        }
132        let db_key = DbKey::new(&self.prefix, key.clone());
133        if let Some(slice) = self.db.get_pinned(&db_key)? {
134            let data: TData = bincode::deserialize(&slice)?;
135            self.cache.insert(key, data.clone());
136            Ok(data)
137        } else {
138            Err(StoreError::KeyNotFound(db_key))
139        }
140    }
141
142    /// Reads a row from a version-aware store.
143    ///
144    /// # Invariants this method assumes
145    ///
146    /// 1. **At most one physical row per logical key.** A logical key is
147    ///    either stored under the legacy (pre-fork) layout
148    ///    `[store_prefix || logical_key]` or under the current layout
149    ///    `[store_prefix || logical_key || version_suffix]`. It is never
150    ///    stored under both simultaneously and never stored under two
151    ///    different versions. The `insert`/`insert_batch` paths on the
152    ///    owning store uphold this invariant by calling `has()`
153    ///    (version-aware) before every write and refusing to overwrite;
154    ///    `delete`/`delete_many` always clear both layouts so no orphan
155    ///    can persist.
156    ///
157    /// 2. **RocksDB `PrefixRange` iteration is ordered and bounded.** The
158    ///    iterator yields keys in lexicographic order, restricted to those
159    ///    starting with `scan_bytes`. We trust the bound and do not
160    ///    re-check `starts_with` on returned keys.
161    ///
162    /// 3. **No key length other than `scan_len` or `scan_len + 1` can
163    ///    legitimately exist.** Anything else is corruption or a future
164    ///    version this binary does not understand — in both cases the
165    ///    right answer is to surface an error rather than silently fall
166    ///    through.
167    ///
168    /// # Behaviour
169    ///
170    /// A single `PrefixRange([store_prefix || logical_key])` scan is opened
171    /// and we take exactly its first (and, by invariant 1, only) element:
172    ///
173    /// - **No element** → [`StoreError::KeyNotFound`].
174    /// - **Element key length == `scan_len`** → legacy row. Decode the value
175    ///   bytes as `TLegacy` and convert via `TLegacy: Into<TData>`. The row
176    ///   is **not** rewritten under the versioned layout — it stays on disk
177    ///   until the key is deleted or explicitly re-inserted.
178    /// - **Element key length == `scan_len + 1`** → versioned row. The tail
179    ///   byte must equal the configured `suffix`; any mismatch is a
180    ///   data-inconsistency error (this binary does not recognize the
181    ///   stored version). On match, decode directly as `TData`.
182    /// - **Any other length** → data-inconsistency error.
183    ///
184    /// The decoded value is inserted into the cache under the caller's
185    /// logical `TKey`, so subsequent reads skip the prefix scan entirely.
186    /// The cache is version-agnostic — it holds the live `TData` regardless
187    /// of which layout produced it.
188    fn read_versioned(&self, key: TKey, suffix: u8) -> Result<TData, StoreError>
189    where
190        TKey: Clone + AsRef<[u8]> + ToString,
191        TData: DeserializeOwned,
192        TLegacy: DeserializeOwned + Into<TData>,
193    {
194        // Build the scan prefix = [store_prefix || logical_key]. Both the
195        // legacy and versioned physical rows share this byte sequence.
196        let scan_prefix = DbKey::new(&self.prefix, key.clone());
197        let scan_bytes = scan_prefix.as_ref();
198        let scan_len = scan_bytes.len();
199
200        let mut read_opts = ReadOptions::default();
201        read_opts.set_iterate_range(rocksdb::PrefixRange(scan_bytes));
202        let mut iter = self.db.iterator_opt(IteratorMode::From(scan_bytes, Direction::Forward), read_opts);
203
204        // By invariant (1) there is at most one matching row. Pull exactly
205        // one element and decide what to do based solely on its key length.
206        let Some(first) = iter.next() else {
207            return Err(StoreError::KeyNotFound(scan_prefix));
208        };
209        let (found_key, found_bytes) = first?;
210
211        let data: TData = if found_key.len() == scan_len {
212            // Legacy (pre-fork) row: empty tail → decode via the legacy
213            // codec and convert into the live `TData`.
214            let legacy_value: TLegacy = bincode::deserialize(&found_bytes)?;
215            legacy_value.into()
216        } else if found_key.len() == scan_len + 1 {
217            // Versioned row: single tail byte that must match the store's
218            // configured version. A mismatch is a data-inconsistency error
219            // — this binary does not recognize the stored version and
220            // refusing to decode is safer than guessing.
221            let tail = found_key[scan_len];
222            if tail != suffix {
223                return Err(StoreError::DataInconsistency(format!(
224                    "unexpected version byte {tail:#x} for key {scan_prefix}, expected {suffix:#x}"
225                )));
226            }
227            bincode::deserialize(&found_bytes)?
228        } else {
229            // Neither layout matches — corruption or an unknown future variant.
230            return Err(StoreError::DataInconsistency(format!(
231                "unexpected row key length {} for {scan_prefix}, expected {} (legacy) or {} (versioned)",
232                found_key.len(),
233                scan_len,
234                scan_len + 1,
235            )));
236        };
237
238        self.cache.insert(key, data.clone());
239        Ok(data)
240    }
241
242    /// Probes the live prefix first and falls back to `fallback_prefix` for legacy
243    /// rows. Used to access a store whose key *prefix* changed across releases —
244    /// readers transparently see both old and new locations during the migration
245    /// window.
246    ///
247    /// Note: `has_with_fallback` and [`Self::read_with_fallback`] are the
248    /// prefix-migration helpers, orthogonal to the version-suffix mechanism.
249    /// They are not aware of `version_suffix` and are not intended to be
250    /// combined with a versioned store; use [`Self::has`] / [`Self::read`]
251    /// for versioned reads.
252    pub fn has_with_fallback(&self, fallback_prefix: &[u8], key: TKey) -> Result<bool, StoreError>
253    where
254        TKey: Clone + AsRef<[u8]>,
255    {
256        if self.cache.contains_key(&key) {
257            Ok(true)
258        } else {
259            let db_key = DbKey::new(&self.prefix, key.clone());
260            if self.db.get_pinned(&db_key)?.is_some() {
261                Ok(true)
262            } else {
263                let db_key = DbKey::new(fallback_prefix, key.clone());
264                Ok(self.db.get_pinned(&db_key)?.is_some())
265            }
266        }
267    }
268
269    /// See [`Self::has_with_fallback`] for the note on interaction with
270    /// versioned stores.
271    pub fn read_with_fallback<TFallbackDeser>(&self, fallback_prefix: &[u8], key: TKey) -> Result<TData, StoreError>
272    where
273        TKey: Clone + AsRef<[u8]> + ToString,
274        TData: DeserializeOwned,
275        TFallbackDeser: DeserializeOwned + Into<TData>,
276    {
277        if let Some(data) = self.cache.get(&key) {
278            Ok(data)
279        } else {
280            let db_key = DbKey::new(&self.prefix, key.clone());
281            if let Some(slice) = self.db.get_pinned(&db_key)? {
282                let data: TData = bincode::deserialize(&slice)?;
283                self.cache.insert(key, data.clone());
284                Ok(data)
285            } else {
286                let db_key = DbKey::new(fallback_prefix, key.clone());
287                if let Some(slice) = self.db.get_pinned(&db_key)? {
288                    let data: TFallbackDeser = bincode::deserialize(&slice)?;
289                    let data: TData = data.into();
290                    self.cache.insert(key, data.clone());
291                    Ok(data)
292                } else {
293                    Err(StoreError::KeyNotFound(db_key))
294                }
295            }
296        }
297    }
298
299    /// Iterates every row in this store under the bare `[store_prefix || *]`
300    /// range. **Not yet aware of versioned stores:** on a version-aware
301    /// store this will emit logical keys that still carry the trailing
302    /// version suffix byte, and it does not dispatch legacy rows through
303    /// `TLegacy`. `DbUtxoDiffsStore` does not call this method; if a future
304    /// versioned store needs streaming iteration, this helper will have to
305    /// learn about `version_suffix` first.
306    pub fn iterator(&self) -> impl Iterator<Item = KeyDataResult<TData>> + '_
307    where
308        TKey: Clone + AsRef<[u8]>,
309        TData: DeserializeOwned, // We need `DeserializeOwned` since the slice coming from `db.get_pinned` has short lifetime
310    {
311        let prefix_key = DbKey::prefix_only(&self.prefix);
312        let mut read_opts = ReadOptions::default();
313        read_opts.set_iterate_range(rocksdb::PrefixRange(prefix_key.as_ref()));
314        self.db.iterator_opt(IteratorMode::From(prefix_key.as_ref(), Direction::Forward), read_opts).map(move |iter_result| {
315            match iter_result {
316                Ok((key, data_bytes)) => match bincode::deserialize(&data_bytes) {
317                    Ok(data) => Ok((key[prefix_key.prefix_len()..].into(), data)),
318                    Err(e) => Err(e.into()),
319                },
320                Err(e) => Err(e.into()),
321            }
322        })
323    }
324
325    pub fn write(&self, mut writer: impl DbWriter, key: TKey, data: TData) -> Result<(), StoreError>
326    where
327        TKey: Clone + AsRef<[u8]>,
328        TData: Serialize,
329    {
330        let bin_data = bincode::serialize(&data)?;
331        self.cache.insert(key.clone(), data);
332        writer.put(self.build_write_key(key), bin_data)?;
333        Ok(())
334    }
335
336    pub fn write_many(
337        &self,
338        mut writer: impl DbWriter,
339        iter: &mut (impl Iterator<Item = (TKey, TData)> + Clone),
340    ) -> Result<(), StoreError>
341    where
342        TKey: Clone + AsRef<[u8]>,
343        TData: Serialize,
344    {
345        let iter_clone = iter.clone();
346        self.cache.insert_many(iter);
347        for (key, data) in iter_clone {
348            let bin_data = bincode::serialize(&data)?;
349            writer.put(self.build_write_key(key), bin_data)?;
350        }
351        Ok(())
352    }
353
354    /// Write directly from an iterator and do not cache any data. NOTE: this action also clears the cache
355    pub fn write_many_without_cache(
356        &self,
357        mut writer: impl DbWriter,
358        iter: &mut impl Iterator<Item = (TKey, TData)>,
359    ) -> Result<(), StoreError>
360    where
361        TKey: Clone + AsRef<[u8]>,
362        TData: Serialize,
363    {
364        for (key, data) in iter {
365            let bin_data = bincode::serialize(&data)?;
366            writer.put(self.build_write_key(key), bin_data)?;
367        }
368        // We must clear the cache in order to avoid invalidated entries
369        self.cache.remove_all();
370        Ok(())
371    }
372
373    pub fn delete(&self, mut writer: impl DbWriter, key: TKey) -> Result<(), StoreError>
374    where
375        TKey: Clone + AsRef<[u8]>,
376    {
377        self.cache.remove(&key);
378        self.delete_both_layouts(&mut writer, key)
379    }
380
381    pub fn delete_many(&self, mut writer: impl DbWriter, key_iter: &mut (impl Iterator<Item = TKey> + Clone)) -> Result<(), StoreError>
382    where
383        TKey: Clone + AsRef<[u8]>,
384    {
385        let key_iter_clone = key_iter.clone();
386        self.cache.remove_many(key_iter);
387        for key in key_iter_clone {
388            self.delete_both_layouts(&mut writer, key)?;
389        }
390        Ok(())
391    }
392
393    /// Builds the DB key used for writes. For non-versioned stores this is
394    /// `[store_prefix || logical_key]`; for versioned stores it is
395    /// `[store_prefix || logical_key || version_suffix]`.
396    fn build_write_key(&self, key: TKey) -> DbKey
397    where
398        TKey: Clone + AsRef<[u8]>,
399    {
400        let mut db_key = DbKey::new(&self.prefix, key);
401        if let Some(suffix) = self.version_suffix {
402            db_key.add_suffix([suffix]);
403        }
404        db_key
405    }
406
407    /// Deletes both the versioned and legacy rows for a logical key. Delete
408    /// of a non-existent row is a no-op in RocksDB, so non-versioned stores
409    /// still issue exactly one delete and versioned stores issue one extra
410    /// no-op delete during the migration window.
411    fn delete_both_layouts(&self, writer: &mut impl DbWriter, key: TKey) -> Result<(), StoreError>
412    where
413        TKey: Clone + AsRef<[u8]>,
414    {
415        if self.version_suffix.is_some() {
416            writer.delete(self.build_write_key(key.clone()))?;
417        }
418        writer.delete(DbKey::new(&self.prefix, key))?;
419        Ok(())
420    }
421
422    /// Deletes all entries in the store using the underlying rocksdb `delete_range` operation
423    pub fn delete_all(&self, mut writer: impl DbWriter) -> Result<(), StoreError>
424    where
425        TKey: Clone + AsRef<[u8]>,
426    {
427        self.cache.remove_all();
428        let db_key = DbKey::prefix_only(&self.prefix);
429        let (from, to) = rocksdb::PrefixRange(db_key.as_ref()).into_bounds();
430        writer.delete_range(from.unwrap(), to.unwrap())?;
431        Ok(())
432    }
433
434    /// A dynamic iterator that can iterate through a specific prefix / bucket, or from a certain start point.
435    ///
436    /// Not yet aware of versioned stores — same caveat as [`Self::iterator`].
437    //TODO: loop and chain iterators for multi-prefix / bucket iterator.
438    pub fn seek_iterator(
439        &self,
440        bucket: Option<&[u8]>,   // iter self.prefix if None, else append bytes to self.prefix.
441        seek_from: Option<TKey>, // iter whole range if None
442        limit: usize,            // amount to take.
443        skip_first: bool,        // skips the first value, (useful in conjunction with the seek-key, as to not re-retrieve).
444    ) -> impl Iterator<Item = KeyDataResult<TData>> + '_
445    where
446        TKey: Clone + AsRef<[u8]>,
447        TData: DeserializeOwned,
448    {
449        let db_key = bucket.map_or_else(
450            move || DbKey::prefix_only(&self.prefix),
451            move |bucket| {
452                let mut key = DbKey::prefix_only(&self.prefix);
453                key.add_bucket(bucket);
454                key
455            },
456        );
457
458        let mut read_opts = ReadOptions::default();
459        read_opts.set_iterate_range(rocksdb::PrefixRange(db_key.as_ref()));
460
461        let mut db_iterator = match seek_from {
462            Some(seek_key) => {
463                self.db.iterator_opt(IteratorMode::From(DbKey::new(&self.prefix, seek_key).as_ref(), Direction::Forward), read_opts)
464            }
465            None => self.db.iterator_opt(IteratorMode::Start, read_opts),
466        };
467
468        if skip_first {
469            db_iterator.next();
470        }
471
472        db_iterator.take(limit).map(move |item| match item {
473            Ok((key_bytes, value_bytes)) => match bincode::deserialize::<TData>(value_bytes.as_ref()) {
474                Ok(value) => Ok((key_bytes[db_key.prefix_len()..].into(), value)),
475                Err(err) => Err(err.into()),
476            },
477            Err(err) => Err(err.into()),
478        })
479    }
480
481    pub fn prefix(&self) -> &[u8] {
482        &self.prefix
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use crate::{
490        create_temp_db,
491        prelude::{BatchDbWriter, ConnBuilder, DirectDbWriter},
492    };
493    use kaspa_hashes::Hash;
494    use rocksdb::WriteBatch;
495
496    #[test]
497    fn test_delete_all() {
498        let (_lifetime, db) = create_temp_db!(ConnBuilder::default().with_files_limit(10));
499        let access = CachedDbAccess::<Hash, u64>::new(db.clone(), CachePolicy::Count(2), vec![1, 2]);
500
501        access.write_many(DirectDbWriter::new(&db), &mut (0..16).map(|i| (i.into(), 2))).unwrap();
502        assert_eq!(16, access.iterator().count());
503        access.delete_all(DirectDbWriter::new(&db)).unwrap();
504        assert_eq!(0, access.iterator().count());
505
506        access.write_many(DirectDbWriter::new(&db), &mut (0..16).map(|i| (i.into(), 2))).unwrap();
507        assert_eq!(16, access.iterator().count());
508        let mut batch = WriteBatch::default();
509        access.delete_all(BatchDbWriter::new(&mut batch)).unwrap();
510        assert_eq!(16, access.iterator().count());
511        db.write(batch).unwrap();
512        assert_eq!(0, access.iterator().count());
513    }
514
515    #[test]
516    fn test_read_with_fallback() {
517        let (_lifetime, db) = create_temp_db!(ConnBuilder::default().with_files_limit(10));
518        let primary_prefix = vec![1];
519        let fallback_prefix = vec![2];
520        let access = CachedDbAccess::<Hash, u64>::new(db.clone(), CachePolicy::Count(10), primary_prefix);
521        let fallback_access = CachedDbAccess::<Hash, u64>::new(db.clone(), CachePolicy::Count(10), fallback_prefix.clone());
522
523        let key: Hash = 1.into();
524        let value = 100;
525
526        // Write to fallback
527        fallback_access.write(DirectDbWriter::new(&db), key, value).unwrap();
528
529        // Read with fallback, should succeed
530        let result = access.read_with_fallback::<u64>(&fallback_prefix, key).unwrap();
531        assert_eq!(result, value);
532
533        // Key should now be in the primary cache
534        assert_eq!(access.read_from_cache(key).unwrap(), value);
535    }
536
537    #[test]
538    fn test_has_with_fallback() {
539        let (_lifetime, db) = create_temp_db!(ConnBuilder::default().with_files_limit(10));
540        let primary_prefix = vec![1];
541        let fallback_prefix = vec![2];
542        let access = CachedDbAccess::<Hash, u64>::new(db.clone(), CachePolicy::Count(10), primary_prefix);
543        let fallback_access = CachedDbAccess::<Hash, u64>::new(db.clone(), CachePolicy::Count(10), fallback_prefix.clone());
544
545        let key_in_fallback: Hash = 1.into();
546        let key_not_found: Hash = 2.into();
547
548        // Write to fallback
549        fallback_access.write(DirectDbWriter::new(&db), key_in_fallback, 100).unwrap();
550
551        // Check for key in fallback, should exist
552        assert!(access.has_with_fallback(&fallback_prefix, key_in_fallback).unwrap());
553
554        // Check for key that doesn't exist, should not be found
555        assert!(!access.has_with_fallback(&fallback_prefix, key_not_found).unwrap());
556    }
557
558    mod versioned {
559        //! Tests for the version-aware code path introduced for UtxoDiffs
560        //! pre/post-Toccata compatibility. Each test uses a dedicated
561        //! `Current` / `Legacy` pair so the legacy decode path is wired
562        //! through a real `TLegacy: Into<TData>` conversion rather than
563        //! relying on the trivial `T -> T` identity.
564        use super::*;
565        use crate::errors::StoreErrorPredicates;
566        use serde::{Deserialize, Serialize};
567
568        /// Post-fork value type.
569        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
570        struct Current {
571            value: u64,
572            extra: Option<u32>,
573        }
574
575        impl MemSizeEstimator for Current {}
576
577        /// Pre-fork value type — one field shorter than `Current`. Its
578        /// derived `Deserialize` consumes only the pre-fork byte layout
579        /// and cleanly fails on the post-fork layout.
580        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581        struct Legacy {
582            value: u64,
583        }
584
585        impl From<Legacy> for Current {
586            fn from(l: Legacy) -> Self {
587                Current { value: l.value, extra: None }
588            }
589        }
590
591        const STORE_PREFIX: u8 = 42;
592        const VERSION_SUFFIX: u8 = 1;
593
594        fn versioned_access(db: Arc<DB>) -> CachedDbAccess<Hash, Current, RandomState, Legacy> {
595            CachedDbAccess::new_with_version_suffix(db, CachePolicy::Count(16), vec![STORE_PREFIX], VERSION_SUFFIX)
596        }
597
598        /// Builds the raw `[prefix || hash]` byte vector used by pre-fork writes.
599        fn legacy_raw_key(hash: Hash) -> Vec<u8> {
600            let mut bytes = vec![STORE_PREFIX];
601            bytes.extend_from_slice(hash.as_bytes().as_ref());
602            bytes
603        }
604
605        /// Builds the raw `[prefix || hash || suffix]` byte vector used by post-fork writes.
606        fn versioned_raw_key(hash: Hash, suffix: u8) -> Vec<u8> {
607            let mut bytes = legacy_raw_key(hash);
608            bytes.push(suffix);
609            bytes
610        }
611
612        #[test]
613        fn versioned_round_trip() {
614            let (_lifetime, db) = create_temp_db!(ConnBuilder::default().with_files_limit(10));
615            let access = versioned_access(db.clone());
616
617            let hash: Hash = 7.into();
618            let value = Current { value: 1_000_000, extra: Some(42) };
619
620            access.write(DirectDbWriter::new(&db), hash, value.clone()).unwrap();
621
622            // On-disk bytes must live under the versioned key, NOT the legacy key.
623            assert!(db.get_pinned(versioned_raw_key(hash, VERSION_SUFFIX)).unwrap().is_some());
624            assert!(db.get_pinned(legacy_raw_key(hash)).unwrap().is_none());
625
626            // Round trip through the access layer.
627            assert_eq!(access.read(hash).unwrap(), value);
628
629            // `has` sees the row.
630            assert!(access.has(hash).unwrap());
631
632            // Delete + re-read.
633            access.delete(DirectDbWriter::new(&db), hash).unwrap();
634            assert!(!access.has(hash).unwrap());
635            assert!(access.read(hash).is_err());
636        }
637
638        #[test]
639        fn versioned_read_decodes_legacy_row() {
640            let (_lifetime, db) = create_temp_db!(ConnBuilder::default().with_files_limit(10));
641            let access = versioned_access(db.clone());
642
643            let hash: Hash = 13.into();
644            let legacy = Legacy { value: 99 };
645            let legacy_bytes = bincode::serialize(&legacy).unwrap();
646
647            // Simulate a pre-fork writer: put raw bytes at the unversioned layout,
648            // bypassing the access layer entirely.
649            db.put(legacy_raw_key(hash), legacy_bytes).unwrap();
650
651            // Reading through the version-aware access layer finds the legacy row
652            // via the prefix scan, decodes it through `TLegacy`, and converts to
653            // `Current` via `From<Legacy>`.
654            let current = access.read(hash).unwrap();
655            assert_eq!(current, Current { value: 99, extra: None });
656
657            // `has` sees the legacy row too (enforces the "no dupe writes" guard).
658            assert!(access.has(hash).unwrap());
659
660            // Deleting through the access layer must clear the legacy row as well,
661            // not just the (non-existent) versioned row.
662            access.delete(DirectDbWriter::new(&db), hash).unwrap();
663            assert!(db.get_pinned(legacy_raw_key(hash)).unwrap().is_none());
664            assert!(!access.has(hash).unwrap());
665        }
666
667        #[test]
668        fn versioned_read_rejects_unknown_suffix_byte() {
669            let (_lifetime, db) = create_temp_db!(ConnBuilder::default().with_files_limit(10));
670            let access = versioned_access(db.clone());
671
672            let hash: Hash = 21.into();
673            let bytes = bincode::serialize(&Current { value: 1, extra: None }).unwrap();
674
675            // Write under an unknown version suffix — this binary does not
676            // recognize it, so the read must refuse to decode.
677            db.put(versioned_raw_key(hash, 0xAB), bytes).unwrap();
678
679            let err = access.read(hash).unwrap_err();
680            assert!(matches!(err, StoreError::DataInconsistency(_)), "expected DataInconsistency, got {err:?}");
681        }
682
683        #[test]
684        fn versioned_read_rejects_unexpected_key_length() {
685            let (_lifetime, db) = create_temp_db!(ConnBuilder::default().with_files_limit(10));
686            let access = versioned_access(db.clone());
687
688            let hash: Hash = 34.into();
689            let bytes = bincode::serialize(&Current { value: 1, extra: None }).unwrap();
690
691            // Corrupt on-disk layout: two trailing bytes after the logical key.
692            let mut bogus_key = legacy_raw_key(hash);
693            bogus_key.push(VERSION_SUFFIX);
694            bogus_key.push(0x00);
695            db.put(bogus_key, bytes).unwrap();
696
697            let err = access.read(hash).unwrap_err();
698            assert!(matches!(err, StoreError::DataInconsistency(_)), "expected DataInconsistency, got {err:?}");
699        }
700
701        #[test]
702        fn versioned_delete_clears_both_layouts() {
703            let (_lifetime, db) = create_temp_db!(ConnBuilder::default().with_files_limit(10));
704            let access = versioned_access(db.clone());
705
706            let hash: Hash = 55.into();
707
708            // Plant both a legacy row AND a versioned row for the same logical key.
709            // This scenario cannot arise through normal usage (the `insert` guard
710            // refuses to overwrite), but the delete path must still be robust to
711            // it so corrupted state can be cleaned up.
712            db.put(legacy_raw_key(hash), bincode::serialize(&Legacy { value: 7 }).unwrap()).unwrap();
713            db.put(versioned_raw_key(hash, VERSION_SUFFIX), bincode::serialize(&Current { value: 7, extra: Some(1) }).unwrap())
714                .unwrap();
715
716            access.delete(DirectDbWriter::new(&db), hash).unwrap();
717
718            assert!(db.get_pinned(legacy_raw_key(hash)).unwrap().is_none());
719            assert!(db.get_pinned(versioned_raw_key(hash, VERSION_SUFFIX)).unwrap().is_none());
720        }
721
722        #[test]
723        fn versioned_has_guards_inserts_against_legacy_rows() {
724            // This is the functional contract that keeps `read_versioned`'s
725            // invariant (1) true: on a store that already holds a legacy row,
726            // `has` must report true so that `insert_batch` / `insert` refuse
727            // to write a second (versioned) row and shadow the legacy one.
728            let (_lifetime, db) = create_temp_db!(ConnBuilder::default().with_files_limit(10));
729            let access = versioned_access(db.clone());
730
731            let hash: Hash = 89.into();
732            db.put(legacy_raw_key(hash), bincode::serialize(&Legacy { value: 11 }).unwrap()).unwrap();
733
734            assert!(access.has(hash).unwrap(), "versioned has() must observe legacy rows");
735
736            // Sanity: a hash with no row anywhere still reports false.
737            assert!(!access.has(Hash::from_u64_word(12345)).unwrap());
738        }
739
740        #[test]
741        fn versioned_read_from_missing_key_is_not_found() {
742            let (_lifetime, db) = create_temp_db!(ConnBuilder::default().with_files_limit(10));
743            let access = versioned_access(db);
744
745            let err = access.read(Hash::from_u64_word(999)).unwrap_err();
746            assert!(err.is_key_not_found(), "expected KeyNotFound, got {err:?}");
747        }
748    }
749}