char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somadd `avril:kv` module78f4748

main
22.5 KiB675 linesraw
1use std::io;
2use std::ops::Bound;
3use std::path::Path;
4use std::sync::{Arc, Mutex, MutexGuard};
5
6use redb::{
7    Database, DatabaseError, ReadOnlyDatabase, ReadableDatabase, ReadableTable,
8    ReadableTableMetadata, TableDefinition, TableHandle,
9};
10
11// Keyspace `name` lives in table "ks:{name}", mapping key bytes to (commit
12// counter, value bytes). The prefix keeps keyspaces apart from META, which
13// holds the commit counter under "" and adapter metadata under any other key.
14// Not "meta": pre-unification Web Storage files have a table by that name.
15const META: TableDefinition<&str, u64> = TableDefinition::new("kv_meta");
16const COUNTER_KEY: &str = "";
17const KEYSPACE_PREFIX: &str = "ks:";
18
19type Bytes = &'static [u8];
20type Entry = (u64, Bytes);
21
22fn keyspace_table_name(name: &str) -> String {
23    format!("{KEYSPACE_PREFIX}{name}")
24}
25
26pub fn stamp_string(counter: u64) -> String {
27    counter.to_string()
28}
29
30pub fn parse_stamp(stamp: &str) -> io::Result<u64> {
31    // Only the canonical rendering parses, so equal stamps are equal strings.
32    // (u64::from_str alone would also accept a leading '+' or zeros.)
33    if !stamp.bytes().all(|b| b.is_ascii_digit()) || (stamp.len() > 1 && stamp.starts_with('0')) {
34        return Err(io::Error::new(
35            io::ErrorKind::InvalidInput,
36            "Invalid kv versionstamp",
37        ));
38    }
39    stamp
40        .parse()
41        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Invalid kv versionstamp"))
42}
43
44pub fn check_keyspace_name(name: &str) -> io::Result<()> {
45    if name.is_empty() {
46        return Err(io::Error::new(
47            io::ErrorKind::InvalidInput,
48            "Keyspace name must not be empty",
49        ));
50    }
51    Ok(())
52}
53
54// "" is the commit counter's slot, so adapter keys cannot touch it.
55fn check_meta_key(key: &str) -> io::Result<()> {
56    if key.is_empty() {
57        return Err(io::Error::new(
58            io::ErrorKind::InvalidInput,
59            "kv metadata keys must be non-empty",
60        ));
61    }
62    Ok(())
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Access {
67    Shared,
68    Exclusive,
69    ReadOnly,
70}
71
72impl Access {
73    pub fn parse(value: &str) -> io::Result<Self> {
74        match value {
75            "shared" => Ok(Access::Shared),
76            "exclusive" => Ok(Access::Exclusive),
77            "read-only" => Ok(Access::ReadOnly),
78            _ => Err(io::Error::new(
79                io::ErrorKind::InvalidInput,
80                "access must be \"shared\", \"exclusive\" or \"read-only\"",
81            )),
82        }
83    }
84
85    pub fn writes(self) -> bool {
86        self != Access::ReadOnly
87    }
88}
89
90enum Held {
91    // Shared mode never holds the file: every operation opens and closes it,
92    // serialized by the store mutex so same-process opens cannot collide.
93    Shared { path: std::path::PathBuf },
94    Exclusive { db: Database },
95    // A missing file stays unopened so read-only never creates anything;
96    // reads then behave as empty.
97    ReadOnly { db: Option<ReadOnlyDatabase> },
98}
99
100// A stored entry: raw value bytes plus the opaque versionstamp of the
101// commit that wrote it.
102pub type StoredEntry = (Vec<u8>, String);
103
104// A listed entry: key plus StoredEntry.
105pub type ListedEntry = (Vec<u8>, Vec<u8>, String);
106
107/// Outcome of a [`Store::write_tx`] closure: commit the transaction or drop
108/// it uncommitted. Aborting changes nothing, including keyspace registration.
109pub enum Decision<T> {
110    Commit(T),
111    Abort(T),
112}
113
114struct StoreInner {
115    // Serializes all operations on this database. Shared mode relies on this
116    // for same-process safety; clones of one Store share the same mutex, so
117    // closures must not re-enter the Store or they will deadlock.
118    mutex: Mutex<()>,
119    held: Held,
120}
121
122fn lock(mutex: &Mutex<()>) -> MutexGuard<'_, ()> {
123    mutex.lock().unwrap_or_else(|error| error.into_inner())
124}
125
126/// Synchronous byte-oriented redb backend. Cheap to clone and share across
127/// threads; every operation runs to completion under one mutex.
128#[derive(Clone)]
129pub struct Store {
130    inner: Arc<StoreInner>,
131}
132
133impl std::fmt::Debug for Store {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("Store").finish_non_exhaustive()
136    }
137}
138
139fn db_error(path: &Path, error: DatabaseError) -> io::Error {
140    match error {
141        DatabaseError::DatabaseAlreadyOpen => io::Error::new(
142            io::ErrorKind::AlreadyExists,
143            format!("kv database is already open: {}", path.display()),
144        ),
145        DatabaseError::Storage(redb::StorageError::Io(error))
146            if error.kind() == io::ErrorKind::NotFound =>
147        {
148            io::Error::new(
149                io::ErrorKind::NotFound,
150                format!("kv database does not exist: {}", path.display()),
151            )
152        }
153        error => io::Error::other(format!("kv database at {}: {error}", path.display())),
154    }
155}
156
157fn is_empty_file(path: &Path) -> bool {
158    std::fs::metadata(path).is_ok_and(|metadata| metadata.len() == 0)
159}
160
161fn create_database(path: &Path) -> Result<Database, DatabaseError> {
162    match Database::create(path) {
163        // A crash between creating the file and initializing it leaves an
164        // empty file; retry from scratch rather than reporting corruption.
165        Err(_) if is_empty_file(path) => {
166            let _ = std::fs::remove_file(path);
167            Database::create(path)
168        }
169        result => result,
170    }
171}
172
173fn open_database(path: &Path, create: bool) -> io::Result<Database> {
174    if let Some(parent) = path.parent()
175        && !parent.as_os_str().is_empty()
176        && !parent.exists()
177    {
178        if !create {
179            return Err(io::Error::new(
180                io::ErrorKind::NotFound,
181                format!("kv database does not exist: {}", path.display()),
182            ));
183        }
184        return Err(io::Error::new(
185            io::ErrorKind::NotFound,
186            format!(
187                "kv database parent directory does not exist: {}",
188                parent.display()
189            ),
190        ));
191    }
192    if path.is_dir() {
193        return Err(io::Error::new(
194            io::ErrorKind::InvalidInput,
195            format!("kv database path is a directory: {}", path.display()),
196        ));
197    }
198    let result = if create {
199        create_database(path)
200    } else if !path.exists() || is_empty_file(path) {
201        return Err(io::Error::new(
202            io::ErrorKind::NotFound,
203            format!("kv database does not exist: {}", path.display()),
204        ));
205    } else {
206        Database::open(path)
207    };
208    result.map_err(|error| db_error(path, error))
209}
210
211// Missing or empty files stay unopened so read-only never creates anything.
212fn read_only_database(path: &Path) -> Result<Option<ReadOnlyDatabase>, DatabaseError> {
213    if !path.exists() || is_empty_file(path) {
214        return Ok(None);
215    }
216    ReadOnlyDatabase::open(path).map(Some)
217}
218
219impl Store {
220    pub fn open(path: &Path, access: Access, create: bool) -> io::Result<Self> {
221        // Lock conflicts surface as DatabaseAlreadyOpen via db_error; redb
222        // locks are non-blocking, so there is no spin or wait here.
223        let held = match access {
224            Access::ReadOnly if create => {
225                return Err(io::Error::new(
226                    io::ErrorKind::InvalidInput,
227                    "read-only kv databases cannot be created",
228                ));
229            }
230            Access::Shared => {
231                // Probe once so open() itself reports missing, locked or
232                // corrupt files instead of deferring the failure to the first
233                // operation.
234                open_database(path, create)?;
235                Held::Shared { path: path.to_owned() }
236            }
237            Access::Exclusive => Held::Exclusive { db: open_database(path, create)? },
238            Access::ReadOnly => match read_only_database(path) {
239                Err(DatabaseError::RepairAborted) => {
240                    return Err(io::Error::other(format!(
241                        "kv database at {} needs repair; open it with write access first",
242                        path.display()
243                    )));
244                }
245                result => Held::ReadOnly {
246                    db: result.map_err(|error| db_error(path, error))?,
247                },
248            },
249        };
250        Ok(Store::new(held))
251    }
252
253    /// Open read-only, repairing an unclean shutdown first. ReadOnlyDatabase
254    /// cannot repair on its own, so a RepairAborted open is followed by
255    /// exactly one writable open (which repairs) and a single read-only
256    /// retry; if the file still needs repair afterwards, that error is
257    /// returned. Missing or empty files open as empty without creating
258    /// anything, like [`Store::open`] with [`Access::ReadOnly`].
259    pub fn open_read_only_repairing(path: &Path) -> io::Result<Self> {
260        let db = match read_only_database(path) {
261            Err(DatabaseError::RepairAborted) => {
262                Database::open(path).map_err(|error| db_error(path, error))?;
263                read_only_database(path)
264            }
265            result => result,
266        };
267        Ok(Store::new(Held::ReadOnly {
268            db: db.map_err(|error| db_error(path, error))?,
269        }))
270    }
271
272    fn new(held: Held) -> Self {
273        Store {
274            inner: Arc::new(StoreInner { mutex: Mutex::new(()), held }),
275        }
276    }
277}
278
279/// Read-only view of one transaction. Constructed only by [`Store::read_tx`].
280pub struct ReadTx<'a> {
281    // None for a database file that does not exist: every read is empty.
282    txn: Option<&'a redb::ReadTransaction>,
283}
284
285/// Read-write view of one transaction. Constructed only by
286/// [`Store::write_tx`]; entry mutations allocate one commit stamp per
287/// transaction, lazily on the first mutation, so a batch of writes shares
288/// exactly one stamp.
289pub struct WriteTx<'a> {
290    txn: &'a redb::WriteTransaction,
291    counter: Option<u64>,
292}
293
294fn commit_counter(meta: &redb::Table<'_, &str, u64>) -> io::Result<u64> {
295    Ok(meta
296        .get(COUNTER_KEY)
297        .map_err(io::Error::other)?
298        .map_or(0, |counter| counter.value()))
299}
300
301// Allocates the transaction's stamp on first use. Entry writes share one
302// counter bump per transaction; metadata-only work never allocates one.
303fn bump_counter(meta: &mut redb::Table<'_, &str, u64>, slot: &mut Option<u64>) -> io::Result<u64> {
304    if let Some(counter) = *slot {
305        return Ok(counter);
306    }
307    let counter = commit_counter(meta)? + 1;
308    meta.insert(COUNTER_KEY, counter)
309        .map_err(io::Error::other)?;
310    *slot = Some(counter);
311    Ok(counter)
312}
313
314fn read_meta(meta: &impl ReadableTable<&'static str, u64>, key: &str) -> io::Result<Option<u64>> {
315    check_meta_key(key)?;
316    Ok(meta
317        .get(key)
318        .map_err(io::Error::other)?
319        .map(|value| value.value()))
320}
321
322// The entry type is dictated by redb's range iterator.
323#[allow(clippy::type_complexity)]
324fn decode_entry(
325    entry: Result<(redb::AccessGuard<'_, Bytes>, redb::AccessGuard<'_, Entry>), redb::StorageError>,
326) -> io::Result<ListedEntry> {
327    let (key, stored) = entry.map_err(io::Error::other)?;
328    let (counter, value) = stored.value();
329    Ok((key.value().to_vec(), value.to_vec(), stamp_string(counter)))
330}
331
332// Exclusive end bound for a prefix: increment the last non-0xff byte. A
333// prefix of all 0xff has no successor, so the range stays unbounded above.
334fn prefix_successor(prefix: &[u8]) -> Option<Vec<u8>> {
335    let mut end = prefix.to_vec();
336    while end.last() == Some(&0xff) {
337        end.pop();
338    }
339    let last = end.last_mut()?;
340    *last += 1;
341    Some(end)
342}
343
344const MAX_LIST: usize = 10_000;
345
346fn open_or_absent<T>(result: Result<T, redb::TableError>) -> io::Result<Option<T>> {
347    match result {
348        Ok(table) => Ok(Some(table)),
349        Err(redb::TableError::TableDoesNotExist(_)) => Ok(None),
350        Err(error) => Err(io::Error::other(error)),
351    }
352}
353
354// The transaction kinds ReadTx and WriteTx read through, so each read is
355// written once. An absent table reads as empty and is never created.
356trait Tables {
357    fn table_names(&self) -> io::Result<Vec<String>>;
358
359    type Table<'t, K: redb::Key + 'static, V: redb::Value + 'static>: ReadableTable<K, V>
360    where
361        Self: 't;
362
363    fn table<K: redb::Key + 'static, V: redb::Value + 'static>(
364        &self,
365        definition: TableDefinition<K, V>,
366    ) -> io::Result<Option<Self::Table<'_, K, V>>>;
367}
368
369impl Tables for Option<&redb::ReadTransaction> {
370    fn table_names(&self) -> io::Result<Vec<String>> {
371        let Some(txn) = self else {
372            return Ok(Vec::new());
373        };
374        let tables = txn.list_tables().map_err(io::Error::other)?;
375        Ok(tables.map(|table| table.name().to_owned()).collect())
376    }
377
378    type Table<'t, K: redb::Key + 'static, V: redb::Value + 'static>
379        = redb::ReadOnlyTable<K, V>
380    where
381        Self: 't;
382
383    fn table<K: redb::Key + 'static, V: redb::Value + 'static>(
384        &self,
385        definition: TableDefinition<K, V>,
386    ) -> io::Result<Option<redb::ReadOnlyTable<K, V>>> {
387        match self {
388            Some(txn) => open_or_absent(txn.open_table(definition)),
389            None => Ok(None),
390        }
391    }
392}
393
394impl Tables for redb::WriteTransaction {
395    type Table<'t, K: redb::Key + 'static, V: redb::Value + 'static> = redb::Table<'t, K, V>;
396
397    fn table_names(&self) -> io::Result<Vec<String>> {
398        let tables = self.list_tables().map_err(io::Error::other)?;
399        Ok(tables.map(|table| table.name().to_owned()).collect())
400    }
401
402    // open_table creates a missing table, so check first: reads, checks and
403    // deletes inside a write transaction must not materialize anything.
404    fn table<K: redb::Key + 'static, V: redb::Value + 'static>(
405        &self,
406        definition: TableDefinition<K, V>,
407    ) -> io::Result<Option<redb::Table<'_, K, V>>> {
408        if !self
409            .table_names()?
410            .iter()
411            .any(|name| name == definition.name())
412        {
413            return Ok(None);
414        }
415        self.open_table(definition)
416            .map(Some)
417            .map_err(io::Error::other)
418    }
419}
420
421fn keyspace_table<'t, T: Tables>(
422    txn: &'t T,
423    keyspace: &str,
424) -> io::Result<Option<T::Table<'t, Bytes, Entry>>> {
425    check_keyspace_name(keyspace)?;
426    txn.table(TableDefinition::new(&keyspace_table_name(keyspace)))
427}
428
429fn get_entry(txn: &impl Tables, keyspace: &str, key: &[u8]) -> io::Result<Option<StoredEntry>> {
430    let Some(table) = keyspace_table(txn, keyspace)? else {
431        return Ok(None);
432    };
433    let Some(entry) = table.get(key).map_err(io::Error::other)? else {
434        return Ok(None);
435    };
436    let (counter, value) = entry.value();
437    Ok(Some((value.to_vec(), stamp_string(counter))))
438}
439
440fn scan(
441    txn: &impl Tables,
442    keyspace: &str,
443    bounds: (Bound<&[u8]>, Bound<&[u8]>),
444    limit: usize,
445    reverse: bool,
446) -> io::Result<Vec<ListedEntry>> {
447    let Some(table) = keyspace_table(txn, keyspace)? else {
448        return Ok(Vec::new());
449    };
450    let range = table.range::<&[u8]>(bounds).map_err(io::Error::other)?;
451    let range: Box<dyn Iterator<Item = _>> = if reverse {
452        Box::new(range.rev())
453    } else {
454        Box::new(range)
455    };
456    range.take(limit).map(decode_entry).collect()
457}
458
459fn entries(txn: &impl Tables, keyspace: &str) -> io::Result<Vec<ListedEntry>> {
460    scan(
461        txn,
462        keyspace,
463        (Bound::Unbounded, Bound::Unbounded),
464        usize::MAX,
465        false,
466    )
467}
468
469fn count(txn: &impl Tables, keyspace: &str) -> io::Result<u64> {
470    keyspace_table(txn, keyspace)?.map_or(Ok(0), |table| table.len().map_err(io::Error::other))
471}
472
473fn list_keyspaces(txn: &impl Tables) -> io::Result<Vec<String>> {
474    let mut names: Vec<String> = txn
475        .table_names()?
476        .into_iter()
477        .filter_map(|name| name.strip_prefix(KEYSPACE_PREFIX).map(str::to_owned))
478        .collect();
479    names.sort();
480    Ok(names)
481}
482
483fn get_meta(txn: &impl Tables, key: &str) -> io::Result<Option<u64>> {
484    txn.table(META)?
485        .map_or(Ok(None), |meta| read_meta(&meta, key))
486}
487
488impl ReadTx<'_> {
489    pub fn get(&self, keyspace: &str, key: &[u8]) -> io::Result<Option<StoredEntry>> {
490        get_entry(&self.txn, keyspace, key)
491    }
492
493    pub fn entries(&self, keyspace: &str) -> io::Result<Vec<ListedEntry>> {
494        entries(&self.txn, keyspace)
495    }
496
497    /// Entries within `[start, end)` and under `prefix`, at most `limit`
498    /// (capped at 10000) of them.
499    #[allow(clippy::too_many_arguments)]
500    pub fn list(
501        &self,
502        keyspace: &str,
503        start: Option<&[u8]>,
504        end: Option<&[u8]>,
505        prefix: Option<&[u8]>,
506        limit: usize,
507        reverse: bool,
508    ) -> io::Result<Vec<ListedEntry>> {
509        // Intersect explicit bounds with the prefix range; the prefix end
510        // is the successor exclusive bound (absent when all bytes are 0xff).
511        let prefix_end = prefix.and_then(prefix_successor);
512        let lower = match (start, prefix) {
513            (Some(a), Some(b)) => Bound::Included(a.max(b)),
514            (Some(a), None) => Bound::Included(a),
515            (None, Some(b)) => Bound::Included(b),
516            (None, None) => Bound::Unbounded,
517        };
518        let upper = match (end, prefix_end.as_deref()) {
519            (Some(a), Some(b)) => Bound::Excluded(a.min(b)),
520            (Some(a), None) => Bound::Excluded(a),
521            (None, Some(b)) => Bound::Excluded(b),
522            (None, None) => Bound::Unbounded,
523        };
524        scan(
525            &self.txn,
526            keyspace,
527            (lower, upper),
528            limit.min(MAX_LIST),
529            reverse,
530        )
531    }
532
533    pub fn count(&self, keyspace: &str) -> io::Result<u64> {
534        count(&self.txn, keyspace)
535    }
536
537    pub fn list_keyspaces(&self) -> io::Result<Vec<String>> {
538        list_keyspaces(&self.txn)
539    }
540
541    pub fn get_meta(&self, key: &str) -> io::Result<Option<u64>> {
542        get_meta(&self.txn, key)
543    }
544}
545
546impl WriteTx<'_> {
547    pub fn get(&self, keyspace: &str, key: &[u8]) -> io::Result<Option<StoredEntry>> {
548        get_entry(self.txn, keyspace, key)
549    }
550
551    pub fn put(&mut self, keyspace: &str, key: &[u8], value: &[u8]) -> io::Result<String> {
552        check_keyspace_name(keyspace)?;
553        let counter = {
554            let mut meta = self.txn.open_table(META).map_err(io::Error::other)?;
555            bump_counter(&mut meta, &mut self.counter)?
556        };
557        let name = keyspace_table_name(keyspace);
558        let mut table = self
559            .txn
560            .open_table(TableDefinition::<Bytes, Entry>::new(&name))
561            .map_err(io::Error::other)?;
562        table
563            .insert(key, (counter, value))
564            .map_err(io::Error::other)?;
565        Ok(stamp_string(counter))
566    }
567
568    pub fn delete(&mut self, keyspace: &str, key: &[u8]) -> io::Result<bool> {
569        let Some(mut table) = keyspace_table(self.txn, keyspace)? else {
570            return Ok(false);
571        };
572        if table.remove(key).map_err(io::Error::other)?.is_none() {
573            return Ok(false);
574        }
575        drop(table);
576        let mut meta = self.txn.open_table(META).map_err(io::Error::other)?;
577        bump_counter(&mut meta, &mut self.counter)?;
578        Ok(true)
579    }
580
581    /// Empty the keyspace, materializing it if it did not exist.
582    pub fn clear(&mut self, keyspace: &str) -> io::Result<()> {
583        check_keyspace_name(keyspace)?;
584        let name = keyspace_table_name(keyspace);
585        let definition = TableDefinition::<Bytes, Entry>::new(&name);
586        self.txn
587            .delete_table(definition)
588            .map_err(io::Error::other)?;
589        self.txn.open_table(definition).map_err(io::Error::other)?;
590        Ok(())
591    }
592
593    pub fn entries(&self, keyspace: &str) -> io::Result<Vec<ListedEntry>> {
594        entries(self.txn, keyspace)
595    }
596
597    pub fn count(&self, keyspace: &str) -> io::Result<u64> {
598        count(self.txn, keyspace)
599    }
600
601    pub fn list_keyspaces(&self) -> io::Result<Vec<String>> {
602        list_keyspaces(self.txn)
603    }
604
605    pub fn get_meta(&self, key: &str) -> io::Result<Option<u64>> {
606        get_meta(self.txn, key)
607    }
608
609    pub fn put_meta(&mut self, key: &str, value: u64) -> io::Result<()> {
610        check_meta_key(key)?;
611        let mut meta = self.txn.open_table(META).map_err(io::Error::other)?;
612        meta.insert(key, value).map_err(io::Error::other)?;
613        Ok(())
614    }
615
616    /// This transaction's commit stamp, if an entry mutation allocated one.
617    /// Check-only work leaves it empty; entry writes share exactly one stamp.
618    pub fn stamp(&self) -> Option<String> {
619        self.counter.map(stamp_string)
620    }
621
622    /// Allocate the commit stamp without writing an entry, for commits that
623    /// must carry a stamp even though no entry changed.
624    pub fn allocate_stamp(&mut self) -> io::Result<String> {
625        let mut meta = self.txn.open_table(META).map_err(io::Error::other)?;
626        Ok(stamp_string(bump_counter(&mut meta, &mut self.counter)?))
627    }
628}
629
630impl Store {
631    /// Run read-only work in one read transaction.
632    pub fn read_tx<T>(&self, work: impl FnOnce(&ReadTx) -> io::Result<T>) -> io::Result<T> {
633        let _guard = lock(&self.inner.mutex);
634        let mut opened = None;
635        let txn = match &self.inner.held {
636            Held::Shared { path } => match open_database(path, false) {
637                Ok(db) => Some(opened.insert(db).begin_read()),
638                // A shared file removed since open reads as empty, like a
639                // missing read-only file.
640                Err(error) if error.kind() == io::ErrorKind::NotFound => None,
641                Err(error) => return Err(error),
642            },
643            Held::Exclusive { db } => Some(db.begin_read()),
644            Held::ReadOnly { db } => db.as_ref().map(ReadableDatabase::begin_read),
645        };
646        let txn = txn.transpose().map_err(io::Error::other)?;
647        work(&ReadTx { txn: txn.as_ref() })
648    }
649
650    /// Run read-write work in one write transaction, committing on
651    /// [`Decision::Commit`] and dropping everything uncommitted on
652    /// [`Decision::Abort`].
653    pub fn write_tx<T>(
654        &self,
655        work: impl FnOnce(&mut WriteTx) -> io::Result<Decision<T>>,
656    ) -> io::Result<T> {
657        let _guard = lock(&self.inner.mutex);
658        let mut opened = None;
659        let db = match &self.inner.held {
660            Held::Shared { path } => &*opened.insert(open_database(path, false)?),
661            Held::Exclusive { db } => db,
662            Held::ReadOnly { .. } => {
663                return Err(io::Error::new(
664                    io::ErrorKind::PermissionDenied,
665                    "kv database is open read-only",
666                ));
667            }
668        };
669        let txn = db.begin_write().map_err(io::Error::other)?;
670        match work(&mut WriteTx { txn: &txn, counter: None })? {
671            Decision::Commit(value) => txn.commit().map_err(io::Error::other).map(|()| value),
672            Decision::Abort(value) => Ok(value),
673        }
674    }
675}