actordb 0.4.0

An actor-first distributed database
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
// Copyright (c) Sean Lawlor
//
// This source code is licensed under both the MIT license found in the
// LICENSE-MIT file in the root directory of this source tree.

//! Pluggable durable storage for a node's local copy.
//!
//! A [`KvStore`](crate::store::KvStore) persists the batches the [`KvActor`](crate::actor::KvActor)
//! flushes and reloads them on boot. Persistence itself is optional:
//! `NodeConfig.store` is `None` by default (a pure in-memory node).
//! The default crate includes no bundled storage backend. Enable the `rocksdb`
//! cargo feature for `RocksDbStore`, the preferred durable implementation, or
//! bring your own. The independent `memory` feature enables `MemoryStore`, a
//! no-op test double for exercising the persistence plumbing without a database.
//! Neither feature enables the other; the Node's volatile actor cache does not
//! depend on either backend.
//!
//! Shared contracts, identities, capability types, and backend options live in
//! this parent module. Each implementation has its own child module:
//! the `memory` feature enables the test double in `store::memory`,
//! [`prefixed`](crate::store::prefixed) contains the fragment namespace adapter,
//! and the `rocksdb` feature enables the offered RocksDB
//! backend in `store::rocksdb`. Existing `store::*` type imports remain available
//! through reexports when the corresponding feature is enabled. `KvStore` and
//! the `PrefixedStore` namespace wrapper remain available without a backend.
//!
//! A caller-supplied store works with the backend-free default crate:
//!
//! ```
//! use actordb::{actor::VersionedValue, shard::FragmentId};
//! use actordb::store::{KvStore, PrefixedStore, StoreError};
//! use std::sync::Arc;
//!
//! struct CallerStore;
//! impl KvStore for CallerStore {
//!     fn load(&self) -> Result<Vec<(Vec<u8>, VersionedValue)>, StoreError> {
//!         Ok(Vec::new())
//!     }
//!     fn write_batch(&self, _: &[(Vec<u8>, VersionedValue)]) -> Result<(), StoreError> {
//!         Ok(())
//!     }
//! }
//! let root: Arc<dyn KvStore> = Arc::new(CallerStore);
//! let fragment = PrefixedStore::new(root, FragmentId(0));
//! assert!(fragment.load().unwrap().is_empty());
//! ```

#![cfg_attr(
    not(feature = "memory"),
    doc = r#"
Without `memory`, neither the root reexport nor the backend module is public:

```compile_fail
use actordb::store::MemoryStore;
```

```compile_fail
use actordb::store::memory::MemoryStore;
```
"#
)]
#![cfg_attr(
    not(feature = "rocksdb"),
    doc = r#"
Without `rocksdb`, neither the root reexport nor the backend module is public:

```compile_fail
use actordb::store::RocksDbStore;
```

```compile_fail
use actordb::store::rocksdb::RocksDbStore;
```

RocksDB open options and Node's convenience constructor are also opt-in:

```compile_fail
use actordb::store::RocksDbOptions;
```

```compile_fail
let config = actordb::node::NodeConfig::with_rocksdb("./data/node-a");
```
"#
)]

use std::fmt;

use crate::actor::VersionedValue;

#[cfg(feature = "memory")]
pub mod memory;
pub mod prefixed;
#[cfg(feature = "rocksdb")]
pub mod rocksdb;

#[cfg(feature = "memory")]
pub use memory::MemoryStore;
pub use prefixed::PrefixedStore;
#[cfg(feature = "rocksdb")]
pub use rocksdb::RocksDbStore;

/// Error from a [`KvStore`] operation.
#[derive(Debug)]
pub struct StoreError(pub String);

impl fmt::Display for StoreError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "store error: {}", self.0)
    }
}

impl std::error::Error for StoreError {}

/// Immutable identity of one node's durable keyspace.
///
/// A backend may persist this identity and reject a later attachment under a
/// different node id or fragment count. The offered RocksDB backend does so:
/// changing either value without an explicit migration can otherwise hide
/// prefixed records or mount one node's local copy as another node.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoreIdentity {
    node_id: String,
    fragment_count: u32,
}

impl StoreIdentity {
    /// Describe the node and fragment layout which will use a store.
    pub fn new(node_id: String, fragment_count: u32) -> Self {
        Self {
            node_id,
            fragment_count,
        }
    }

    /// Node id permanently associated with the store.
    pub fn node_id(&self) -> &str {
        &self.node_id
    }

    /// Fragment count permanently associated with the store.
    pub fn fragment_count(&self) -> u32 {
        self.fragment_count
    }
}

/// RAII ownership returned when a node attaches to a store.
///
/// Custom stores receive an unmanaged binding from the default
/// [`KvStore::bind`] implementation. RocksDB uses a managed binding to reject
/// two live nodes sharing one `RocksDbStore` instance; dropping the binding
/// releases that in-process attachment.
pub struct StoreBinding {
    release: Option<Box<dyn FnOnce() + Send + Sync>>,
}

impl StoreBinding {
    /// Construct a binding with no release action.
    ///
    /// This is useful to custom stores which validate [`StoreIdentity`] but do
    /// not need to own an exclusive backend lease.
    pub fn unmanaged() -> Self {
        Self { release: None }
    }

    /// Construct a binding which runs `release` when it is cleanly dropped.
    ///
    /// Custom stores can use this to own an exclusive backend lease for the
    /// lifetime of a Node. actordb may deliberately abandon the callback when
    /// asynchronous Node startup or shutdown is cancelled and actor exit
    /// cannot be proven; in that case the callback and its captures are
    /// retained until process exit so an RAII capture cannot release the lease.
    pub fn managed(release: impl FnOnce() + Send + Sync + 'static) -> Self {
        Self {
            release: Some(Box::new(release)),
        }
    }

    /// Keep a managed backend fail-closed when its owner cannot prove that all
    /// users have stopped. Explicit Node shutdown releases normally; best-
    /// effort Drop abandons the release callback so the same live store object
    /// cannot be reattached while old actor tasks may still be exiting.
    pub(crate) fn abandon(&mut self) {
        if let Some(release) = self.release.take() {
            // Dropping a closure also drops its captures. A custom backend may
            // capture an RAII lease whose Drop releases exclusivity, so merely
            // discarding the callback would violate fail-closed cancellation.
            // There is no later join point which can safely release it; retain
            // the lease until process exit instead.
            std::mem::forget(release);
        }
    }
}

impl Drop for StoreBinding {
    fn drop(&mut self) {
        if let Some(release) = self.release.take() {
            release();
        }
    }
}

/// Whether a backend opts into lifecycle-driven prefix deletion.
///
/// The conservative default is [`PrefixDeleteSupport::Retained`], so adding
/// prefix retirement to actordb does not cause existing custom stores to begin
/// receiving destructive calls without an explicit implementation choice.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PrefixDeleteSupport {
    /// Keep retired namespaces. Callers must not invoke `delete_prefix` as an
    /// automatic lifecycle side effect.
    #[default]
    Retained,
    /// The backend supports quiesced, durable, idempotent prefix deletion.
    Supported,
}

/// Crash durability of an ordinary successful [`KvStore::write_batch`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StoreCommitDurability {
    /// The backend accepted the write, but an operating-system crash or power
    /// loss may still discard it.
    #[default]
    Buffered,
    /// The backend reports that the write reached its stable crash-recovery
    /// boundary before returning.
    Stable,
}

/// Result of asking a store to remove a key prefix.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PrefixDeleteOutcome {
    /// The prefix was durably made invisible before the call returned.
    Deleted,
    /// The backend retained the prefix. This is the source-compatible default
    /// for custom stores which have not implemented durable namespace cleanup.
    Retained,
}

/// Durable storage for a node's local copy.
///
/// Implementors must be thread-safe: different fragments can call the same
/// underlying store concurrently. `load` runs once at actor boot;
/// `write_batch` runs on every flush (see `NodeConfig` flush knobs). Writes
/// for one fragment are serialized, but a batch may be retried after a
/// failure or cancelled caller, so implementations must be idempotent for
/// the same key/value versions.
///
/// Keys are raw bytes. The actor works with `String` keys and converts
/// at the boundary (failing boot loudly on non-UTF-8 data); the store
/// itself never interprets key bytes.
///
/// Every persisted [`VersionedValue::node_id`] must contain 1 to 255 UTF-8
/// bytes. Node actors validate this before calling the store and again while
/// loading, but direct store users must preserve the same invariant.
pub trait KvStore: Send + Sync + 'static {
    /// Attach one live node and validate any backend identity metadata.
    ///
    /// The default is an unmanaged no-op so existing custom implementations
    /// remain source compatible. Backends which persist multiple fragment
    /// namespaces should bind at least the node id and fragment count. A
    /// wrapper around a backend which requires binding must delegate this call
    /// and retain the returned ownership rather than relying on the default.
    fn bind(&self, _identity: &StoreIdentity) -> Result<StoreBinding, StoreError> {
        Ok(StoreBinding::unmanaged())
    }
    /// Load the full backing dataset. The default [`KvStore::load_prefix`]
    /// implementation filters this result during fragment startup.
    fn load(&self) -> Result<Vec<(Vec<u8>, VersionedValue)>, StoreError>;
    /// Load only keys beginning with `prefix`. The default preserves source
    /// compatibility for custom stores; stores with ordered iteration should
    /// override it to avoid a full-dataset scan for every fragment.
    fn load_prefix(&self, prefix: &[u8]) -> Result<Vec<(Vec<u8>, VersionedValue)>, StoreError> {
        Ok(self
            .load()?
            .into_iter()
            .filter(|(key, _)| key.starts_with(prefix))
            .collect())
    }
    /// Crash durability provided by an ordinary successful `write_batch`.
    ///
    /// Existing custom stores default to [`StoreCommitDurability::Buffered`].
    /// A wrapper must delegate this method if the wrapped backend offers a
    /// stronger guarantee.
    fn commit_durability(&self) -> StoreCommitDurability {
        StoreCommitDurability::Buffered
    }
    /// Force writes completed before this call through the backend's strongest
    /// available crash-recovery fence.
    ///
    /// Callers flush actor-side buffers before invoking this method. The default
    /// performs no additional work and reports [`KvStore::commit_durability`],
    /// preserving compatibility for custom stores. Implementations may override
    /// it when an explicit fence can strengthen buffered ordinary writes.
    fn stable_fence(&self) -> Result<StoreCommitDurability, StoreError> {
        Ok(self.commit_durability())
    }
    /// Atomically persist a batch of winning writes. Each key occurs at most
    /// once in a batch.
    fn write_batch(&self, writes: &[(Vec<u8>, VersionedValue)]) -> Result<(), StoreError>;
    /// Declare whether lifecycle code may invoke [`KvStore::delete_prefix`].
    ///
    /// Existing custom stores retain namespaces by default. Opting in promises
    /// that `delete_prefix` obeys its quiescence, durability, and idempotency
    /// contract; the returned [`PrefixDeleteOutcome`] remains authoritative for
    /// each call.
    fn prefix_delete_support(&self) -> PrefixDeleteSupport {
        PrefixDeleteSupport::Retained
    }
    /// Durably remove keys beginning with `prefix` after their writers have
    /// been quiesced. The operation must be idempotent because lifecycle work
    /// can retry it after an ambiguous failure.
    ///
    /// Existing custom stores retain data by default. Returning
    /// [`PrefixDeleteOutcome::Deleted`] promises that a successful reopen will
    /// not expose any pre-call key in this prefix.
    fn delete_prefix(&self, _prefix: &[u8]) -> Result<PrefixDeleteOutcome, StoreError> {
        Ok(PrefixDeleteOutcome::Retained)
    }
}

/// Crash policy for successful RocksDB write batches.
///
/// Both modes keep the write-ahead log enabled. The distinction is whether a
/// successful call forces that log through the operating-system buffer cache.
#[cfg(feature = "rocksdb")]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RocksDbDurability {
    /// Append to the WAL without synchronously flushing it to stable media.
    AsyncWal,
    /// Synchronously flush the WAL before reporting success.
    #[default]
    SyncWal,
}

/// Options used when opening the offered RocksDB backend.
#[cfg(feature = "rocksdb")]
#[derive(Clone, Copy, Debug, PartialEq, Eq, bon::Builder)]
pub struct RocksDbOptions {
    #[builder(default = RocksDbDurability::SyncWal)]
    durability: RocksDbDurability,
    #[builder(default = false, name = legacy_manifest_adoption)]
    adopt_legacy_manifest: bool,
}

#[cfg(feature = "rocksdb")]
impl Default for RocksDbOptions {
    fn default() -> Self {
        Self::builder().build()
    }
}

#[cfg(feature = "rocksdb")]
impl RocksDbOptions {
    /// Select the WAL completion policy for ordinary data batches.
    pub fn with_durability(mut self, durability: RocksDbDurability) -> Self {
        self.durability = durability;
        self
    }

    /// Explicitly allow a non-empty pre-manifest database to be adopted by the
    /// first [`StoreIdentity`] supplied to [`KvStore::bind`].
    ///
    /// This is a one-time migration escape hatch. A legacy database contains
    /// no trustworthy original node id, so callers must verify the path and
    /// take a backup before enabling it. Existing, corrupt, or mismatched
    /// manifests are never overwritten.
    pub fn with_legacy_manifest_adoption(mut self, allow: bool) -> Self {
        self.adopt_legacy_manifest = allow;
        self
    }

    /// Current ordinary-write durability policy.
    pub fn durability(&self) -> RocksDbDurability {
        self.durability
    }

    /// Whether this open may adopt a non-empty pre-manifest database.
    pub fn legacy_manifest_adoption(&self) -> bool {
        self.adopt_legacy_manifest
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn custom_store_capabilities_default_to_retention_and_buffering() {
        struct CustomStore;

        impl KvStore for CustomStore {
            fn load(&self) -> Result<Vec<(Vec<u8>, VersionedValue)>, StoreError> {
                Ok(Vec::new())
            }

            fn write_batch(&self, _writes: &[(Vec<u8>, VersionedValue)]) -> Result<(), StoreError> {
                Ok(())
            }
        }

        let store = CustomStore;
        assert_eq!(store.commit_durability(), StoreCommitDurability::Buffered);
        assert_eq!(
            store.stable_fence().unwrap(),
            StoreCommitDurability::Buffered
        );
        assert_eq!(store.prefix_delete_support(), PrefixDeleteSupport::Retained);
    }
}