Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions docs/v0.3.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 26 additions & 12 deletions tests/store_correctness.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};

use grossular::Store;

Expand All @@ -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<u8>, Vec<u8>>::new();
let mut seen = HashSet::<Vec<u8>>::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)
);
}
}

Expand Down