Coverage Report

Created: 2026-10-25 01:51

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/lyquor/lyquor/platform/db/src/lib.rs
Line
Count
Source
1
#![doc(html_no_source)] // remove it upon open-source
2
3
//! Concrete key-value backends for Lyquor persistence traits.
4
//!
5
//! `lyquor-db` implements the `lyquor_api::store` interfaces for both in-memory storage and
6
//! RocksDB-backed storage. Higher layers use those interfaces for simple state, versioned state,
7
//! artifact caches, and sequencing metadata; this crate keeps backend transactions and iteration
8
//! behavior below that shared storage boundary.
9
10
use std::collections::HashMap;
11
use std::path::Path;
12
use std::sync::Arc;
13
14
pub use rocksdb;
15
use rocksdb::{OptimisticTransactionDB, Options};
16
17
use lyquor_api::{
18
    anyhow::{Context, anyhow},
19
    log::LogClass,
20
    parking_lot::RwLock,
21
    store::{KVStore, KVStoreError, Key, SortedMapping, StoreFuture, Value},
22
};
23
use tokio::sync::Mutex;
24
25
/// In-memory key-value store used by tests and ephemeral node configurations.
26
pub struct MemDB(RwLock<HashMap<Bytes, Bytes>>);
27
28
impl MemDB {
29
    /// Create an empty in-memory store.
30
498
    pub fn new() -> Self {
31
498
        Self(RwLock::new(HashMap::new()))
32
498
    }
33
}
34
35
impl Default for MemDB {
36
2
    fn default() -> Self {
37
2
        Self::new()
38
2
    }
39
}
40
41
impl KVStore for MemDB {
42
16.4k
    fn atomic_write<'a>(
43
16.4k
        &'a self, changes: Box<dyn Iterator<Item = (Key, Option<Value>)> + 'a>,
44
16.4k
    ) -> Result<(), KVStoreError> {
45
16.4k
        let mut map = self.0.write();
46
47.9k
        for (k, v) in 
changes16.4k
{
47
47.9k
            match v {
48
47.9k
                Some(v) => {
49
47.9k
                    map.insert(k.consolidated(), v);
50
47.9k
                }
51
1
                None => {
52
1
                    map.remove(&k.consolidated());
53
1
                }
54
            }
55
        }
56
16.4k
        Ok(())
57
16.4k
    }
58
59
12
    fn contains(&self, key: Key) -> StoreFuture<bool> {
60
12
        Box::pin(std::future::ready(self.0.read().contains_key(&key.consolidated())))
61
12
    }
62
63
270k
    fn get(&self, key: Key) -> StoreFuture<Result<Option<Value>, KVStoreError>> {
64
270k
        Box::pin(std::future::ready(Ok(self.0.read().get(&key.consolidated()).cloned())))
65
270k
    }
66
}
67
68
/// RocksDB-backed key-value store implementing Lyquor's shared storage trait.
69
#[derive(Clone)]
70
pub struct RocksDB(Arc<rocksdb::OptimisticTransactionDB>);
71
72
impl KVStore for RocksDB {
73
17
    fn atomic_write<'a>(
74
17
        &'a self, changes: Box<dyn Iterator<Item = (Key, Option<Value>)> + 'a>,
75
17
    ) -> Result<(), KVStoreError> {
76
17
        let mut wb = rocksdb::WriteBatchWithTransaction::default();
77
36
        for (k, v) in 
changes17
{
78
36
            match v {
79
35
                Some(v) => wb.put(k.consolidated(), v),
80
1
                None => wb.delete(k.consolidated()),
81
            }
82
        }
83
17
        if let Err(
error0
) = self.0.write(wb).context("RocksDB write error.") {
84
0
            tracing::error!(
85
                operation = "write",
86
                class = %LogClass::NodeStorage,
87
                error = ?error,
88
                "RocksDB operation failed"
89
            );
90
0
            return Err(error.into());
91
17
        }
92
17
        Ok(())
93
17
    }
94
95
0
    fn contains(&self, key: Key) -> StoreFuture<bool> {
96
0
        let db = self.0.clone();
97
0
        let key = key.consolidated();
98
0
        Box::pin(async move {
99
0
            tokio::task::spawn_blocking(move || db.key_may_exist(key))
100
0
                .await
101
0
                .unwrap_or(false)
102
0
        })
103
0
    }
104
105
355
    fn get(&self, key: Key) -> StoreFuture<Result<Option<Value>, KVStoreError>> {
106
355
        let db = self.0.clone();
107
355
        let key = key.consolidated();
108
355
        Box::pin(async move {
109
355
            tokio::task::spawn_blocking(move || -> Result<Option<Value>, KVStoreError> {
110
355
                match db.get(key).context("RocksDB read error.") {
111
355
                    Ok(value) => Ok(value.map(std::convert::Into::into)),
112
0
                    Err(error) => {
113
0
                        tracing::error!(
114
                            operation = "read",
115
                            class = %LogClass::NodeStorage,
116
                            error = ?error,
117
                            "RocksDB operation failed"
118
                        );
119
0
                        Err(error.into())
120
                    }
121
                }
122
355
            })
123
355
            .await
124
355
            .context("RocksDB read task join error.")
?0
125
355
        })
126
355
    }
127
}
128
129
impl RocksDB {
130
    /// Open or create a RocksDB store at `path`.
131
13
    pub fn new(path: &Path) -> Result<Self, KVStoreError> {
132
13
        let mut opts = Options::default();
133
13
        opts.create_if_missing(true);
134
        // Compiled artifacts can be tens of megabytes. Keep them out of SST data
135
        // blocks so reading adjacent state keys does not decompress the artifact.
136
13
        opts.set_enable_blob_files(true);
137
13
        opts.set_min_blob_size(1024 * 1024);
138
13
        opts.set_blob_compression_type(rocksdb::DBCompressionType::Snappy);
139
13
        opts.set_enable_blob_gc(true);
140
13
        let db = match OptimisticTransactionDB::open(&opts, path).context("RocksDB open error") {
141
13
            Ok(db) => db,
142
0
            Err(error) => {
143
0
                tracing::error!(
144
0
                    path = %path.display(),
145
                    operation = "open",
146
                    class = %LogClass::NodeStorage,
147
                    error = ?error,
148
                    "RocksDB operation failed"
149
                );
150
0
                return Err(error.into());
151
            }
152
        };
153
13
        tracing::info!(path = %
path0
.
display0
(), "RocksDB store opened");
154
13
        Ok(Self(Arc::new(db)))
155
13
    }
156
}
157
158
use lyquor_api::subkey_builder;
159
use lyquor_primitives::{Bytes, decode_object, encode_object};
160
use serde::{Deserialize, Serialize};
161
use std::hash::Hash;
162
163
/// Persistent sorted mapping backed by a Lyquor key-value store.
164
pub struct SortedMappingStore<
165
    S: KVStore,
166
    K: Hash + Eq + Ord + Serialize + for<'a> Deserialize<'a> + Clone,
167
    V: Serialize + for<'a> Deserialize<'a> + Clone,
168
> {
169
    state: Arc<Mutex<SortedMappingState<K, V>>>,
170
    store: Arc<S>,
171
}
172
173
struct SortedMappingState<
174
    K: Hash + Eq + Ord + Serialize + for<'a> Deserialize<'a> + Clone,
175
    V: Serialize + for<'a> Deserialize<'a> + Clone,
176
> {
177
    cache: lru::LruCache<K, V>,
178
    max: Option<K>,
179
    first_idx: u64,
180
    next_idx: u64,
181
}
182
183
subkey_builder!(SortedMappingSubkey(
184
    ([0x00])-value(&[u8]) => Key,
185
    ([0x01])-index(&u64) => Key,
186
    ([0x02])-max() => Key,
187
    ([0x03])-first_idx() => Key,
188
    ([0x04])-next_idx() => Key,
189
));
190
191
impl<
192
    S: KVStore,
193
    K: Hash + Eq + Ord + Serialize + for<'a> Deserialize<'a> + Clone,
194
    V: Serialize + for<'a> Deserialize<'a> + Clone,
195
> SortedMappingStore<S, K, V>
196
{
197
118
    fn subkey() -> &'static SortedMappingSubkey {
198
        static KEY: std::sync::OnceLock<SortedMappingSubkey> = std::sync::OnceLock::new();
199
118
        KEY.get_or_init(|| 
SortedMappingSubkey::new10
(
Value::new10
().
into10
()))
200
118
    }
201
202
    /// Open a sorted mapping view over `store` with an in-memory lookup cache.
203
15
    pub async fn new(store: S, cache_size: std::num::NonZeroUsize) -> Result<Self, KVStoreError> {
204
15
        let 
max14
:
Option<K>14
= match store.get(Self::subkey().max()).await
?0
{
205
2
            Some(raw) => Some(decode_object(&raw).ok_or_else(|| 
anyhow!1
("failed to decode sorted mapping max key"))
?1
),
206
13
            None => None,
207
        };
208
14
        let first_idx: u64 = match store.get(Self::subkey().first_idx()).await
?0
{
209
0
            Some(raw) => decode_object(&raw).ok_or_else(|| anyhow!("failed to decode sorted mapping first index"))?,
210
14
            None => 0,
211
        };
212
14
        let next_idx: u64 = match store.get(Self::subkey().next_idx()).await
?0
{
213
1
            Some(raw) => decode_object(&raw).ok_or_else(|| 
anyhow!0
("failed to decode sorted mapping next index"))
?0
,
214
13
            None => 0,
215
        };
216
217
14
        Ok(Self {
218
14
            state: Arc::new(Mutex::new(SortedMappingState {
219
14
                cache: lru::LruCache::new(cache_size),
220
14
                max,
221
14
                first_idx,
222
14
                next_idx,
223
14
            })),
224
14
            store: Arc::new(store),
225
14
        })
226
15
    }
227
}
228
229
impl<
230
    S: KVStore + 'static,
231
    K: Hash + Eq + Ord + Serialize + for<'a> Deserialize<'a> + Clone + Send + 'static,
232
    V: Serialize + for<'a> Deserialize<'a> + Clone + Send + 'static,
233
> SortedMapping<K, V> for SortedMappingStore<S, K, V>
234
{
235
12
    fn max_key(&self) -> StoreFuture<Option<K>> {
236
12
        let state = self.state.clone();
237
12
        Box::pin(async move { state.lock().await.max.clone() })
238
12
    }
239
240
3
    fn append(&self, key: K, value: V) -> StoreFuture<Result<bool, KVStoreError>> {
241
3
        let state = self.state.clone();
242
3
        let store = self.store.clone();
243
3
        Box::pin(async move {
244
3
            let mut state = state.lock().await;
245
3
            Self::append_new_max(store.as_ref(), &mut state, key, value)
246
3
        })
247
3
    }
248
249
13
    fn append_or_update_max(&self, key: K, value: V) -> StoreFuture<Result<bool, KVStoreError>> {
250
13
        let state = self.state.clone();
251
13
        let store = self.store.clone();
252
13
        Box::pin(async move {
253
13
            let mut state = state.lock().await;
254
13
            match state.max.clone() {
255
5
                Some(
max2
) if key > ma
x2
=>
Self::append_new_max2
(
store.as_ref()2
,
&mut state2
,
key2
,
value2
),
256
3
                Some(
max2
) if key == ma
x2
=> {
257
2
                    let key_raw: Bytes = encode_object(&key).into();
258
2
                    store.atomic_write(Box::new(
259
2
                        [(Self::subkey().value(&key_raw), Some(encode_object(&value).into()))].into_iter(),
260
2
                    ))
?0
;
261
2
                    state.cache.push(key.clone(), value.clone());
262
2
                    Ok(true)
263
                }
264
1
                Some(_) => Ok(false),
265
8
                None => Self::append_new_max(store.as_ref(), &mut state, key, value),
266
            }
267
13
        })
268
13
    }
269
270
19
    fn get_le(&self, key: K) -> StoreFuture<Result<Option<V>, KVStoreError>> {
271
19
        let state = self.state.clone();
272
19
        let store = self.store.clone();
273
19
        Box::pin(async move {
274
19
            let mut state = state.lock().await;
275
19
            let first_idx = state.first_idx;
276
19
            let next_idx = state.next_idx;
277
19
            let Some(
raw9
) = store.get(Self::subkey().index(&first_idx)).await
?0
else {
278
10
                return Ok(None);
279
            };
280
9
            let mut l_key = decode_object(&raw).ok_or_else(|| 
anyhow!0
("failed to decode sorted mapping index key"))
?0
;
281
9
            if key < l_key {
282
0
                return Ok(None);
283
9
            }
284
9
            let mut l = first_idx;
285
9
            let mut r = next_idx;
286
11
            while r > l + 1 {
287
2
                let mid = (l + r) >> 1;
288
2
                let Some(raw) = store.get(Self::subkey().index(&mid)).await
?0
else {
289
0
                    return Ok(None);
290
                };
291
2
                let mid_key =
292
2
                    decode_object(&raw).ok_or_else(|| 
anyhow!0
("failed to decode sorted mapping index key"))
?0
;
293
2
                if key < mid_key {
294
1
                    r = mid;
295
1
                } else {
296
1
                    l = mid;
297
1
                    l_key = mid_key;
298
1
                }
299
            }
300
9
            Self::get_locked(store, &mut state, l_key).await
301
19
        })
302
19
    }
303
304
4
    fn get(&self, key: K) -> StoreFuture<Result<Option<V>, KVStoreError>> {
305
4
        let state = self.state.clone();
306
4
        let store = self.store.clone();
307
4
        Box::pin(async move {
308
4
            let mut state = state.lock().await;
309
4
            Self::get_locked(store, &mut state, key).await
310
4
        })
311
4
    }
312
}
313
314
impl<
315
    S: KVStore + 'static,
316
    K: Hash + Eq + Ord + Serialize + for<'a> Deserialize<'a> + Clone + Send + 'static,
317
    V: Serialize + for<'a> Deserialize<'a> + Clone + Send + 'static,
318
> SortedMappingStore<S, K, V>
319
{
320
13
    fn append_new_max(store: &S, state: &mut SortedMappingState<K, V>, key: K, value: V) -> Result<bool, KVStoreError> {
321
13
        if state.max.clone().is_some_and(|max| 
key3
<=
max3
) {
322
1
            return Ok(false);
323
12
        }
324
325
12
        let idx = state.next_idx;
326
12
        let next_idx = idx + 1;
327
12
        let key_raw: Bytes = encode_object(&key).into();
328
12
        let mapping = [
329
12
            (Self::subkey().value(&key_raw), Some(encode_object(&value).into())),
330
12
            (Self::subkey().index(&idx), Some(key_raw.clone())),
331
12
            (Self::subkey().max(), Some(key_raw)),
332
12
            (Self::subkey().next_idx(), Some(encode_object(&next_idx).into())),
333
12
        ]
334
12
        .into_iter();
335
12
        store.atomic_write(Box::new(mapping))
?0
;
336
12
        state.next_idx = next_idx;
337
12
        state.cache.push(key.clone(), value);
338
12
        state.max = Some(key);
339
12
        Ok(true)
340
13
    }
341
342
13
    async fn get_locked(
343
13
        store: Arc<S>, state: &mut SortedMappingState<K, V>, key: K,
344
13
    ) -> Result<Option<V>, KVStoreError> {
345
13
        if let Some(
value11
) = state.cache.get(&key).cloned() {
346
11
            return Ok(Some(value));
347
2
        }
348
349
2
        let Some(
raw1
) = store.get(Self::subkey().value(&encode_object(&key))).await
?0
else {
350
1
            return Ok(None);
351
        };
352
1
        let 
value0
:
V0
= decode_object(&raw).ok_or_else(|| anyhow!("failed to decode sorted mapping value"))?;
353
0
        state.cache.push(key.clone(), value.clone());
354
0
        Ok(Some(value))
355
13
    }
356
}
357
358
#[cfg(test)]
359
mod tests {
360
    use std::sync::Arc;
361
362
    use super::SortedMappingStore;
363
    use crate::{MemDB, RocksDB};
364
    use lyquor_api::store::{KVStore, SortedMapping};
365
    use lyquor_primitives::{Bytes, encode_object};
366
    use lyquor_test::test;
367
368
    #[test]
369
    fn small_state_reads_do_not_load_neighboring_artifacts() {
370
        use rocksdb::{PerfContext, PerfMetric, PerfStatsLevel, perf::set_perf_stats};
371
372
        let dir = tempfile::tempdir().unwrap();
373
        let artifact = vec![42; 4 * 1024 * 1024];
374
        {
375
            let db = RocksDB::new(dir.path()).unwrap();
376
            db.atomic_write(Box::new(
377
                [
378
                    (b"state:a".as_slice().into(), Some(b"metadata".to_vec().into())),
379
                    (b"state:z".as_slice().into(), Some(artifact.clone().into())),
380
                ]
381
                .into_iter(),
382
            ))
383
            .unwrap();
384
            db.0.flush().unwrap();
385
        }
386
387
        let db = RocksDB::new(dir.path()).unwrap();
388
        set_perf_stats(PerfStatsLevel::EnableCount);
389
        let mut perf = PerfContext::default();
390
        for (key, expected) in [(b"state:a", Some(b"metadata".to_vec())), (b"state:b", None)] {
391
            perf.reset();
392
            assert_eq!(db.0.get(key).unwrap(), expected);
393
            assert!(
394
                perf.metric(PerfMetric::BlockReadByte) < 64 * 1024,
395
                "small state lookup loaded an artifact-sized block: {}",
396
                perf.report(true)
397
            );
398
        }
399
        set_perf_stats(PerfStatsLevel::Disable);
400
        assert_eq!(db.0.get(b"state:z").unwrap().as_deref(), Some(artifact.as_slice()));
401
    }
402
403
    #[test(tokio::test)]
404
    async fn rocksdb_preserves_inline_and_blob_values_across_restarts() {
405
        let dir = tempfile::tempdir().unwrap();
406
        let original = vec![17; 2 * 1024 * 1024];
407
        let replacement = vec![23; 3 * 1024 * 1024];
408
        let key = b"artifact".as_slice();
409
        {
410
            let mut opts = rocksdb::Options::default();
411
            opts.create_if_missing(true);
412
            let legacy: rocksdb::OptimisticTransactionDB =
413
                rocksdb::OptimisticTransactionDB::open(&opts, dir.path()).unwrap();
414
            legacy.put(key, &original).unwrap();
415
            legacy.flush().unwrap();
416
        }
417
        {
418
            let db = RocksDB::new(dir.path()).unwrap();
419
            assert_eq!(db.get(key.into()).await.unwrap().unwrap().as_ref(), original);
420
            db.atomic_write(Box::new([(key.into(), Some(replacement.clone().into()))].into_iter()))
421
                .unwrap();
422
            db.0.flush().unwrap();
423
        }
424
        {
425
            let db = RocksDB::new(dir.path()).unwrap();
426
            assert_eq!(db.get(key.into()).await.unwrap().unwrap().as_ref(), replacement);
427
            db.atomic_write(Box::new([(key.into(), None)].into_iter())).unwrap();
428
            db.0.flush().unwrap();
429
        }
430
        let db = RocksDB::new(dir.path()).unwrap();
431
        assert!(db.get(key.into()).await.unwrap().is_none());
432
    }
433
434
    #[test(tokio::test)]
435
    async fn sorted_mapping_store_updates_existing_max_key() {
436
        let store = SortedMappingStore::new(MemDB::new(), 4.try_into().unwrap())
437
            .await
438
            .unwrap();
439
        assert!(store.append(7_u64, 10_u64).await.unwrap());
440
        assert!(!store.append(7, 12).await.unwrap());
441
        assert_eq!(store.get(7).await.unwrap(), Some(10));
442
        assert!(store.append_or_update_max(7, 11).await.unwrap());
443
        assert!(!store.append_or_update_max(6, 12).await.unwrap());
444
        assert_eq!(store.get(6).await.unwrap(), None);
445
        assert_eq!(store.max_key().await, Some(7));
446
        assert_eq!(store.get(7).await.unwrap(), Some(11));
447
        assert_eq!(store.get_le(7).await.unwrap(), Some(11));
448
    }
449
450
    #[test(tokio::test)]
451
    async fn sorted_mapping_rejects_corrupt_reads() {
452
        let db = MemDB::new();
453
        db.atomic_write(Box::new(
454
            [(
455
                SortedMappingStore::<MemDB, u64, u64>::subkey().max(),
456
                Some(Bytes::new()),
457
            )]
458
            .into_iter(),
459
        ))
460
        .unwrap();
461
462
        assert!(
463
            SortedMappingStore::<_, u64, u64>::new(db, 4.try_into().unwrap())
464
                .await
465
                .is_err()
466
        );
467
468
        let db = Arc::new(MemDB::new());
469
        let store = SortedMappingStore::new(db.clone(), 4.try_into().unwrap())
470
            .await
471
            .unwrap();
472
        assert!(store.append(7_u64, 10_u64).await.unwrap());
473
474
        let key_raw: Bytes = encode_object(&7_u64).into();
475
        db.atomic_write(Box::new(
476
            [(
477
                SortedMappingStore::<Arc<MemDB>, u64, u64>::subkey().value(&key_raw),
478
                Some(Bytes::new()),
479
            )]
480
            .into_iter(),
481
        ))
482
        .unwrap();
483
484
        let reopened: SortedMappingStore<_, u64, u64> =
485
            SortedMappingStore::new(db, 4.try_into().unwrap()).await.unwrap();
486
        assert!(reopened.get(7_u64).await.is_err());
487
    }
488
}