Coverage Report

Created: 2026-09-11 07:47

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
474
    pub fn new() -> Self {
31
474
        Self(RwLock::new(HashMap::new()))
32
474
    }
33
}
34
35
impl Default for MemDB {
36
0
    fn default() -> Self {
37
0
        Self::new()
38
0
    }
39
}
40
41
impl KVStore for MemDB {
42
16.3k
    fn atomic_write<'a>(
43
16.3k
        &'a self, changes: Box<dyn Iterator<Item = (Key, Option<Value>)> + 'a>,
44
16.3k
    ) -> Result<(), KVStoreError> {
45
16.3k
        let mut map = self.0.write();
46
47.5k
        for (k, v) in 
changes16.3k
{
47
47.5k
            match v {
48
47.5k
                Some(v) => {
49
47.5k
                    map.insert(k.consolidated(), v);
50
47.5k
                }
51
2
                None => {
52
2
                    map.remove(&k.consolidated());
53
2
                }
54
            }
55
        }
56
16.3k
        Ok(())
57
16.3k
    }
58
59
24
    fn contains(&self, key: Key) -> StoreFuture<bool> {
60
24
        Box::pin(std::future::ready(self.0.read().contains_key(&key.consolidated())))
61
24
    }
62
63
420k
    fn get(&self, key: Key) -> StoreFuture<Result<Option<Value>, KVStoreError>> {
64
420k
        Box::pin(std::future::ready(Ok(self.0.read().get(&key.consolidated()).cloned())))
65
420k
    }
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
7
    fn atomic_write<'a>(
74
7
        &'a self, changes: Box<dyn Iterator<Item = (Key, Option<Value>)> + 'a>,
75
7
    ) -> Result<(), KVStoreError> {
76
7
        let mut wb = rocksdb::WriteBatchWithTransaction::default();
77
25
        for (k, v) in 
changes7
{
78
25
            match v {
79
25
                Some(v) => wb.put(k.consolidated(), v),
80
0
                None => wb.delete(k.consolidated()),
81
            }
82
        }
83
7
        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
7
        }
92
7
        Ok(())
93
7
    }
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
290
    fn get(&self, key: Key) -> StoreFuture<Result<Option<Value>, KVStoreError>> {
106
290
        let db = self.0.clone();
107
290
        let key = key.consolidated();
108
290
        Box::pin(async move {
109
290
            tokio::task::spawn_blocking(move || -> Result<Option<Value>, KVStoreError> {
110
290
                match db.get(key).context("RocksDB read error.") {
111
290
                    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
290
            })
123
290
            .await
124
290
            .context("RocksDB read task join error.")
?0
125
290
        })
126
290
    }
127
}
128
129
impl RocksDB {
130
    /// Open or create a RocksDB store at `path`.
131
6
    pub fn new(path: &Path) -> Result<Self, KVStoreError> {
132
6
        let mut opts = Options::default();
133
6
        opts.create_if_missing(true);
134
6
        let db = match OptimisticTransactionDB::open(&opts, path).context("RocksDB open error") {
135
6
            Ok(db) => db,
136
0
            Err(error) => {
137
0
                tracing::error!(
138
0
                    path = %path.display(),
139
                    operation = "open",
140
                    class = %LogClass::NodeStorage,
141
                    error = ?error,
142
                    "RocksDB operation failed"
143
                );
144
0
                return Err(error.into());
145
            }
146
        };
147
6
        tracing::info!(path = %
path0
.
display0
(), "RocksDB store opened");
148
6
        Ok(Self(Arc::new(db)))
149
6
    }
150
}
151
152
use lyquor_api::subkey_builder;
153
use lyquor_primitives::{Bytes, decode_object, encode_object};
154
use serde::{Deserialize, Serialize};
155
use std::hash::Hash;
156
157
/// Persistent sorted mapping backed by a Lyquor key-value store.
158
pub struct SortedMappingStore<
159
    S: KVStore,
160
    K: Hash + Eq + Ord + Serialize + for<'a> Deserialize<'a> + Clone,
161
    V: Serialize + for<'a> Deserialize<'a> + Clone,
162
> {
163
    state: Arc<Mutex<SortedMappingState<K, V>>>,
164
    store: Arc<S>,
165
}
166
167
struct SortedMappingState<
168
    K: Hash + Eq + Ord + Serialize + for<'a> Deserialize<'a> + Clone,
169
    V: Serialize + for<'a> Deserialize<'a> + Clone,
170
> {
171
    cache: lru::LruCache<K, V>,
172
    max: Option<K>,
173
    first_idx: u64,
174
    next_idx: u64,
175
}
176
177
subkey_builder!(SortedMappingSubkey(
178
    ([0x00])-value(&[u8]) => Key,
179
    ([0x01])-index(&u64) => Key,
180
    ([0x02])-max() => Key,
181
    ([0x03])-first_idx() => Key,
182
    ([0x04])-next_idx() => Key,
183
));
184
185
impl<
186
    S: KVStore,
187
    K: Hash + Eq + Ord + Serialize + for<'a> Deserialize<'a> + Clone,
188
    V: Serialize + for<'a> Deserialize<'a> + Clone,
189
> SortedMappingStore<S, K, V>
190
{
191
90
    fn subkey() -> &'static SortedMappingSubkey {
192
        static KEY: std::sync::OnceLock<SortedMappingSubkey> = std::sync::OnceLock::new();
193
90
        KEY.get_or_init(|| 
SortedMappingSubkey::new7
(
Value::new7
().
into7
()))
194
90
    }
195
196
    /// Open a sorted mapping view over `store` with an in-memory lookup cache.
197
11
    pub async fn new(store: S, cache_size: std::num::NonZeroUsize) -> Result<Self, KVStoreError> {
198
11
        let 
max10
:
Option<K>10
= match store.get(Self::subkey().max()).await
?0
{
199
2
            Some(raw) => Some(decode_object(&raw).ok_or_else(|| 
anyhow!1
("failed to decode sorted mapping max key"))
?1
),
200
9
            None => None,
201
        };
202
10
        let first_idx: u64 = match store.get(Self::subkey().first_idx()).await
?0
{
203
0
            Some(raw) => decode_object(&raw).ok_or_else(|| anyhow!("failed to decode sorted mapping first index"))?,
204
10
            None => 0,
205
        };
206
10
        let next_idx: u64 = match store.get(Self::subkey().next_idx()).await
?0
{
207
1
            Some(raw) => decode_object(&raw).ok_or_else(|| 
anyhow!0
("failed to decode sorted mapping next index"))
?0
,
208
9
            None => 0,
209
        };
210
211
10
        Ok(Self {
212
10
            state: Arc::new(Mutex::new(SortedMappingState {
213
10
                cache: lru::LruCache::new(cache_size),
214
10
                max,
215
10
                first_idx,
216
10
                next_idx,
217
10
            })),
218
10
            store: Arc::new(store),
219
10
        })
220
11
    }
221
}
222
223
impl<
224
    S: KVStore + 'static,
225
    K: Hash + Eq + Ord + Serialize + for<'a> Deserialize<'a> + Clone + Send + 'static,
226
    V: Serialize + for<'a> Deserialize<'a> + Clone + Send + 'static,
227
> SortedMapping<K, V> for SortedMappingStore<S, K, V>
228
{
229
8
    fn max_key(&self) -> StoreFuture<Option<K>> {
230
8
        let state = self.state.clone();
231
8
        Box::pin(async move { state.lock().await.max.clone() })
232
8
    }
233
234
2
    fn append(&self, key: K, value: V) -> StoreFuture<Result<bool, KVStoreError>> {
235
2
        let state = self.state.clone();
236
2
        let store = self.store.clone();
237
2
        Box::pin(async move {
238
2
            let mut state = state.lock().await;
239
2
            Self::append_new_max(store.as_ref(), &mut state, key, value)
240
2
        })
241
2
    }
242
243
8
    fn append_or_update_max(&self, key: K, value: V) -> StoreFuture<Result<bool, KVStoreError>> {
244
8
        let state = self.state.clone();
245
8
        let store = self.store.clone();
246
8
        Box::pin(async move {
247
8
            let mut state = state.lock().await;
248
8
            match state.max.clone() {
249
2
                Some(
max1
) if key > ma
x1
=>
Self::append_new_max1
(
store.as_ref()1
,
&mut state1
,
key1
,
value1
),
250
1
                Some(max) if key == max => {
251
1
                    let key_raw: Bytes = encode_object(&key).into();
252
1
                    store.atomic_write(Box::new(
253
1
                        [(Self::subkey().value(&key_raw), Some(encode_object(&value).into()))].into_iter(),
254
1
                    ))
?0
;
255
1
                    state.cache.push(key.clone(), value.clone());
256
1
                    Ok(true)
257
                }
258
0
                Some(_) => Ok(false),
259
6
                None => Self::append_new_max(store.as_ref(), &mut state, key, value),
260
            }
261
8
        })
262
8
    }
263
264
17
    fn get_le(&self, key: K) -> StoreFuture<Result<Option<V>, KVStoreError>> {
265
17
        let state = self.state.clone();
266
17
        let store = self.store.clone();
267
17
        Box::pin(async move {
268
17
            let mut state = state.lock().await;
269
17
            let first_idx = state.first_idx;
270
17
            let next_idx = state.next_idx;
271
17
            let Some(
raw8
) = store.get(Self::subkey().index(&first_idx)).await
?0
else {
272
9
                return Ok(None);
273
            };
274
8
            let mut l_key = decode_object(&raw).ok_or_else(|| 
anyhow!0
("failed to decode sorted mapping index key"))
?0
;
275
8
            if key < l_key {
276
0
                return Ok(None);
277
8
            }
278
8
            let mut l = first_idx;
279
8
            let mut r = next_idx;
280
10
            while r > l + 1 {
281
2
                let mid = (l + r) >> 1;
282
2
                let Some(raw) = store.get(Self::subkey().index(&mid)).await
?0
else {
283
0
                    return Ok(None);
284
                };
285
2
                let mid_key =
286
2
                    decode_object(&raw).ok_or_else(|| 
anyhow!0
("failed to decode sorted mapping index key"))
?0
;
287
2
                if key < mid_key {
288
1
                    r = mid;
289
1
                } else {
290
1
                    l = mid;
291
1
                    l_key = mid_key;
292
1
                }
293
            }
294
8
            Self::get_locked(store, &mut state, l_key).await
295
17
        })
296
17
    }
297
298
2
    fn get(&self, key: K) -> StoreFuture<Result<Option<V>, KVStoreError>> {
299
2
        let state = self.state.clone();
300
2
        let store = self.store.clone();
301
2
        Box::pin(async move {
302
2
            let mut state = state.lock().await;
303
2
            Self::get_locked(store, &mut state, key).await
304
2
        })
305
2
    }
306
}
307
308
impl<
309
    S: KVStore + 'static,
310
    K: Hash + Eq + Ord + Serialize + for<'a> Deserialize<'a> + Clone + Send + 'static,
311
    V: Serialize + for<'a> Deserialize<'a> + Clone + Send + 'static,
312
> SortedMappingStore<S, K, V>
313
{
314
9
    fn append_new_max(store: &S, state: &mut SortedMappingState<K, V>, key: K, value: V) -> Result<bool, KVStoreError> {
315
9
        if state.max.clone().is_some_and(|max| 
key1
<=
max1
) {
316
0
            return Ok(false);
317
9
        }
318
319
9
        let idx = state.next_idx;
320
9
        let next_idx = idx + 1;
321
9
        let key_raw: Bytes = encode_object(&key).into();
322
9
        let mapping = [
323
9
            (Self::subkey().value(&key_raw), Some(encode_object(&value).into())),
324
9
            (Self::subkey().index(&idx), Some(key_raw.clone())),
325
9
            (Self::subkey().max(), Some(key_raw)),
326
9
            (Self::subkey().next_idx(), Some(encode_object(&next_idx).into())),
327
9
        ]
328
9
        .into_iter();
329
9
        store.atomic_write(Box::new(mapping))
?0
;
330
9
        state.next_idx = next_idx;
331
9
        state.cache.push(key.clone(), value);
332
9
        state.max = Some(key);
333
9
        Ok(true)
334
9
    }
335
336
10
    async fn get_locked(
337
10
        store: Arc<S>, state: &mut SortedMappingState<K, V>, key: K,
338
10
    ) -> Result<Option<V>, KVStoreError> {
339
10
        if let Some(
value9
) = state.cache.get(&key).cloned() {
340
9
            return Ok(Some(value));
341
1
        }
342
343
1
        let Some(raw) = store.get(Self::subkey().value(&encode_object(&key))).await
?0
else {
344
0
            return Ok(None);
345
        };
346
1
        let 
value0
:
V0
= decode_object(&raw).ok_or_else(|| anyhow!("failed to decode sorted mapping value"))?;
347
0
        state.cache.push(key.clone(), value.clone());
348
0
        Ok(Some(value))
349
10
    }
350
}
351
352
#[cfg(test)]
353
mod tests {
354
    use std::sync::Arc;
355
356
    use super::SortedMappingStore;
357
    use crate::MemDB;
358
    use lyquor_api::store::{KVStore, SortedMapping};
359
    use lyquor_primitives::{Bytes, encode_object};
360
    use lyquor_test::test;
361
362
    #[test(tokio::test)]
363
    async fn sorted_mapping_store_updates_existing_max_key() {
364
        let store = SortedMappingStore::new(MemDB::new(), 4.try_into().unwrap())
365
            .await
366
            .unwrap();
367
        assert!(store.append(7_u64, 10_u64).await.unwrap());
368
        assert!(store.append_or_update_max(7, 11).await.unwrap());
369
        assert_eq!(store.max_key().await, Some(7));
370
        assert_eq!(store.get(7).await.unwrap(), Some(11));
371
        assert_eq!(store.get_le(7).await.unwrap(), Some(11));
372
    }
373
374
    #[test(tokio::test)]
375
    async fn sorted_mapping_rejects_corrupt_reads() {
376
        let db = MemDB::new();
377
        db.atomic_write(Box::new(
378
            [(
379
                SortedMappingStore::<MemDB, u64, u64>::subkey().max(),
380
                Some(Bytes::new()),
381
            )]
382
            .into_iter(),
383
        ))
384
        .unwrap();
385
386
        assert!(
387
            SortedMappingStore::<_, u64, u64>::new(db, 4.try_into().unwrap())
388
                .await
389
                .is_err()
390
        );
391
392
        let db = Arc::new(MemDB::new());
393
        let store = SortedMappingStore::new(db.clone(), 4.try_into().unwrap())
394
            .await
395
            .unwrap();
396
        assert!(store.append(7_u64, 10_u64).await.unwrap());
397
398
        let key_raw: Bytes = encode_object(&7_u64).into();
399
        db.atomic_write(Box::new(
400
            [(
401
                SortedMappingStore::<Arc<MemDB>, u64, u64>::subkey().value(&key_raw),
402
                Some(Bytes::new()),
403
            )]
404
            .into_iter(),
405
        ))
406
        .unwrap();
407
408
        let reopened: SortedMappingStore<_, u64, u64> =
409
            SortedMappingStore::new(db, 4.try_into().unwrap()).await.unwrap();
410
        assert!(reopened.get(7_u64).await.is_err());
411
    }
412
}