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
// Copyright 2026 James Gober. Licensed under Apache-2.0.
//! Database builder.
use std::path::PathBuf;
#[cfg(feature = "ttl")]
use std::time::Duration;
use crate::Emdb;
use crate::Result;
/// Builder for constructing an [`Emdb`].
#[derive(Debug, Clone, Default)]
pub struct EmdbBuilder {
pub(crate) path: Option<PathBuf>,
#[cfg(feature = "ttl")]
pub(crate) default_ttl: Option<Duration>,
pub(crate) data_root: Option<PathBuf>,
pub(crate) app_name: Option<String>,
pub(crate) database_name: Option<String>,
pub(crate) enable_range_scans: bool,
pub(crate) flush_policy: crate::FlushPolicy,
/// Optional Linux io_uring `SQPOLL` idle window in milliseconds.
/// `None` (default) keeps the conservative non-SQPOLL submission
/// path. `Some(idle_ms)` opts the journal's per-handle io_uring
/// ring into kernel-side polling: the kernel spawns a thread that
/// drains the SQ without requiring `io_uring_enter` syscalls,
/// idling itself after `idle_ms` of no submissions. Linux-only;
/// silently ignored on other platforms.
pub(crate) iouring_sqpoll_idle_ms: Option<u32>,
#[cfg(feature = "encrypt")]
pub(crate) encryption_key: Option<crate::encryption::KeyBytes>,
#[cfg(feature = "encrypt")]
pub(crate) encryption_passphrase: Option<String>,
#[cfg(feature = "encrypt")]
pub(crate) cipher: Option<crate::encryption::Cipher>,
}
impl EmdbBuilder {
/// Create a new builder.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Set the explicit on-disk path. Mutually exclusive with the
/// OS-resolution methods ([`Self::app_name`] / [`Self::database_name`]
/// / [`Self::data_root`]).
#[must_use]
pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
self.path = Some(path.into());
self
}
/// Subfolder name under the OS data root. Default `"emdb"`.
#[must_use]
pub fn app_name(mut self, name: impl Into<String>) -> Self {
self.app_name = Some(name.into());
self
}
/// Database filename. Default `"emdb-default.emdb"`.
#[must_use]
pub fn database_name(mut self, name: impl Into<String>) -> Self {
self.database_name = Some(name.into());
self
}
/// Override the OS data root. Mostly for tests / containers.
#[must_use]
pub fn data_root(mut self, root: impl Into<PathBuf>) -> Self {
self.data_root = Some(root.into());
self
}
/// Set a global default TTL applied to inserts using
/// [`crate::Ttl::Default`].
#[cfg(feature = "ttl")]
#[must_use]
pub fn default_ttl(mut self, ttl: Duration) -> Self {
self.default_ttl = Some(ttl);
self
}
/// Maintain a sorted secondary index alongside the hash index so
/// `Emdb::range(...)` and `Namespace::range(...)` can iterate keys
/// in lexicographic order. Off by default.
///
/// Backed by a lock-free `crossbeam_skiplist::SkipMap` — inserts
/// and range iteration are concurrent-safe without a global
/// shard-wide lock. Cost: one `Vec<u8>` clone of the key per
/// insert plus the skiplist node overhead. Roughly doubles
/// in-memory index size for a typical workload. Calling
/// `range()` without enabling this at open time returns
/// [`crate::Error::InvalidConfig`].
#[must_use]
pub fn enable_range_scans(mut self, enabled: bool) -> Self {
self.enable_range_scans = enabled;
self
}
/// Opt the journal's per-handle io_uring ring into kernel-side
/// `IORING_SETUP_SQPOLL` submission polling. The kernel spawns a
/// polling thread that drains the submission queue without
/// requiring `io_uring_enter` syscalls, sleeping after
/// `idle_ms` of no submissions.
///
/// **When to enable.** Sustained-throughput Linux WAL writers
/// (high-frequency `insert` / `insert_many` loops) where the
/// per-submission syscall overhead is measurable. The kernel
/// thread keeps the queue draining hot; emdb's hot append
/// path stops paying `io_uring_enter` on every batch.
///
/// **Fallback.** If `io_uring_setup(2)` rejects `SQPOLL`
/// (kernel < 5.13 without `CAP_SYS_NICE`, sandboxed containers,
/// EPERM), fsys cleanly falls back to non-SQPOLL `pwrite +
/// fdatasync` — same durability contract, slower path. No
/// panic, no hang.
///
/// **Platforms.** Linux-only. macOS and Windows ignore the
/// value (no io_uring on those platforms).
///
/// Default `None` preserves the pre-0.9.7 conservative
/// non-SQPOLL submission path.
#[must_use]
pub fn iouring_sqpoll(mut self, idle_ms: u32) -> Self {
self.iouring_sqpoll_idle_ms = Some(idle_ms);
self
}
/// Set the flush policy.
///
/// Default is [`crate::FlushPolicy::OnEachFlush`], which makes
/// every `db.flush()` perform its own `fdatasync` (one sync per
/// flush call — the v0.7.x behaviour).
///
/// [`crate::FlushPolicy::Group`] enables the group-commit
/// coordinator: concurrent `flush()` calls fuse into a single
/// `fdatasync`. Pick this for multi-threaded workloads that
/// flush per record. See the `FlushPolicy` documentation for
/// the leader-follower protocol and tuning guidance.
#[must_use]
pub fn flush_policy(mut self, policy: crate::FlushPolicy) -> Self {
self.flush_policy = policy;
self
}
/// Enable AES-256-GCM at-rest encryption with a raw 32-byte key.
/// Mutually exclusive with [`Self::encryption_passphrase`].
///
/// The key bytes are wrapped in [`zeroize::Zeroizing`] internally
/// so they clear on drop. The caller is still responsible for
/// zeroizing their own copy of the key after passing it in —
/// this method takes the array by value, so the caller's
/// original is moved here, but a `Copy` wouldn't be (and
/// `[u8; 32]` is `Copy`). Keep the original behind a
/// `Zeroizing<[u8; 32]>` on the caller side if you can.
#[cfg(feature = "encrypt")]
#[must_use]
pub fn encryption_key(mut self, key: [u8; 32]) -> Self {
self.encryption_key = Some(crate::encryption::KeyBytes::from(key));
self
}
/// Enable encryption with a key derived from a UTF-8 passphrase
/// via Argon2id. Mutually exclusive with [`Self::encryption_key`].
#[cfg(feature = "encrypt")]
#[must_use]
pub fn encryption_passphrase(mut self, passphrase: impl Into<String>) -> Self {
self.encryption_passphrase = Some(passphrase.into());
self
}
/// Override the AEAD cipher. Default is AES-256-GCM; reopens
/// inherit the cipher recorded in the file header.
#[cfg(feature = "encrypt")]
#[must_use]
pub fn cipher(mut self, cipher: crate::encryption::Cipher) -> Self {
self.cipher = Some(cipher);
self
}
/// Construct the [`Emdb`].
pub fn build(self) -> Result<Emdb> {
Emdb::from_builder(self)
}
/// Async-context variant of [`Self::build`]. Routes the build
/// through `tokio::task::spawn_blocking` so the file-open and
/// recovery scan don't block the async-task scheduler. Gated
/// behind the `async` feature.
///
/// Returns an [`crate::AsyncEmdb`] handle. Reach for the sync
/// surface via [`crate::AsyncEmdb::sync_handle`] when needed.
#[cfg(feature = "async")]
pub async fn build_async(self) -> Result<crate::AsyncEmdb> {
let emdb = tokio::task::spawn_blocking(move || self.build())
.await
.map_err(|err| {
crate::Error::Io(std::io::Error::other(format!("async join: {err}")))
})??;
Ok(crate::AsyncEmdb::from_sync(emdb))
}
}
#[cfg(test)]
mod tests {
use super::EmdbBuilder;
fn tmp_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0_u128, |d| d.as_nanos());
p.push(format!("emdb-builder-{name}-{nanos}.emdb"));
p
}
fn cleanup(path: &std::path::Path) {
let _ = std::fs::remove_file(path);
if let Some(parent) = path.parent() {
if let Some(stem) = path.file_name().and_then(|n| n.to_str()) {
let _ = std::fs::remove_file(parent.join(format!("{stem}.lock")));
}
}
}
#[test]
fn build_persists_at_explicit_path() {
let path = tmp_path("explicit");
let result = EmdbBuilder::new().path(path.clone()).build();
assert!(result.is_ok(), "build failed: {result:?}");
let _db = result.unwrap_or_else(|err| panic!("{err}"));
drop(_db);
cleanup(&path);
}
}