Skip to content

Commit 7786c75

Browse files
Phase 5: value model, order-preserving key encoding, row/wire encodings, UUIDv7
1 parent afca053 commit 7786c75

13 files changed

Lines changed: 2072 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,37 @@ under a category (`Added` / `Changed` / `Fixed` / `Removed` / `Security`).
88

99
## [Unreleased]
1010

11+
### Phase 5 — Types, values & encoding
12+
13+
#### Added
14+
- `types`: the `Value` model covering every v1 type (`SPEC.md` §3), with the
15+
engine's logical total order (`Eq`/`Ord` follow it: nulls first, IEEE-754
16+
float total order with one canonical NaN — `DECISIONS.md` D12).
17+
- The **order-preserving key encoding** (`encode_key`/`decode_key`): bytewise
18+
comparison of encoded keys equals logical comparison, for single and
19+
composite keys; prefix-free escape-coded text/blob; exactly decodable;
20+
`json` is not keyable.
21+
- The row encoding (`encode_row`/`decode_row`): compact, self-describing,
22+
exact round-trip, typed `RowCorrupt` errors on hostile bytes.
23+
- An in-house, hardened minimal MessagePack codec (`DECISIONS.md` D11): the
24+
wire mapping for values (`encode_value`/schema-directed `decode_value`;
25+
uuid as canonical string, timestamp as int) and the `json` well-formedness
26+
gate (`validate_json`: depth-limited, rejects reserved/ext bytes, trailing
27+
bytes, truncation).
28+
- `UuidV7Gen`: RFC 9562 UUIDv7 over the injected `Clock`/`Rng`, strictly
29+
monotonic within a run (counter in `rand_a`, timestamp nudge on overflow),
30+
plus canonical-string format/parse.
31+
- Exit-criteria tests: the order property (`bytewise_cmp(encode(a),encode(b))
32+
== logical_cmp(a,b)`) over curated edge cases, seeded random values, and
33+
composites; key/row/wire round-trips; malformed-json rejection; decoder
34+
no-panic fuzz; UUIDv7 monotonicity under clock stall/backstep.
35+
- Decision D13: no `u64` value type (SPEC §4.3/§3 inconsistency, raised);
36+
`rowversion` will be `i64`.
37+
38+
#### Changed
39+
- `clippy.toml`: `allow-panic-in-tests = true`, completing the declared
40+
"test code may use them freely" scoping of operating rule 3.
41+
1142
### Phase 4 — Transactions, MVCC & durability
1243

1344
#### Added

DECISIONS.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,54 @@ Per `PLAN.md` §1 rule 6, every resolution of an ambiguity or deviation from
55

66
---
77

8+
## D13 — No `u64` value type; `rowversion` columns are `i64`
9+
10+
**Phase:** 5 · **Status:** accepted (SPEC inconsistency, raised)
11+
12+
`SPEC.md` §4.3's example schema declares `version: { type: u64, rowversion:
13+
true }`, but §3's authoritative type table has no `u64`.
14+
15+
**Decision:** the `Value` model implements exactly the §3 table; there is no
16+
`u64`. A `rowversion` counter fits comfortably in `i64` (~9.2 × 10¹⁸ writes),
17+
so Phase 6 models `rowversion` columns as `i64`. Recorded here rather than
18+
silently extending the type system.
19+
20+
## D12 — Key-encoding order: nulls first, one canonical NaN above +inf
21+
22+
**Phase:** 5 · **Status:** accepted
23+
24+
`ARCHITECTURE.md` §3.4 mandates `bytewise_cmp(encode(a), encode(b)) ==
25+
logical_cmp(a, b)` with "a total order over floats" and "defined null
26+
ordering", but fixes neither choice.
27+
28+
**Decision:**
29+
- **Nulls sort first**, engine-wide (tag `0x01`, the lowest).
30+
- **Floats** use the IEEE-754 total order (`-0.0 < +0.0`), with **every NaN
31+
canonicalized to the one positive quiet NaN**, sorting above `+inf`. Key
32+
equality follows the same total order (`NaN == NaN`), so NaN keys are
33+
well-behaved rather than unmatchable.
34+
- **Variable-length components** (`text`/`blob`) are escape-coded
35+
(`0x00``0x00 0xFF`, terminator `0x00 0x00`): prefix-free, so composite
36+
keys are plain concatenation and prefixes sort first.
37+
- The encoding is **decodable** (exact round-trip): Phase 7 recovers PK
38+
suffixes from non-unique index entries instead of storing the PK twice.
39+
- `json` is opaque in v1 and **not keyable** (typed `NotKeyable` error).
40+
41+
## D11 — In-house minimal MessagePack codec
42+
43+
**Phase:** 5 · **Status:** accepted
44+
45+
The wire mapping, `json` storage, and Phase 8's AST decode all need
46+
MessagePack. `rmp`/`rmpv` would be the first unvetted external dependencies
47+
(D4 keeps the graph at `thiserror` only), and the engine needs a *hardened*
48+
decoder (depth limits, no panics on hostile bytes) more than a featureful one.
49+
50+
**Decision:** implement the needed subset in `types::msgpack`: compact int /
51+
str / bin / array / map encode, schema-directed value decode, and a
52+
well-formedness walk with `MAX_JSON_DEPTH = 64`, rejecting the reserved byte
53+
`0xC1` and all ext types in v1 documents. Phase 8 builds its AST decoding on
54+
this module.
55+
856
## D10 — Pager commit releases the state lock across fsyncs
957

1058
**Phase:** 4 · **Status:** accepted

clippy.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
# (set in Cargo.toml) to non-test code.
44
allow-unwrap-in-tests = true
55
allow-expect-in-tests = true
6+
allow-panic-in-tests = true

crates/dbms/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ impl Error {
5050
/// use otf_dbms::{Error, ErrorCategory};
5151
///
5252
/// // A type error surfaces as a `Validation` category error.
53-
/// let err: Error = types::TypeError::Invalid.into();
53+
/// let err: Error = types::TypeError::BadUuid.into();
5454
/// assert_eq!(err.category(), ErrorCategory::Validation);
5555
/// ```
5656
pub fn category(&self) -> ErrorCategory {

crates/types/src/key.rs

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
//! The order-preserving key encoding.
2+
//!
3+
//! For any two keyable values (and any two composite keys),
4+
//! `bytewise_cmp(encode(a), encode(b)) == logical_cmp(a, b)`. Every encoded
5+
//! value is **prefix-free**, so composite keys are plain concatenation and the
6+
//! encoding decodes back exactly — Phase 7 relies on that to recover PK
7+
//! suffixes from index entries.
8+
//!
9+
//! Layout per value: one tag byte (fixing the cross-type rank, nulls first),
10+
//! then a payload:
11+
//!
12+
//! | type | payload |
13+
//! |---|---|
14+
//! | `null` | none |
15+
//! | `bool` | `0x00` / `0x01` |
16+
//! | `i64`, `timestamp` | 8 bytes big-endian, sign bit flipped |
17+
//! | `f64` | 8 bytes big-endian IEEE-754 total-order mapping |
18+
//! | `text`, `blob` | bytes with `0x00` → `0x00 0xFF`, terminated `0x00 0x00` |
19+
//! | `uuid` | 16 raw bytes |
20+
//!
21+
//! `json` is opaque in v1 and cannot be a key component.
22+
23+
use crate::value::{f64_from_total_key, f64_total_key, Value};
24+
use crate::{KeyCorruption, Result, TypeError};
25+
26+
const TAG_NULL: u8 = 0x01;
27+
const TAG_BOOL: u8 = 0x02;
28+
const TAG_I64: u8 = 0x03;
29+
const TAG_F64: u8 = 0x04;
30+
const TAG_TEXT: u8 = 0x05;
31+
const TAG_BLOB: u8 = 0x06;
32+
const TAG_UUID: u8 = 0x07;
33+
const TAG_TIMESTAMP: u8 = 0x08;
34+
35+
/// Encode a composite key (one or more components) into its order-preserving
36+
/// byte form. A single-column key is a one-element slice.
37+
///
38+
/// Returns [`TypeError::NotKeyable`] if a component is `json` (opaque in v1).
39+
///
40+
/// # Examples
41+
///
42+
/// ```
43+
/// use types::{encode_key, Value};
44+
///
45+
/// let lo = encode_key(&[Value::I64(-5)]).unwrap();
46+
/// let hi = encode_key(&[Value::I64(3)]).unwrap();
47+
/// assert!(lo < hi);
48+
/// ```
49+
pub fn encode_key(components: &[Value]) -> Result<Vec<u8>> {
50+
let mut out = Vec::new();
51+
for value in components {
52+
encode_into(&mut out, value)?;
53+
}
54+
Ok(out)
55+
}
56+
57+
fn encode_into(out: &mut Vec<u8>, value: &Value) -> Result<()> {
58+
match value {
59+
Value::Null => out.push(TAG_NULL),
60+
Value::Bool(b) => {
61+
out.push(TAG_BOOL);
62+
out.push(u8::from(*b));
63+
}
64+
Value::I64(v) => {
65+
out.push(TAG_I64);
66+
out.extend_from_slice(&flip_sign(*v).to_be_bytes());
67+
}
68+
Value::F64(v) => {
69+
out.push(TAG_F64);
70+
out.extend_from_slice(&f64_total_key(*v).to_be_bytes());
71+
}
72+
Value::Text(s) => {
73+
out.push(TAG_TEXT);
74+
escape_into(out, s.as_bytes());
75+
}
76+
Value::Blob(b) => {
77+
out.push(TAG_BLOB);
78+
escape_into(out, b);
79+
}
80+
Value::Uuid(u) => {
81+
out.push(TAG_UUID);
82+
out.extend_from_slice(u);
83+
}
84+
Value::Timestamp(v) => {
85+
out.push(TAG_TIMESTAMP);
86+
out.extend_from_slice(&flip_sign(*v).to_be_bytes());
87+
}
88+
Value::Json(_) => return Err(TypeError::NotKeyable { kind: "json" }),
89+
}
90+
Ok(())
91+
}
92+
93+
/// Decode a key produced by [`encode_key`] back into its components.
94+
///
95+
/// The input is stored bytes, so every malformation is a typed
96+
/// [`TypeError::KeyCorrupt`] — never a panic.
97+
pub fn decode_key(bytes: &[u8]) -> Result<Vec<Value>> {
98+
let mut components = Vec::new();
99+
let mut rest = bytes;
100+
while let Some((&tag, after_tag)) = rest.split_first() {
101+
let (value, after_value) = decode_one(tag, after_tag)?;
102+
components.push(value);
103+
rest = after_value;
104+
}
105+
Ok(components)
106+
}
107+
108+
fn decode_one(tag: u8, rest: &[u8]) -> Result<(Value, &[u8])> {
109+
match tag {
110+
TAG_NULL => Ok((Value::Null, rest)),
111+
TAG_BOOL => {
112+
let (&byte, rest) = rest
113+
.split_first()
114+
.ok_or_else(|| corrupt(KeyCorruption::Truncated))?;
115+
match byte {
116+
0 => Ok((Value::Bool(false), rest)),
117+
1 => Ok((Value::Bool(true), rest)),
118+
_ => Err(corrupt(KeyCorruption::BadBool { byte })),
119+
}
120+
}
121+
TAG_I64 => {
122+
let (word, rest) = take_u64(rest)?;
123+
Ok((Value::I64(unflip_sign(word)), rest))
124+
}
125+
TAG_F64 => {
126+
let (word, rest) = take_u64(rest)?;
127+
Ok((Value::F64(f64_from_total_key(word)), rest))
128+
}
129+
TAG_TEXT => {
130+
let (bytes, rest) = unescape(rest)?;
131+
let text = String::from_utf8(bytes).map_err(|_| corrupt(KeyCorruption::InvalidUtf8))?;
132+
Ok((Value::Text(text), rest))
133+
}
134+
TAG_BLOB => {
135+
let (bytes, rest) = unescape(rest)?;
136+
Ok((Value::Blob(bytes), rest))
137+
}
138+
TAG_UUID => {
139+
if rest.len() < 16 {
140+
return Err(corrupt(KeyCorruption::Truncated));
141+
}
142+
let (head, rest) = rest.split_at(16);
143+
let mut uuid = [0u8; 16];
144+
uuid.copy_from_slice(head);
145+
Ok((Value::Uuid(uuid), rest))
146+
}
147+
TAG_TIMESTAMP => {
148+
let (word, rest) = take_u64(rest)?;
149+
Ok((Value::Timestamp(unflip_sign(word)), rest))
150+
}
151+
_ => Err(corrupt(KeyCorruption::BadTag { tag })),
152+
}
153+
}
154+
155+
/// Map an `i64` to a `u64` whose unsigned (big-endian byte) order matches the
156+
/// signed order: flip the sign bit.
157+
fn flip_sign(v: i64) -> u64 {
158+
(v as u64) ^ (1 << 63)
159+
}
160+
161+
fn unflip_sign(word: u64) -> i64 {
162+
(word ^ (1 << 63)) as i64
163+
}
164+
165+
fn take_u64(rest: &[u8]) -> Result<(u64, &[u8])> {
166+
if rest.len() < 8 {
167+
return Err(corrupt(KeyCorruption::Truncated));
168+
}
169+
let (head, rest) = rest.split_at(8);
170+
let mut word = [0u8; 8];
171+
word.copy_from_slice(head);
172+
Ok((u64::from_be_bytes(word), rest))
173+
}
174+
175+
/// Escape variable-length bytes so they are prefix-free yet order-preserving:
176+
/// every `0x00` becomes `0x00 0xFF`, and the value ends with `0x00 0x00`.
177+
/// A proper prefix then terminates (`0x00 0x00`) exactly where the longer
178+
/// value continues with either an escaped zero (`0x00 0xFF`, larger) or any
179+
/// other byte (`0x01..`, larger) — so prefixes sort first, matching the
180+
/// logical bytewise order.
181+
fn escape_into(out: &mut Vec<u8>, bytes: &[u8]) {
182+
for &b in bytes {
183+
out.push(b);
184+
if b == 0x00 {
185+
out.push(0xFF);
186+
}
187+
}
188+
out.extend_from_slice(&[0x00, 0x00]);
189+
}
190+
191+
fn unescape(mut rest: &[u8]) -> Result<(Vec<u8>, &[u8])> {
192+
let mut out = Vec::new();
193+
while let Some((&b, tail)) = rest.split_first() {
194+
if b != 0x00 {
195+
out.push(b);
196+
rest = tail;
197+
continue;
198+
}
199+
match tail.split_first() {
200+
Some((&0x00, after)) => return Ok((out, after)),
201+
Some((&0xFF, after)) => {
202+
out.push(0x00);
203+
rest = after;
204+
}
205+
Some((&escape, _)) => return Err(corrupt(KeyCorruption::BadEscape { escape })),
206+
None => return Err(corrupt(KeyCorruption::Truncated)),
207+
}
208+
}
209+
Err(corrupt(KeyCorruption::Truncated))
210+
}
211+
212+
fn corrupt(kind: KeyCorruption) -> TypeError {
213+
TypeError::KeyCorrupt(kind)
214+
}
215+
216+
#[cfg(test)]
217+
mod tests {
218+
use super::*;
219+
220+
#[test]
221+
fn prefix_sorts_before_extension() {
222+
let a = encode_key(&[Value::Text("ab".into())]).unwrap();
223+
let b = encode_key(&[Value::Text("ab\u{0}".into())]).unwrap();
224+
let c = encode_key(&[Value::Text("abc".into())]).unwrap();
225+
assert!(a < b && b < c);
226+
}
227+
228+
#[test]
229+
fn composite_orders_component_wise() {
230+
let a = encode_key(&[Value::Text("a".into()), Value::I64(9)]).unwrap();
231+
let b = encode_key(&[Value::Text("ab".into()), Value::I64(0)]).unwrap();
232+
assert!(a < b, "shorter first component must dominate");
233+
}
234+
235+
#[test]
236+
fn json_is_not_keyable() {
237+
let err = encode_key(&[Value::Json(vec![0xC0])]).unwrap_err();
238+
assert!(matches!(err, TypeError::NotKeyable { kind: "json" }));
239+
}
240+
}

0 commit comments

Comments
 (0)