/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 > max2 => Self::append_new_max2 (store.as_ref()2 , &mut state2 , key2 , value2 ), |
256 | 3 | Some(max2 ) if key == max2 => { |
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 | | } |