Skip to main content

cute_sqlite_kv/
lib.rs

1//! This crate provides a very small and simple multi-process
2//! persistent key-value store, using `SQLite` for storage.
3//!
4//! The code is intended to be as simple a wrapper around `SQLite`
5//! (via rusqlite) as possible.
6//!
7//! Two stores are provided:
8//!
9//! - [`KVStore`] maps string keys to string values.
10//! - [`BlobStore`] maps string keys to binary (`Vec<u8>`) values.
11//!
12//! Both are thin aliases for the generic [`Store`], which holds all the
13//! logic; only the value type differs. Keys are always `&str`.
14//!
15//! The store can be used from multiple processes, and also opened
16//! multiple times from the same process. File-backed stores use WAL
17//! mode so reads can proceed during a write (see [`Store::new_from_file`]
18//! for the caveats, notably that WAL does not work over network
19//! filesystems).
20//!
21//! While `SQLite` can be very quick, this key-value store is not
22//! intended for high-performance situations, but when you need
23//! something as simple as possible, but still correct. Please feel
24//! free to take, extend, and modify this code for your own requirements!
25//!
26//! # One value type per database file
27//!
28//! A given database file should be used with a single store type. The
29//! value column happily holds whatever you write (text or binary), but
30//! reading a binary value back as a `String` (or a text value as bytes)
31//! fails -- and, per the panic policy below, that failure is a panic.
32//! So pick [`KVStore`] or [`BlobStore`] for a file and stick to it.
33//!
34//! # Errors and panics
35//!
36//! Opening a store (`new_from_file` / `new_in_memory`) returns a
37//! `Result`, because a bad path or permissions is a normal thing a
38//! caller might want to handle.
39//!
40//! Every other operation panics if the underlying `SQLite` call fails.
41//! The reasoning: once the store is open, the only remaining failures
42//! are catastrophic (disk full, corruption, the file vanished) and
43//! there is no sensible recovery. A loud panic is better than a
44//! silently dropped error. Lock contention between processes does
45//! *not* cause a panic: a `busy_timeout` is set so writers wait for
46//! the lock rather than failing.
47//!
48//! # Examples
49//!
50//! ```rust
51//! use cute_sqlite_kv::KVStore;
52//!
53//! let kvstore = KVStore::new_in_memory().unwrap();
54//!
55//! kvstore.insert("key", "value");
56//!
57//! assert_eq!(kvstore.get("key"), Some("value".to_string()));
58//!
59//! kvstore.remove("key");
60//!
61//! assert_eq!(kvstore.get("key"), None);
62//! ```
63//!
64//! Storing binary values with [`BlobStore`]:
65//!
66//! ```rust
67//! use cute_sqlite_kv::BlobStore;
68//!
69//! let store = BlobStore::new_in_memory().unwrap();
70//!
71//! let bytes: &[u8] = &[0, 1, 2, 255];
72//! store.insert("key", bytes);
73//!
74//! assert_eq!(store.get("key"), Some(bytes.to_vec()));
75//! ```
76//!
77//! Creating a file-backed store:
78//!
79//! ```no_run
80//! use cute_sqlite_kv::KVStore;
81//!
82//! let kvstore = KVStore::new_from_file("mydata.db").unwrap();
83//! ```
84use std::marker::PhantomData;
85use std::path::Path;
86use std::time::{Duration, Instant};
87
88use rusqlite::types::FromSql;
89use rusqlite::{Connection, OptionalExtension, ToSql};
90
91const KEY_COLUMN: &str = "KVStore_key";
92const VAL_COLUMN: &str = "KVStore_val";
93const TABLE: &str = "KVStore_table";
94
95/// How long a connection waits for a database lock held by another
96/// connection or process before giving up (and panicking).
97///
98/// Every operation here is a single autocommit statement, so two
99/// writers simply serialise: the loser's busy-handler sleeps and retries
100/// until the winner commits (typically microseconds). This timeout
101/// therefore only matters when some *other* process holds the write lock
102/// for a long time -- a long external transaction, or a hung/crashed
103/// process leaving a stale lock. A genuine deadlock is not affected by
104/// this value: `SQLite` returns `SQLITE_BUSY` immediately in that case
105/// rather than waiting. We pick a generous timeout so a slow but live
106/// lock-holder is tolerated, and only give up (panic) once a wait this
107/// long suggests the holder is never going to release.
108const BUSY_TIMEOUT: Duration = Duration::from_secs(30);
109
110/// Puts a connection's database into WAL mode, robustly under concurrent
111/// cold opens.
112///
113/// Switching a fresh database to WAL needs a brief exclusive lock, and
114/// `SQLite` does *not* apply the connection's `busy_timeout` to a
115/// journal-mode change. So when many processes open the same brand-new
116/// file at once (e.g. lots of instances starting after a reboot), some of
117/// the WAL switches get `SQLITE_BUSY` and would otherwise fail the open.
118/// We retry within the same time budget as `busy_timeout`.
119///
120/// WAL is persistent in the database header, so the common warm-open case
121/// (already WAL) takes the fast path and never contends for the lock.
122fn enable_wal(connection: &Connection) -> rusqlite::Result<()> {
123    let current: String = connection.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
124    if current.eq_ignore_ascii_case("wal") {
125        return Ok(());
126    }
127
128    let deadline = Instant::now() + BUSY_TIMEOUT;
129    let mut backoff = Duration::from_millis(1);
130    loop {
131        match connection.query_row("PRAGMA journal_mode=WAL", [], |row| row.get::<_, String>(0)) {
132            // The resulting mode may not be "wal" on a filesystem that does
133            // not support it (e.g. NFS); that is still a correct store, just
134            // without WAL, so we accept whatever we got.
135            Ok(_) => return Ok(()),
136            Err(e) if is_locked(&e) && Instant::now() < deadline => {
137                std::thread::sleep(backoff);
138                backoff = (backoff * 2).min(Duration::from_millis(50));
139            }
140            Err(e) => return Err(e),
141        }
142    }
143}
144
145/// Whether an error is a `SQLITE_BUSY`/`SQLITE_LOCKED` lock-contention error.
146fn is_locked(error: &rusqlite::Error) -> bool {
147    matches!(
148        error,
149        rusqlite::Error::SqliteFailure(e, _)
150            if matches!(
151                e.code,
152                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
153            )
154    )
155}
156
157/// A type that can be used as the value of a [`Store`].
158///
159/// This ties the owned value type (what [`Store::get`] returns) to its
160/// borrowed form (what [`Store::insert`] accepts). It is implemented for
161/// `String` (borrowed as `str`) and `Vec<u8>` (borrowed as `[u8]`).
162pub trait StoreValue: FromSql {
163    /// The borrowed form accepted by [`Store::insert`]: `str` for
164    /// `String`, `[u8]` for `Vec<u8>`.
165    type Ref: ToSql + ?Sized;
166}
167
168impl StoreValue for String {
169    type Ref = str;
170}
171
172impl StoreValue for Vec<u8> {
173    type Ref = [u8];
174}
175
176/// A simple key-value store backed by `SQLite`, generic over its value
177/// type.
178///
179/// You will normally use one of the aliases [`KVStore`] (string values)
180/// or [`BlobStore`] (binary values) rather than naming `Store` directly.
181pub struct Store<V> {
182    connection: Connection,
183    _marker: PhantomData<fn() -> V>,
184}
185
186/// A key-value store mapping string keys to string values.
187pub type KVStore = Store<String>;
188
189/// A key-value store mapping string keys to binary (`Vec<u8>`) values.
190pub type BlobStore = Store<Vec<u8>>;
191
192impl<V: StoreValue> Store<V> {
193    /// Creates a new in-memory key-value store.
194    ///
195    /// An in-memory key-value store is in practice worse than
196    /// a standard `HashMap` in every way, so the only use of this function
197    /// is for creating a key value store for testing.
198    ///
199    /// # Examples
200    ///
201    /// ```rust
202    /// use cute_sqlite_kv::KVStore;
203    ///
204    /// let kvstore = KVStore::new_in_memory().unwrap();
205    /// ```
206    pub fn new_in_memory() -> rusqlite::Result<Store<V>> {
207        let connection = Connection::open_in_memory()?;
208        connection.busy_timeout(BUSY_TIMEOUT)?;
209        let store = Store {
210            connection,
211            _marker: PhantomData,
212        };
213        store.create_table()?;
214        Ok(store)
215    }
216
217    /// Creates a new store using a file as the storage.
218    ///
219    /// The database is put into WAL (write-ahead logging) mode, so that
220    /// readers can proceed while another connection is writing. This is
221    /// persistent and creates two sidecar files next to the database
222    /// (`<file>-wal` and `<file>-shm`). WAL requires all accessing
223    /// processes to be on the same machine as the file; it is not
224    /// supported on network filesystems such as NFS.
225    ///
226    /// # Arguments
227    ///
228    /// * `filename` - The path to the file used as the storage for the store.
229    ///
230    /// # Examples
231    ///
232    /// ```no_run
233    /// use cute_sqlite_kv::KVStore;
234    ///
235    /// let kvstore = KVStore::new_from_file("mydata.db").unwrap();
236    /// ```
237    pub fn new_from_file(filename: impl AsRef<Path>) -> rusqlite::Result<Store<V>> {
238        let connection = Connection::open(filename)?;
239        connection.busy_timeout(BUSY_TIMEOUT)?;
240        // Enable WAL so reads don't block on a concurrent write. This is a
241        // no-op for in-memory databases, which is why it lives here rather
242        // than in `new_in_memory`.
243        enable_wal(&connection)?;
244        let store = Store {
245            connection,
246            _marker: PhantomData,
247        };
248        store.create_table()?;
249        Ok(store)
250    }
251
252    /// Internal function which ensures the store's table is created
253    fn create_table(&self) -> rusqlite::Result<()> {
254        self.connection.execute(
255            &format!(
256                "CREATE TABLE IF NOT EXISTS {TABLE} (
257                {KEY_COLUMN} varchar PRIMARY KEY UNIQUE NOT NULL,
258                {VAL_COLUMN}
259            )"
260            ),
261            (),
262        )?;
263        Ok(())
264    }
265
266    /// Inserts a key-value pair in the store.
267    /// Overwrites any existing value.
268    ///
269    /// # Arguments
270    ///
271    /// * `key` - The key for the value.
272    /// * `value` - The value to be stored.
273    ///
274    /// # Panics
275    ///
276    /// Panics if the underlying `SQLite` write fails.
277    ///
278    /// # Examples
279    ///
280    /// ```rust
281    /// use cute_sqlite_kv::KVStore;
282    ///
283    /// let kvstore = KVStore::new_in_memory().unwrap();
284    ///
285    /// kvstore.insert("key", "value");
286    /// ```
287    pub fn insert(&self, key: &str, value: &V::Ref) {
288        self.connection
289            .execute(
290                &format!("REPLACE INTO {TABLE} ({KEY_COLUMN}, {VAL_COLUMN}) VALUES (?, ?)"),
291                rusqlite::params![key, value],
292            )
293            .expect("cute-sqlite-kv: insert failed");
294    }
295
296    /// Checks if a particular key is contained in the store.
297    ///
298    /// # Arguments
299    ///
300    /// * `key` - The key to check for existence.
301    ///
302    /// # Panics
303    ///
304    /// Panics if the underlying `SQLite` query fails.
305    ///
306    /// # Examples
307    ///
308    /// ```rust
309    /// use cute_sqlite_kv::KVStore;
310    ///
311    /// let kvstore = KVStore::new_in_memory().unwrap();
312    ///
313    /// kvstore.insert("key", "value");
314    ///
315    /// assert!(kvstore.contains_key("key"));
316    /// assert!(!kvstore.contains_key("nonexistent_key"));
317    /// ```
318    pub fn contains_key(&self, key: &str) -> bool {
319        let exists: i64 = self
320            .connection
321            .query_row(
322                &format!("SELECT EXISTS(SELECT 1 FROM {TABLE} WHERE {KEY_COLUMN} = ?)"),
323                [key],
324                |row| row.get(0),
325            )
326            .expect("cute-sqlite-kv: contains_key query failed");
327        exists != 0
328    }
329
330    /// Retrieves the value for a given key from the store.
331    ///
332    /// # Arguments
333    ///
334    /// * `key` - The key to retrieve the value for.
335    ///
336    /// # Panics
337    ///
338    /// Panics if the underlying `SQLite` query fails.
339    ///
340    /// # Examples
341    ///
342    /// ```rust
343    /// use cute_sqlite_kv::KVStore;
344    ///
345    /// let kvstore = KVStore::new_in_memory().unwrap();
346    ///
347    /// kvstore.insert("key", "value");
348    ///
349    /// assert_eq!(kvstore.get("key"), Some("value".to_string()));
350    /// ```
351    pub fn get(&self, key: &str) -> Option<V> {
352        self.connection
353            .query_row(
354                &format!("SELECT {VAL_COLUMN} FROM {TABLE} WHERE {KEY_COLUMN} = ?"),
355                [key],
356                |row| row.get(0),
357            )
358            .optional()
359            .expect("cute-sqlite-kv: get query failed")
360    }
361
362    /// Removes a key-value pair from the store,
363    /// if present, and returns the old value if it existed.
364    ///
365    /// # Arguments
366    ///
367    /// * `key` - The key to remove.
368    ///
369    /// # Panics
370    ///
371    /// Panics if the underlying `SQLite` write fails.
372    ///
373    /// # Examples
374    ///
375    /// ```rust
376    /// use cute_sqlite_kv::KVStore;
377    ///
378    /// let kvstore = KVStore::new_in_memory().unwrap();
379    ///
380    /// kvstore.insert("key", "value");
381    ///
382    /// assert_eq!(kvstore.remove("key"), Some("value".to_string()));
383    ///
384    /// assert_eq!(kvstore.get("key"), None);
385    ///
386    /// assert_eq!(kvstore.remove("key"), None);
387    /// ```
388    pub fn remove(&self, key: &str) -> Option<V> {
389        self.connection
390            .query_row(
391                &format!("DELETE FROM {TABLE} WHERE {KEY_COLUMN} = ? RETURNING {VAL_COLUMN}"),
392                [key],
393                |row| row.get(0),
394            )
395            .optional()
396            .expect("cute-sqlite-kv: remove failed")
397    }
398
399    /// Clears the entire table in the store.
400    ///
401    /// This method removes all key-value pairs from the table, effectively clearing the entire store.
402    ///
403    /// # Panics
404    ///
405    /// Panics if the underlying `SQLite` write fails.
406    ///
407    /// # Examples
408    ///
409    /// ```rust
410    /// use cute_sqlite_kv::KVStore;
411    ///
412    /// let kvstore = KVStore::new_in_memory().unwrap();
413    ///
414    /// kvstore.insert("key1", "value1");
415    /// kvstore.insert("key2", "value2");
416    ///
417    /// kvstore.clear();
418    ///
419    /// assert_eq!(kvstore.get("key1"), None);
420    /// assert_eq!(kvstore.get("key2"), None);
421    /// ```
422    pub fn clear(&self) {
423        self.connection
424            .execute(&format!("DELETE FROM {TABLE}"), ())
425            .expect("cute-sqlite-kv: clear failed");
426    }
427
428    /// Checks if the store is empty.
429    ///
430    /// Note: Since the store can be used concurrently, the result of this method
431    /// can be out of date almost immediately.
432    ///
433    /// # Panics
434    ///
435    /// Panics if the underlying `SQLite` query fails.
436    ///
437    /// # Examples
438    ///
439    /// ```rust
440    /// use cute_sqlite_kv::KVStore;
441    ///
442    /// let kvstore = KVStore::new_in_memory().unwrap();
443    /// assert!(kvstore.is_empty());
444    ///
445    /// kvstore.insert("key", "value");
446    /// assert!(!kvstore.is_empty());
447    /// ```
448    pub fn is_empty(&self) -> bool {
449        let empty: i64 = self
450            .connection
451            .query_row(
452                &format!("SELECT NOT EXISTS(SELECT 1 FROM {TABLE})"),
453                [],
454                |row| row.get(0),
455            )
456            .expect("cute-sqlite-kv: is_empty query failed");
457        empty != 0
458    }
459
460    /// Returns the number of key-value pairs in the store.
461    ///
462    /// Note: Since the store can be used concurrently, the result of this method
463    /// can be out of date almost immediately.
464    ///
465    /// # Panics
466    ///
467    /// Panics if the underlying `SQLite` query fails.
468    ///
469    /// # Examples
470    ///
471    /// ```rust
472    /// use cute_sqlite_kv::KVStore;
473    ///
474    /// let kvstore = KVStore::new_in_memory().unwrap();
475    /// assert_eq!(kvstore.len(), 0);
476    ///
477    /// kvstore.insert("key1", "value1");
478    /// kvstore.insert("key2", "value2");
479    /// assert_eq!(kvstore.len(), 2);
480    /// ```
481    pub fn len(&self) -> usize {
482        let count: i64 = self
483            .connection
484            .query_row(&format!("SELECT COUNT(*) FROM {TABLE}"), [], |row| {
485                row.get(0)
486            })
487            .expect("cute-sqlite-kv: len query failed");
488        count as usize
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    use tempfile::tempdir;
497
498    #[test]
499    fn test_new_in_memory() {
500        let _ = KVStore::new_in_memory().unwrap();
501    }
502
503    #[test]
504    fn test_new_from_file() {
505        let temp_dir = tempdir().expect("Failed to create temp directory");
506        let filename = temp_dir.path().join("kvstore.db");
507        let _ = KVStore::new_from_file(&filename).unwrap();
508    }
509
510    #[test]
511    fn test_new_from_file_more() {
512        let temp_dir = tempdir().expect("Failed to create temp directory");
513        let filename = temp_dir.path().join("kvstore.db");
514        let kvstore = KVStore::new_from_file(&filename).unwrap();
515        let key = "test_key";
516        let value = "test_value";
517        kvstore.insert(key, value);
518        let result = kvstore.get(key);
519        assert_eq!(result, Some(value.to_string()));
520    }
521
522    #[test]
523    fn test_reopen_database() {
524        let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
525        let filename = temp_dir.path().join("kvstore.db");
526        {
527            let kvstore = KVStore::new_from_file(&filename).unwrap();
528            let key = "test_key";
529            let value = "test_value";
530            kvstore.insert(key, value);
531        }
532        {
533            let kvstore = KVStore::new_from_file(&filename).unwrap();
534            let key = "test_key";
535            let result = kvstore.get(key);
536            assert_eq!(result, Some("test_value".to_string()));
537        }
538    }
539
540    #[test]
541    fn test_insert_and_get() {
542        let kvstore = KVStore::new_in_memory().unwrap();
543        let key = "test_key";
544        let value = "test_value";
545        kvstore.insert(key, value);
546        let result = kvstore.get(key);
547        assert_eq!(result, Some(value.to_string()));
548    }
549
550    #[test]
551    fn test_get_nonexistent_key() {
552        let kvstore = KVStore::new_in_memory().unwrap();
553        let key = "nonexistent_key";
554        let result = kvstore.get(key);
555        assert_eq!(result, None);
556    }
557
558    #[test]
559    fn test_remove() {
560        let kvstore = KVStore::new_in_memory().unwrap();
561        let key = "test_key";
562        let value = "test_value";
563        kvstore.insert(key, value);
564        let old_value = kvstore.remove(key);
565        assert_eq!(old_value, Some(value.to_string()));
566        let result = kvstore.get(key);
567        assert_eq!(result, None);
568    }
569
570    #[test]
571    fn test_remove_nonexistent_key() {
572        let kvstore = KVStore::new_in_memory().unwrap();
573        let key = "nonexistent_key";
574        let old_value = kvstore.remove(key);
575        assert_eq!(old_value, None);
576        let result = kvstore.get(key);
577        assert_eq!(result, None);
578    }
579
580    #[test]
581    fn test_clear() {
582        let kvstore = KVStore::new_in_memory().unwrap();
583        let key = "test_key";
584        let value = "test_value";
585        kvstore.insert(key, value);
586        kvstore.clear();
587        let result = kvstore.get(key);
588        assert_eq!(result, None);
589    }
590
591    #[test]
592    fn test_many_connections() {
593        let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
594        let filename = temp_dir.path().join("kvstore.db");
595
596        // Create the first connection and add a key
597        {
598            let kvstore = KVStore::new_from_file(&filename).unwrap();
599            let key = "test_key";
600            let value = "test_value";
601            kvstore.insert(key, value);
602        }
603
604        // Check if the key is there
605        {
606            let kvstore = KVStore::new_from_file(&filename).unwrap();
607            let key = "test_key";
608            let result = kvstore.get(key);
609            assert_eq!(result, Some("test_value".to_string()));
610        }
611
612        // remove the key
613        {
614            let kvstore = KVStore::new_from_file(&filename).unwrap();
615            let key = "test_key";
616            kvstore.remove(key);
617        }
618
619        // Check if the key is gone
620        {
621            let kvstore = KVStore::new_from_file(&filename).unwrap();
622            let key = "test_key";
623            let result = kvstore.get(key);
624            assert_eq!(result, None);
625        }
626    }
627
628    #[test]
629    fn test_overlapping_connections() {
630        let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
631        let filename = temp_dir.path().join("kvstore.db");
632
633        let kvstore = KVStore::new_from_file(&filename).unwrap();
634
635        // Create the first connection and add a key
636        {
637            let key = "test_key";
638            let value = "test_value";
639            kvstore.insert(key, value);
640        }
641
642        let kvstore2 = KVStore::new_from_file(&filename).unwrap();
643
644        // Check if the key is there
645        {
646            let key = "test_key";
647            let result = kvstore2.get(key);
648            assert_eq!(result, Some("test_value".to_string()));
649        }
650
651        // remove the key
652        {
653            let key = "test_key";
654            kvstore2.remove(key);
655        }
656
657        // Check if the key is gone
658        {
659            let key = "test_key";
660            let result = kvstore.get(key);
661            assert_eq!(result, None);
662        }
663    }
664
665    #[test]
666    fn test_insert_multiple_times() {
667        let kvstore = KVStore::new_in_memory().unwrap();
668        let key = "test_key";
669        let value1 = "test_value1";
670        let value2 = "test_value2";
671        let value3 = "test_value3";
672
673        kvstore.insert(key, value1);
674        let result1 = kvstore.get(key);
675        assert_eq!(result1, Some(value1.to_string()));
676
677        kvstore.insert(key, value2);
678        let result2 = kvstore.get(key);
679        assert_eq!(result2, Some(value2.to_string()));
680
681        kvstore.insert(key, value3);
682        let result3 = kvstore.get(key);
683        assert_eq!(result3, Some(value3.to_string()));
684    }
685
686    #[test]
687    fn test_blob_roundtrip() {
688        let store = BlobStore::new_in_memory().unwrap();
689        // Deliberately not valid UTF-8.
690        let value: &[u8] = &[0u8, 159, 146, 150, 255];
691        store.insert("key", value);
692        assert_eq!(store.get("key"), Some(value.to_vec()));
693        assert_eq!(store.remove("key"), Some(value.to_vec()));
694        assert_eq!(store.get("key"), None);
695    }
696
697    #[test]
698    fn test_file_uses_wal() {
699        let temp_dir = tempdir().expect("Failed to create temp directory");
700        let filename = temp_dir.path().join("kvstore.db");
701        let _store = KVStore::new_from_file(&filename).unwrap();
702
703        // WAL mode is persistent in the database header, so a fresh
704        // connection reports it.
705        let raw = rusqlite::Connection::open(&filename).unwrap();
706        let mode: String = raw
707            .query_row("PRAGMA journal_mode", [], |row| row.get(0))
708            .unwrap();
709        assert_eq!(mode, "wal");
710    }
711
712    #[test]
713    fn test_blob_reopen() {
714        let temp_dir = tempdir().expect("Failed to create temp directory");
715        let filename = temp_dir.path().join("blobstore.db");
716        let value: &[u8] = &[10, 20, 0, 255, 128];
717        {
718            let store = BlobStore::new_from_file(&filename).unwrap();
719            store.insert("key", value);
720        }
721        {
722            let store = BlobStore::new_from_file(&filename).unwrap();
723            assert_eq!(store.get("key"), Some(value.to_vec()));
724        }
725    }
726}