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
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
//! API-002: general key-value operations on [`Database`].
//!
//! This module is intentionally a thin facade over the database handle and shard
//! actors. Single-key operations route to exactly one owning shard with the
//! database router's stable `BLAKE3(key) % shard_count` convention; `commit`
//! fans out once to every shard and returns each shard's current root hash by
//! shard id. The WAL buffer/tree semantics live in [`crate::shard::actor`], not
//! in this API layer.
//!
//! `range` remains a shard-local `[from, to)` query routed from the lower bound.
//! It does not hide a cross-shard fan-out or global merge; callers that choose a
//! sharded keyspace should keep range prefixes shard-local when they require a
//! sorted result.
use std::collections::BTreeMap;
use std::time::Duration;
use crate::db::helpers::{map_shard_error, range_on_handle};
use crate::db::{Database, DatabaseError, run_indexed_parallel};
use crate::shard::actor::ShardHandle;
use crate::sync::{
Ack, ConsistencyError, ConsistencyMode, SyncNodeId, wait_for_quorum_from_receiver,
};
use crate::tree::Hash;
/// Key bytes used by the general KV API.
///
/// Re-exported from the platform-neutral [`crate::ids`] module so the wasm sync
/// codec and the native KV API share one identical type.
pub use crate::ids::{KvKey, KvValue};
/// One key-value pair returned by [`Database::range`].
pub type KvEntry = (KvKey, KvValue);
/// Sorted shard-local range result returned by [`Database::range`].
pub type KvRange = Vec<KvEntry>;
/// Root hash returned for each shard id after [`Database::commit`].
pub type ShardRoots = BTreeMap<usize, Hash>;
impl Database {
/// Read one key through its owning shard actor.
///
/// The shard actor checks its WAL buffer first and only falls through to the
/// committed tree when the key is not buffered, so an uncommitted put shadows
/// a stale committed value and an uncommitted delete shadows any tree value.
pub fn get(&self, key: &[u8]) -> Result<Option<KvValue>, DatabaseError> {
self.handle_for(key)?
.get(key.to_vec(), self.timeout())
.map_err(map_shard_error)
}
/// Append a single-key put mutation to the owning shard's durable WAL and
/// live WAL buffer.
///
/// This does not flush to the tree; [`Self::commit`] owns that boundary.
pub fn put(&self, key: KvKey, value: KvValue) -> Result<(), DatabaseError> {
self.put_with_consistency(key, value, ConsistencyMode::default())
}
/// Append a single-key put using the requested per-operation consistency
/// policy. Eventual mode preserves the existing local WAL acknowledgment
/// boundary. Strong mode performs the local write first, then waits for
/// quorum acknowledgments supplied by the distribution sync path.
pub fn put_with_consistency(
&self,
key: KvKey,
value: KvValue,
consistency: ConsistencyMode,
) -> Result<(), DatabaseError> {
self.put_with_ttl_and_consistency(key, value, None, consistency)
}
/// Append a single-key put with optional TTL metadata.
///
/// This does not flush to the tree; [`Self::commit`] owns that boundary.
/// TTL writes are always available: the read-time filter makes expired values
/// invisible immediately, while the shard actor's expiry index independently
/// drives physical reclamation at the next known deadline.
pub fn put_with_ttl(
&self,
key: KvKey,
value: KvValue,
ttl: Option<Duration>,
) -> Result<(), DatabaseError> {
self.put_with_ttl_and_consistency(key, value, ttl, ConsistencyMode::default())
}
/// Append a single-key put with optional TTL metadata and per-operation
/// consistency policy.
pub fn put_with_ttl_and_consistency(
&self,
key: KvKey,
value: KvValue,
ttl: Option<Duration>,
consistency: ConsistencyMode,
) -> Result<(), DatabaseError> {
self.handle_for(&key)?
.put_with_ttl(key, value, ttl, self.timeout())
.map_err(map_shard_error)?;
wait_for_consistency(consistency)
}
/// Append a single-key STAMPED TOMBSTONE to the owning shard's durable WAL and
/// live WAL buffer (AA-3-4b).
///
/// A delete is unified into the one stamped write path: it stores a tombstone
/// (a comparable, mergeable, stamped entry that reads as absent), NOT a bare
/// key-removal. Single-node read-after-delete is unchanged (the key reads as
/// `None`), but the delete now persists in the tree so the §2.4 union merge can
/// never resurrect it from a lagging node. The stamp is drawn from this shard's
/// in-memory serve-authority exactly like a put (R-LE / R-SEQ); with no live
/// election it is `(bottom, seq)` (single-node / 2a-compat). For a quorum-
/// replicated, fenced delete use [`Self::replicate_delete`].
///
/// This does not flush to the tree; [`Self::commit`] owns that boundary.
pub fn delete(&self, key: KvKey) -> Result<(), DatabaseError> {
let stamp = self.next_stamp_for_key(&key);
self.handle_for(&key)?
.delete(key, stamp, self.timeout())
.map_err(map_shard_error)
}
/// Read a shard-local `[from, to)` key range in ascending key order.
///
/// The request is routed to the shard owning `from`. Inside that shard, the
/// actor merges committed tree entries with sorted WAL-buffer entries:
/// buffered puts shadow tree entries for the same key, and buffered deletes
/// suppress tree entries. `from >= to` returns an empty result without
/// routing.
pub fn range(&self, from: &[u8], to: &[u8]) -> Result<KvRange, DatabaseError> {
if from >= to {
return Ok(Vec::new());
}
let handle = self.handle_for(from)?;
range_on_handle(&handle, from, to, self.timeout())
}
/// Read a `[from, to)` range from the shard at `shard_id` (by index), in
/// ascending key order. Unlike [`Self::range`] (which routes by the `from`
/// key's hash), this names the shard directly — the primitive a cross-shard
/// enumeration fans out over every shard with. `from >= to` returns empty.
pub fn range_per_shard(
&self,
shard_id: usize,
from: &[u8],
to: &[u8],
) -> Result<KvRange, DatabaseError> {
if from >= to {
return Ok(Vec::new());
}
let handle = self.handle_for_shard(shard_id)?;
range_on_handle(&handle, from, to, self.timeout())
}
/// Append a routed single-key put, co-located by `route_key` (AA-4-1).
///
/// Routes to a shard by hashing `route_key` (via [`Self::handle_for`]) but
/// reads/writes the physical `key` bytes. The owning shard stores the physical
/// key inside `route_key`'s shard, so every routed operation that supplies the
/// SAME `route_key` lands on the SAME shard regardless of the physical key —
/// this is the general-KV generalization of how [`crate::EventStore::append`]
/// co-locates a stream's events by routing on `stream_key`.
///
/// Callers MUST use a stable `route_key` for every record in a co-located
/// family; mixing route keys for the same physical key splits it across shards
/// and breaks read-after-write. Like plain [`Self::put`] this is a local WAL
/// append with no consistency/quorum wait; [`Self::commit`] owns the tree
/// flush.
pub fn put_routed(
&self,
route_key: &[u8],
key: KvKey,
value: KvValue,
) -> Result<(), DatabaseError> {
self.handle_for(route_key)?
.put_with_ttl(key, value, None, self.timeout())
.map_err(map_shard_error)
}
/// Read a routed single key, co-located by `route_key` (AA-4-1).
///
/// Routes to a shard by hashing `route_key` (via [`Self::handle_for`]) but
/// reads the physical `key` bytes from that shard. Mirrors [`Self::get`]'s
/// buffer-before-tree read semantics inside `route_key`'s shard. The caller
/// MUST pass the SAME `route_key` used for the matching [`Self::put_routed`];
/// a different route key routes to a different shard and will not see the
/// value (the co-location guarantee is keyed on `route_key`, exactly as
/// [`crate::EventStore`] co-locates a stream by `stream_key`).
pub fn get_routed(
&self,
route_key: &[u8],
key: &[u8],
) -> Result<Option<KvValue>, DatabaseError> {
self.handle_for(route_key)?
.get(key.to_vec(), self.timeout())
.map_err(map_shard_error)
}
/// Append a routed single-key STAMPED TOMBSTONE, co-located by `route_key`
/// (AA-4-1).
///
/// Routes to a shard by hashing `route_key` (via [`Self::handle_for`]) but
/// deletes the physical `key` inside that shard. Mirrors [`Self::delete`]'s
/// stamped-tombstone path: the stamp is still drawn per physical `key` (R-LE /
/// R-SEQ); only the routing target changes to `route_key`. The caller MUST use
/// the SAME `route_key` as the matching [`Self::put_routed`] so the tombstone
/// lands on the shard that holds the record — the same route-by-key
/// co-location [`crate::EventStore`] relies on for `stream_key`.
pub fn delete_routed(&self, route_key: &[u8], key: KvKey) -> Result<(), DatabaseError> {
let stamp = self.next_stamp_for_key(&key);
self.handle_for(route_key)?
.delete(key, stamp, self.timeout())
.map_err(map_shard_error)
}
/// Read a routed `[from, to)` range, co-located by `route_key` (AA-4-1).
///
/// Routes to a shard by hashing `route_key` (via [`Self::handle_for`]) but
/// scans the physical `[from, to)` range inside that one shard. Mirrors
/// [`Self::range`]'s shard-local merge of committed tree and WAL-buffer
/// entries; `from >= to` returns empty without routing. The scan is
/// deliberately shard-LOCAL within `route_key`'s shard: a co-located record
/// family (all sharing one stable `route_key`) lives entirely on that shard,
/// so this returns the whole family in sorted order. Callers MUST use the SAME
/// `route_key` used to write the family — the same route-by-key co-location
/// [`crate::EventStore`] relies on for `stream_key`.
pub fn range_routed(
&self,
route_key: &[u8],
from: &[u8],
to: &[u8],
) -> Result<KvRange, DatabaseError> {
if from >= to {
return Ok(Vec::new());
}
let handle = self.handle_for(route_key)?;
range_on_handle(&handle, from, to, self.timeout())
}
/// Flush every shard's WAL buffer to its prolly tree and return roots by
/// shard id.
///
/// LAZY MATERIALISATION / GATE 1 — empty-root synthesis. Exactly one `Commit`
/// command is sent to each MATERIALISED shard actor (which applies its buffer
/// as one `batch_mutate`, persists the new root marker, clears the buffer, and
/// replies with its current root). Every shard id in `0..shard_count` that has
/// NOT been materialised holds no data — it would commit to the canonical
/// empty-tree root — so its slot is filled with the store-free
/// [`crate::tree::empty_root_hash`] constant WITHOUT spawning its actor. The
/// returned `ShardRoots` is therefore byte-identical to the eager case (proven
/// by the S0 spike `global_root_identical_eager_vs_synthesised`): a call with
/// no buffered writes still returns one root for every shard.
pub fn commit(&self) -> Result<ShardRoots, DatabaseError> {
let (materialised_ids, handles) = self.materialised_shards();
let timeout = self.timeout();
let results = run_indexed_parallel(handles, |handle: ShardHandle| handle.commit(timeout))?;
// Committed roots for the shards that were live, keyed by their real id.
let mut committed: BTreeMap<usize, Hash> = BTreeMap::new();
for (position, result) in results {
let shard_id = materialised_ids[position];
let hash = result.map_err(map_shard_error)?;
committed.insert(shard_id, hash);
}
// Fill the full `0..shard_count` slot vector: real root where committed,
// synthesised empty root everywhere else (GATE 1).
let empty = crate::tree::empty_root_hash();
let roots = (0..self.shard_count())
.map(|shard_id| (shard_id, committed.get(&shard_id).copied().unwrap_or(empty)))
.collect();
Ok(roots)
}
}
fn wait_for_consistency(consistency: ConsistencyMode) -> Result<(), DatabaseError> {
let ConsistencyMode::Strong(strong) = consistency else {
return Ok(());
};
// DIST-002 defines the per-operation API and quorum wait semantics, but
// DIST-001/DIST-003 own node transfer and topology scheduling. Until that
// sync path feeds remote acknowledgments here, a single-node operation can
// complete with the local durable WAL ack; multi-node strong writes fail
// honestly rather than pretending replication happened.
//
// The ack channel keys on the real node identity `SyncNodeId` (2a-2): the
// quorum primitive is generic over the node id and the live producer is wired
// in 2a-3, so here the sender is still dropped immediately.
let (sender, receiver) = std::sync::mpsc::channel::<Ack<SyncNodeId>>();
let result = wait_for_quorum_from_receiver(strong, &receiver)
.map(drop)
.map_err(|error| map_consistency_error(&error));
drop(sender);
result
}
fn map_consistency_error(error: &ConsistencyError) -> DatabaseError {
DatabaseError::from(error.clone())
}
#[cfg(test)]
mod tests {
use std::error::Error;
use std::path::Path;
use std::time::Duration;
use crate::DatabaseError;
use crate::db::{Database, DatabaseConfig};
use crate::sync::{ConsistencyMode, EventualConsistency, StrongConsistency};
use crate::wal::{DurableWal, OperationType};
fn config_for(path: &Path, shard_count: usize) -> DatabaseConfig {
DatabaseConfig {
data_dir: path.to_path_buf(),
shard_count,
distributed: None,
}
}
fn shard_local_keys(db: &Database, count: usize, from: &[u8]) -> Vec<Vec<u8>> {
let target_shard = db.shard_for(from);
let mut keys = Vec::with_capacity(count);
let mut candidate = 0_u64;
while keys.len() < count {
let key = format!("r:{candidate:010}").into_bytes();
if db.shard_for(&key) == target_shard {
keys.push(key);
}
candidate = candidate.saturating_add(1);
assert!(candidate < 10_000, "failed to find enough shard-local keys");
}
keys
}
fn wal_path(data_dir: &Path, shard_id: usize) -> std::path::PathBuf {
data_dir.join(format!("shard-{shard_id}")).join("shard.wal")
}
#[test]
fn get_routes_to_owner_and_checks_buffer_before_tree() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
let db = Database::create(config_for(&data_dir, 4))?;
let key = b"conversation-state".to_vec();
assert_eq!(db.get(b"missing")?, None);
db.put(key.clone(), b"old".to_vec())?;
db.commit()?;
db.put(key.clone(), b"new".to_vec())?;
assert_eq!(db.get(&key)?, Some(b"new".to_vec()));
Ok(())
}
#[test]
fn get_after_committed_delete_returns_none_even_with_stale_tree_value()
-> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
let db = Database::create(config_for(&data_dir, 4))?;
let key = b"conversation-state".to_vec();
db.put(key.clone(), b"tree-value".to_vec())?;
db.commit()?;
// commit() clears the buffer, so this value can only come from the tree.
assert_eq!(db.get(&key)?, Some(b"tree-value".to_vec()));
// A buffered tombstone must suppress the still-present committed tree value.
db.delete(key.clone())?;
assert_eq!(db.get(&key)?, None);
Ok(())
}
#[test]
fn put_and_delete_buffer_wal_mutations_without_tree_flush() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
let db = Database::create(config_for(&data_dir, 4))?;
let key = b"durable-channel".to_vec();
let shard_id = db.shard_for(&key);
db.put(key.clone(), b"live".to_vec())?;
assert_eq!(db.get(&key)?, Some(b"live".to_vec()));
db.delete(key.clone())?;
assert_eq!(db.get(&key)?, None);
drop(db);
let contents = DurableWal::read_file(wal_path(&data_dir, shard_id))?;
assert_eq!(contents.committed_root(), None);
assert_eq!(contents.entries().len(), 2);
assert_eq!(contents.entries()[0].operation_type(), OperationType::Put);
assert_eq!(contents.entries()[0].key(), key.as_slice());
assert_eq!(contents.entries()[0].value(), Some(b"live".as_slice()));
// ASSERTION CHANGED (AA-3-4b): a delete is now a STAMPED TOMBSTONE — a
// `Put` of the tombstone envelope, NOT a bare `OperationType::Delete`. The
// tombstone is a stamped entry (magic `HMSTMP01`), reads as absent, and
// survives reopen as a committed delete (mergeable, never resurrected).
assert_eq!(contents.entries()[1].operation_type(), OperationType::Put);
assert_eq!(contents.entries()[1].key(), key.as_slice());
let tombstone = contents.entries()[1].value().ok_or("tombstone is a Put")?;
assert!(
tombstone.starts_with(b"HMSTMP01"),
"delete stores a stamped tombstone"
);
let reopened = Database::open(&data_dir)?;
assert_eq!(reopened.get(&key)?, None);
Ok(())
}
#[test]
fn range_merges_tree_and_buffer_entries_in_sorted_order() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
let db = Database::create(config_for(&data_dir, 4))?;
let range_from = b"r:";
let range_to = b"r;";
let mut keys = shard_local_keys(&db, 4, range_from);
keys.sort();
db.put(keys[0].clone(), b"tree-a".to_vec())?;
db.put(keys[1].clone(), b"tree-b".to_vec())?;
db.put(keys[2].clone(), b"tree-c".to_vec())?;
db.commit()?;
db.put(keys[1].clone(), b"buffer-b".to_vec())?;
db.delete(keys[2].clone())?;
db.put(keys[3].clone(), b"buffer-d".to_vec())?;
assert_eq!(
db.range(range_from, range_to)?,
vec![
(keys[0].clone(), b"tree-a".to_vec()),
(keys[1].clone(), b"buffer-b".to_vec()),
(keys[3].clone(), b"buffer-d".to_vec()),
]
);
Ok(())
}
#[test]
fn commit_returns_current_roots_by_shard_and_clears_buffers() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
let db = Database::create(config_for(&data_dir, 4))?;
let empty_roots = db.commit()?;
assert_eq!(empty_roots.len(), 4);
assert_eq!(
empty_roots.keys().copied().collect::<Vec<_>>(),
vec![0, 1, 2, 3]
);
db.put(b"after-empty-commit".to_vec(), b"value".to_vec())?;
let committed_roots = db.commit()?;
assert_eq!(committed_roots.len(), 4);
assert_eq!(db.get(b"after-empty-commit")?, Some(b"value".to_vec()));
let repeated_roots = db.commit()?;
assert_eq!(repeated_roots, committed_roots);
drop(db);
let reopened = Database::open(&data_dir)?;
assert_eq!(
reopened.get(b"after-empty-commit")?,
Some(b"value".to_vec())
);
assert_eq!(reopened.commit()?, committed_roots);
Ok(())
}
#[test]
fn put_with_eventual_consistency_returns_after_local_write() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
let db = Database::create(config_for(&data_dir, 4))?;
let mode = ConsistencyMode::Eventual(EventualConsistency::new(Duration::from_secs(30)));
db.put_with_consistency(b"eventual".to_vec(), b"value".to_vec(), mode)?;
assert_eq!(db.get(b"eventual")?, Some(b"value".to_vec()));
Ok(())
}
#[test]
fn put_with_single_node_strong_consistency_counts_local_ack() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
let db = Database::create(config_for(&data_dir, 4))?;
let mode = ConsistencyMode::Strong(StrongConsistency::new(1, Duration::from_secs(1)));
db.put_with_consistency(b"strong-local".to_vec(), b"value".to_vec(), mode)?;
assert_eq!(db.get(b"strong-local")?, Some(b"value".to_vec()));
Ok(())
}
#[test]
fn different_operations_can_choose_different_consistency_modes() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
let db = Database::create(config_for(&data_dir, 4))?;
db.put_with_consistency(
b"eventual".to_vec(),
b"value".to_vec(),
ConsistencyMode::eventual(Duration::from_secs(30)),
)?;
let strong_result = db.put_with_consistency(
b"strong".to_vec(),
b"value".to_vec(),
ConsistencyMode::strong(3, Duration::from_millis(1)),
);
assert_eq!(db.get(b"eventual")?, Some(b"value".to_vec()));
assert!(matches!(
strong_result,
Err(DatabaseError::ConsistencyError(_))
));
assert_eq!(db.get(b"strong")?, Some(b"value".to_vec()));
Ok(())
}
#[test]
fn put_with_ttl_is_legal_without_ttl_configuration() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
let db = Database::create(config_for(&data_dir, 4))?;
let key = b"temporary".to_vec();
db.put_with_ttl(
key.clone(),
b"value".to_vec(),
Some(Duration::from_secs(60)),
)?;
assert_eq!(db.get(&key)?, Some(b"value".to_vec()));
Ok(())
}
#[test]
fn get_and_range_filter_expired_ttl_entries() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
// Read-time visibility remains authoritative independently of the
// actor-owned expiry index's asynchronous physical reclamation.
let db = Database::create(config_for(&data_dir, 4))?;
let range_from = b"ttl:";
let range_to = b"ttl;";
let mut keys = Vec::new();
let mut candidate = 0_u64;
while keys.len() < 2 {
let key = format!("ttl:{candidate:04}").into_bytes();
if db.shard_for(&key) == db.shard_for(range_from) {
keys.push(key);
}
candidate = candidate.saturating_add(1);
}
keys.sort();
db.put_with_ttl(keys[0].clone(), b"expired".to_vec(), Some(Duration::ZERO))?;
db.put_with_ttl(
keys[1].clone(),
b"live".to_vec(),
Some(Duration::from_secs(60)),
)?;
assert_eq!(db.get(&keys[0])?, None);
assert_eq!(db.get(&keys[1])?, Some(b"live".to_vec()));
assert_eq!(
db.range(range_from, range_to)?,
vec![(keys[1].clone(), b"live".to_vec())]
);
Ok(())
}
#[test]
fn range_per_shard_returns_only_that_shards_keys_and_union_is_complete()
-> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
let db = Database::create(config_for(&data_dir, 3))?;
assert_eq!(db.shard_count(), 3);
// A range covering every test key. All keys share the "k:" prefix and are
// spread across shards by whole-key BLAKE3 routing.
let low = b"k:";
let high = b"k;";
// Put a spread of keys (and one delete) so multiple shards are populated.
let mut put_keys: Vec<Vec<u8>> = Vec::new();
for i in 0..30_u64 {
let key = format!("k:{i:04}").into_bytes();
db.put(key.clone(), format!("v{i}").into_bytes())?;
put_keys.push(key);
}
// A committed-then-deleted key must NOT appear in any shard's range.
let deleted = b"k:deleted".to_vec();
db.put(deleted.clone(), b"gone".to_vec())?;
db.commit()?;
db.delete(deleted.clone())?;
// Confirm the keys really do spread across more than one shard, else this
// test would not exercise per-shard routing.
let distinct_shards: std::collections::BTreeSet<usize> =
put_keys.iter().map(|k| db.shard_for(k)).collect();
assert!(
distinct_shards.len() > 1,
"test keys must span multiple shards (got {distinct_shards:?})"
);
// Per-shard: every returned key belongs to that shard, and the union over
// all shards equals exactly the live key set.
let mut union: std::collections::BTreeSet<Vec<u8>> = std::collections::BTreeSet::new();
for s in 0..db.shard_count() {
let entries = db.range_per_shard(s, low, high)?;
for (key, _value) in &entries {
assert_eq!(
db.shard_for(key),
s,
"key {key:?} surfaced on shard {s} but routes to {}",
db.shard_for(key)
);
assert!(
union.insert(key.clone()),
"key {key:?} appeared in more than one shard's result"
);
}
}
let expected: std::collections::BTreeSet<Vec<u8>> = put_keys.iter().cloned().collect();
assert_eq!(
union, expected,
"union of shard ranges must equal live keys"
);
assert!(!union.contains(&deleted), "deleted key must not appear");
// Empty range [k, k) returns empty on every shard.
for s in 0..db.shard_count() {
assert_eq!(db.range_per_shard(s, low, low)?, Vec::new());
}
// Out-of-range shard id errors cleanly (no panic).
let err = db.range_per_shard(db.shard_count(), low, high);
assert!(err.is_err(), "out-of-range shard_id must error, not panic");
Ok(())
}
#[test]
fn routed_ops_colocate_by_route_key_not_physical_key() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let data_dir = dir.path().join("db");
let db = Database::create(config_for(&data_dir, 3))?;
assert_eq!(db.shard_count(), 3);
// Physical key and a bracketing range for the routed scan.
let physical_key = b"physical:key".to_vec();
let lo = b"physical:";
let hi = b"physical;";
// Find a route_key whose shard differs from the physical key's shard, so
// the test proves routing follows route_key, not the physical key. If they
// matched, a non-routed get could "accidentally" find the value.
let physical_shard = db.shard_for(&physical_key);
let mut route_key: Vec<u8> = Vec::new();
let mut found = false;
for candidate in 0_u64..10_000 {
let candidate_key = format!("route:{candidate:06}").into_bytes();
if db.shard_for(&candidate_key) != physical_shard {
route_key = candidate_key;
found = true;
break;
}
}
assert!(
found,
"must find a route_key on a different shard (test is non-vacuous)"
);
let route_shard = db.shard_for(&route_key);
assert_ne!(
route_shard, physical_shard,
"route_key and physical key must hash to different shards"
);
let value = b"routed-value".to_vec();
db.put_routed(&route_key, physical_key.clone(), value.clone())?;
db.commit()?;
// Routed read finds it (routes to route_key's shard).
assert_eq!(
db.get_routed(&route_key, &physical_key)?,
Some(value.clone())
);
// A NON-routed get(physical_key) routes to shard_for(physical_key) — a
// DIFFERENT shard — so it must NOT see the value. This is the proof that
// co-location is keyed on route_key, not the physical key.
assert_eq!(
db.get(&physical_key)?,
None,
"non-routed get must not find a value co-located by route_key"
);
// The value physically lives on route_key's shard, not the physical key's.
let in_route_shard = db.range_per_shard(route_shard, lo, hi)?;
assert!(
in_route_shard.iter().any(|(k, _)| k == &physical_key),
"value must physically live on route_key's shard"
);
let in_physical_shard = db.range_per_shard(physical_shard, lo, hi)?;
assert!(
!in_physical_shard.iter().any(|(k, _)| k == &physical_key),
"value must NOT live on the physical key's shard"
);
// Routed range finds it and is shard-local to route_key's shard.
let routed_range = db.range_routed(&route_key, lo, hi)?;
assert_eq!(routed_range, vec![(physical_key.clone(), value)]);
// Routed delete removes it.
db.delete_routed(&route_key, physical_key.clone())?;
db.commit()?;
assert_eq!(db.get_routed(&route_key, &physical_key)?, None);
Ok(())
}
}