diff --git a/docs/v0.3.md b/docs/v0.3.md new file mode 100644 index 0000000..5b6ad5f --- /dev/null +++ b/docs/v0.3.md @@ -0,0 +1,105 @@ +# Grossular 0.3 + +Grossular 0.3 replaces the initial record header with Tsavorite's two packed +metadata words and uses the first record-state flag to implement deletion with +tombstones. + +## Record layout + +The header remains 16 bytes: + +```text +┌───────────────────────────┬───────────────────────────┐ +│ RecordInfo: 8 bytes │ RecordDataHeader: 8 bytes │ +└───────────────────────────┴───────────────────────────┘ +│ key bytes │ value bytes │ alignment padding │ +``` + +`RecordInfo` stores the previous logical address and record state: + +```text +63 53 52 51 50 49 48 47 0 +┌─────────┬───────┬────────┬───────┬─────┬────┬────────────────┐ +│reserved │sealed │modified│version│valid│tomb│ previous addr │ +└─────────┴───────┴────────┴───────┴─────┴────┴────────────────┘ +``` + +`RecordDataHeader` describes the payload: + +```text +63 56 55 48 47 24 23 14 13 6 5 0 +┌─────────┬───────────┬─────────────────┬───────────┬────────────┬────────┐ +│namespace│record type│ value length │ key length│filler words│ flags │ +└─────────┴───────────┴─────────────────┴───────────┴────────────┴────────┘ +``` + +Only inline byte keys and values are supported. The inline flags tell the +decoder that the actual bytes follow the header rather than living in external +object storage. Keys are limited to 1,022 bytes and values to 16,777,214 bytes +until overflow storage is implemented. + +## Tombstones + +Delete is represented by appending another record, not removing old bytes: + +```mermaid +flowchart LR + I[index entry] --> T["foo tombstone"] + T --> B["bar = value"] + B --> O["foo = old value"] +``` + +`Store::get` stops at the first matching key. If that record is a tombstone, the +key is absent; it must not continue to an older value. + +`Store::delete`: + +```text +find the newest matching record +if missing or already tombstoned, return false +append a tombstone whose previous address is the current tag-chain head +replace the index head and return true +``` + +The tombstone links to the tag-chain head rather than directly to the matching +record so colliding keys between them remain reachable. A later upsert appends a +normal value above the tombstone and makes the key visible again. + +## What changed from v0.2 + +- The record header still occupies 16 bytes, but now carries a Tsavorite-shaped + inline subset of its layout and state fields. +- Record lengths are derived from `RecordDataHeader` and include alignment and + filler capacity. +- `RecordRef` exposes semantic accessors for previous address and tombstone + state; record extent is derived internally from `RecordDataHeader`. +- `Log` can append value records and tombstone records. +- `Store` now supports `delete` and avoids decoding the matching record twice. +- The cache-line hash index and global append-only log are otherwise unchanged. + +## Invariants + +1. `RecordInfo` and `RecordDataHeader` are each exactly 8 bytes. +2. Record state bits never overlap the 48-bit previous-address field. +3. A newly appended tombstone contains a key and no value; a future in-place + tombstone may retain its old allocation as filler. +4. An empty value record is not a tombstone. +5. A matching tombstone hides every older value for that key. +6. Record extent remains 8-byte aligned and includes explicit filler words. + +## Completion + +Unit tests cover both packed words, exact bit positions, inline limits, aligned +record lengths, filler, value/tombstone round trips, deletion, reinsertion, and +colliding keys. The reference-model suite mixes get, upsert, and delete across +1, 16, and 1,024 index buckets and compares Grossular with `HashMap`. + +## Deferred + +- overflow key and value storage; +- in-place updates and filler consumption; +- mutable and read-only log regions; +- fixed-size pages; +- atomic valid/sealed transitions; +- checkpoint versions; +- expiration, ETags, record types, and namespaces. diff --git a/tests/store_correctness.rs b/tests/store_correctness.rs index c8946cc..c21e442 100644 --- a/tests/store_correctness.rs +++ b/tests/store_correctness.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use grossular::Store; @@ -12,26 +12,40 @@ fn next_random(state: &mut u64) -> u64 { fn run_trace(bucket_count: usize) { let mut store = Store::new(bucket_count); let mut expected = HashMap::, Vec>::new(); + let mut seen = HashSet::>::new(); let mut random = 0x4d59_5df4_d0f3_3173; for _ in 0..10_000 { let key = (next_random(&mut random) % 256).to_le_bytes(); + seen.insert(key.to_vec()); - if next_random(&mut random).is_multiple_of(4) { - assert_eq!( - store.get(&key), - expected.get(key.as_slice()).map(Vec::as_slice) - ); - } else { - let value = next_random(&mut random).to_le_bytes(); + match next_random(&mut random) % 5 { + 0 => { + assert_eq!( + store.get(&key), + expected.get(key.as_slice()).map(Vec::as_slice) + ); + } + 1 => { + assert_eq!( + store.delete(&key), + expected.remove(key.as_slice()).is_some() + ); + } + _ => { + let value = next_random(&mut random).to_le_bytes(); - store.upsert(&key, &value); - expected.insert(key.to_vec(), value.to_vec()); + store.upsert(&key, &value); + expected.insert(key.to_vec(), value.to_vec()); + } } } - for (key, expected_value) in &expected { - assert_eq!(store.get(key), Some(expected_value.as_slice())); + for key in seen { + assert_eq!( + store.get(&key), + expected.get(key.as_slice()).map(Vec::as_slice) + ); } }