tidaldb/tidal/src/wal/format/session.rs
jordan f4cfd6c81f feat: complete M8 replication primitives + forage enhancements + docs
Milestone 8 (phases 1-4):
- Shard-aware WAL segment naming, BatchHeader v2, ShardRouter
- Transport trait, InProcessTransport, WalShipper, FollowerDb
- HLC, PNCounter, LWWRegister, CrdtSignalState, ReconciliationEngine
- Session replication bridge with SeqNo/HWM, idempotency store

Forage application:
- Multi-source discovery engine with MAB exploration
- Embedding-based label system, server handlers, UI refresh

Other:
- QUICKSTART.md, README.md, milestone-8 planning docs
- Hard negative union semantics, RLHF export enhancements
- Recovery benchmark and visibility test expansions
- Split 8 oversized source files per CODING_GUIDELINES §9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:17:19 -07:00

582 lines
18 KiB
Rust

// ── Session journal record types ────────────────────────────────────────────
/// Record type discriminant for session start events.
pub const SESSION_RECORD_START: u8 = 0x01;
/// Record type discriminant for session signal events.
pub const SESSION_RECORD_SIGNAL: u8 = 0x02;
/// Record type discriminant for session close events.
pub const SESSION_RECORD_CLOSE: u8 = 0x03;
/// Monotonic sequence number for session writes.
///
/// Incremented once per session write operation (preference signal,
/// annotation, search query). Used by the receiver to enforce idempotent
/// replay: a write with seqno <= hwm is a no-op.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub struct SessionSeqNo(pub u64);
impl SessionSeqNo {
pub const ZERO: Self = Self(0);
#[must_use]
pub const fn next(self) -> Self {
Self(self.0 + 1)
}
}
impl std::fmt::Display for SessionSeqNo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ssn:{}", self.0)
}
}
/// A session event decoded from the session journal.
///
/// These events are stored in a separate append-only file (`sessions.log`)
/// alongside the signal WAL. They are used to restore active sessions on
/// crash recovery.
#[derive(Debug, Clone, PartialEq)]
pub enum SessionWalEvent {
/// A session was started.
Start {
session_id: u64,
user_id: u64,
started_at_ns: u64,
agent_id: String,
policy_name: String,
},
/// A signal was written within a session.
Signal {
session_id: u64,
entity_id: u64,
weight: f32,
ts_ns: u64,
signal_name: String,
annotation: Option<String>,
// m8p4 extensions (default None for legacy events)
session_seqno: Option<SessionSeqNo>,
idempotency_key: Option<u128>,
},
/// A session was closed.
Close { session_id: u64 },
}
/// Encode a session event to bytes for the session journal.
///
/// Format: `[len: u32 LE][type: u8][payload bytes]`
///
/// **Start payload**: `[session_id: u64 LE][user_id: u64 LE][started_at_ns: u64 LE]`
/// `[agent_id_len: u16 LE][agent_id: bytes][policy_name_len: u16 LE][policy_name: bytes]`
///
/// **Signal payload**: `[session_id: u64 LE][entity_id: u64 LE][weight: f32 LE][ts_ns: u64 LE]`
/// `[signal_name_len: u16 LE][signal_name: bytes][has_annotation: u8]`
/// `[if has_annotation: annotation_len: u16 LE, annotation: bytes]`
///
/// **Close payload**: `[session_id: u64 LE]`
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn encode_session_event(event: &SessionWalEvent) -> Vec<u8> {
// Encode the payload first, then prepend the length+type header.
let mut payload = Vec::new();
match event {
SessionWalEvent::Start {
session_id,
user_id,
started_at_ns,
agent_id,
policy_name,
} => {
payload.push(SESSION_RECORD_START);
payload.extend_from_slice(&session_id.to_le_bytes());
payload.extend_from_slice(&user_id.to_le_bytes());
payload.extend_from_slice(&started_at_ns.to_le_bytes());
payload.extend_from_slice(&(agent_id.len() as u16).to_le_bytes());
payload.extend_from_slice(agent_id.as_bytes());
payload.extend_from_slice(&(policy_name.len() as u16).to_le_bytes());
payload.extend_from_slice(policy_name.as_bytes());
}
SessionWalEvent::Signal {
session_id,
entity_id,
weight,
ts_ns,
signal_name,
annotation,
session_seqno,
idempotency_key,
} => {
payload.push(SESSION_RECORD_SIGNAL);
payload.extend_from_slice(&session_id.to_le_bytes());
payload.extend_from_slice(&entity_id.to_le_bytes());
payload.extend_from_slice(&weight.to_le_bytes());
payload.extend_from_slice(&ts_ns.to_le_bytes());
payload.extend_from_slice(&(signal_name.len() as u16).to_le_bytes());
payload.extend_from_slice(signal_name.as_bytes());
match annotation {
Some(ann) => {
payload.push(1u8);
payload.extend_from_slice(&(ann.len() as u16).to_le_bytes());
payload.extend_from_slice(ann.as_bytes());
}
None => {
payload.push(0u8);
}
}
// m8p4: session_seqno (optional u64)
match session_seqno {
Some(seqno) => {
payload.push(1u8);
payload.extend_from_slice(&seqno.0.to_le_bytes());
}
None => {
payload.push(0u8);
}
}
// m8p4: idempotency_key (optional u128)
match idempotency_key {
Some(key) => {
payload.push(1u8);
payload.extend_from_slice(&key.to_le_bytes());
}
None => {
payload.push(0u8);
}
}
}
SessionWalEvent::Close { session_id } => {
payload.push(SESSION_RECORD_CLOSE);
payload.extend_from_slice(&session_id.to_le_bytes());
}
}
let len = payload.len() as u32;
let mut buf = Vec::with_capacity(4 + payload.len());
buf.extend_from_slice(&len.to_le_bytes());
buf.extend(payload);
buf
}
/// Decode all session events from a session journal file's contents.
///
/// Stops at the first truncated or malformed record. This is the correct
/// behavior for crash recovery: a torn write at the end of the file is
/// simply ignored.
#[must_use]
pub fn decode_session_events(bytes: &[u8]) -> Vec<SessionWalEvent> {
let mut events = Vec::new();
let mut pos = 0;
while pos + 4 <= bytes.len() {
let record_len =
u32::from_le_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]])
as usize;
pos += 4;
if pos + record_len > bytes.len() || record_len == 0 {
// Truncated or zero-length record -- stop.
break;
}
let record_end = pos + record_len;
let record_type = bytes[pos];
pos += 1;
match record_type {
SESSION_RECORD_START => {
if let Some(event) = decode_start_record(bytes, &mut pos, record_end) {
events.push(event);
} else {
break;
}
}
SESSION_RECORD_SIGNAL => {
if let Some(event) = decode_signal_record(bytes, &mut pos, record_end) {
events.push(event);
} else {
break;
}
}
SESSION_RECORD_CLOSE => {
if pos + 8 > record_end {
break;
}
let session_id = read_u64_le(bytes, &mut pos);
events.push(SessionWalEvent::Close { session_id });
}
_ => {
// Unknown record type -- skip handled by `pos = record_end` below.
}
}
// Ensure pos is at the expected record boundary.
pos = record_end;
}
events
}
/// Helper: read a little-endian u64 from `bytes` at `*pos`, advancing `*pos`.
fn read_u64_le(bytes: &[u8], pos: &mut usize) -> u64 {
let v = u64::from_le_bytes([
bytes[*pos],
bytes[*pos + 1],
bytes[*pos + 2],
bytes[*pos + 3],
bytes[*pos + 4],
bytes[*pos + 5],
bytes[*pos + 6],
bytes[*pos + 7],
]);
*pos += 8;
v
}
/// Helper: read a little-endian u16 from `bytes` at `*pos`, advancing `*pos`.
fn read_u16_le(bytes: &[u8], pos: &mut usize) -> u16 {
let v = u16::from_le_bytes([bytes[*pos], bytes[*pos + 1]]);
*pos += 2;
v
}
/// Decode a Start record from the payload region.
fn decode_start_record(bytes: &[u8], pos: &mut usize, end: usize) -> Option<SessionWalEvent> {
if *pos + 24 > end {
return None;
}
let session_id = read_u64_le(bytes, pos);
let user_id = read_u64_le(bytes, pos);
let started_at_ns = read_u64_le(bytes, pos);
if *pos + 2 > end {
return None;
}
let agent_len = read_u16_le(bytes, pos) as usize;
if *pos + agent_len > end {
return None;
}
let agent_id = String::from_utf8_lossy(&bytes[*pos..*pos + agent_len]).to_string();
*pos += agent_len;
if *pos + 2 > end {
return None;
}
let policy_len = read_u16_le(bytes, pos) as usize;
if *pos + policy_len > end {
return None;
}
let policy_name = String::from_utf8_lossy(&bytes[*pos..*pos + policy_len]).to_string();
*pos += policy_len;
Some(SessionWalEvent::Start {
session_id,
user_id,
started_at_ns,
agent_id,
policy_name,
})
}
/// Decode a Signal record from the payload region.
fn decode_signal_record(bytes: &[u8], pos: &mut usize, end: usize) -> Option<SessionWalEvent> {
// session_id(8) + entity_id(8) + weight(4) + ts_ns(8) = 28
if *pos + 28 > end {
return None;
}
let session_id = read_u64_le(bytes, pos);
let entity_id = read_u64_le(bytes, pos);
let weight = f32::from_le_bytes([
bytes[*pos],
bytes[*pos + 1],
bytes[*pos + 2],
bytes[*pos + 3],
]);
*pos += 4;
let ts_ns = read_u64_le(bytes, pos);
if *pos + 2 > end {
return None;
}
let sig_len = read_u16_le(bytes, pos) as usize;
if *pos + sig_len > end {
return None;
}
let signal_name = String::from_utf8_lossy(&bytes[*pos..*pos + sig_len]).to_string();
*pos += sig_len;
if *pos + 1 > end {
return None;
}
let has_annotation = bytes[*pos] != 0;
*pos += 1;
let annotation = if has_annotation {
if *pos + 2 > end {
return None;
}
let ann_len = read_u16_le(bytes, pos) as usize;
if *pos + ann_len > end {
return None;
}
let ann = String::from_utf8_lossy(&bytes[*pos..*pos + ann_len]).to_string();
*pos += ann_len;
Some(ann)
} else {
None
};
// m8p4: session_seqno (backward-compatible — absent in legacy records)
let session_seqno = if *pos < end {
let has_seqno = bytes[*pos];
*pos += 1;
if has_seqno != 0 && *pos + 8 <= end {
let v = read_u64_le(bytes, pos);
Some(SessionSeqNo(v))
} else {
None
}
} else {
None
};
// m8p4: idempotency_key (backward-compatible — absent in legacy records)
let idempotency_key = if *pos < end {
let has_key = bytes[*pos];
*pos += 1;
if has_key != 0 && *pos + 16 <= end {
let v = u128::from_le_bytes([
bytes[*pos],
bytes[*pos + 1],
bytes[*pos + 2],
bytes[*pos + 3],
bytes[*pos + 4],
bytes[*pos + 5],
bytes[*pos + 6],
bytes[*pos + 7],
bytes[*pos + 8],
bytes[*pos + 9],
bytes[*pos + 10],
bytes[*pos + 11],
bytes[*pos + 12],
bytes[*pos + 13],
bytes[*pos + 14],
bytes[*pos + 15],
]);
*pos += 16;
Some(v)
} else {
None
}
} else {
None
};
Some(SessionWalEvent::Signal {
session_id,
entity_id,
weight,
ts_ns,
signal_name,
annotation,
session_seqno,
idempotency_key,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn session_start_roundtrip() {
let event = SessionWalEvent::Start {
session_id: 42,
user_id: 100,
started_at_ns: 1_000_000_000,
agent_id: "test-agent".to_string(),
policy_name: "default_policy".to_string(),
};
let encoded = encode_session_event(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], event);
}
#[test]
fn session_signal_roundtrip_with_annotation() {
let event = SessionWalEvent::Signal {
session_id: 7,
entity_id: 999,
weight: 1.5,
ts_ns: 2_000_000_000,
signal_name: "reward".to_string(),
annotation: Some("jazz fusion".to_string()),
session_seqno: None,
idempotency_key: None,
};
let encoded = encode_session_event(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], event);
}
#[test]
fn session_signal_roundtrip_without_annotation() {
let event = SessionWalEvent::Signal {
session_id: 7,
entity_id: 999,
weight: 1.5,
ts_ns: 2_000_000_000,
signal_name: "view".to_string(),
annotation: None,
session_seqno: None,
idempotency_key: None,
};
let encoded = encode_session_event(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], event);
}
#[test]
fn session_close_roundtrip() {
let event = SessionWalEvent::Close { session_id: 42 };
let encoded = encode_session_event(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], event);
}
#[test]
fn session_multiple_events_roundtrip() {
let events = vec![
SessionWalEvent::Start {
session_id: 1,
user_id: 10,
started_at_ns: 100,
agent_id: "agent-a".to_string(),
policy_name: "policy-1".to_string(),
},
SessionWalEvent::Signal {
session_id: 1,
entity_id: 42,
weight: 1.0,
ts_ns: 200,
signal_name: "view".to_string(),
annotation: None,
session_seqno: None,
idempotency_key: None,
},
SessionWalEvent::Signal {
session_id: 1,
entity_id: 43,
weight: 2.0,
ts_ns: 300,
signal_name: "reward".to_string(),
annotation: Some("good content".to_string()),
session_seqno: None,
idempotency_key: None,
},
SessionWalEvent::Close { session_id: 1 },
];
let mut all_bytes = Vec::new();
for event in &events {
all_bytes.extend(encode_session_event(event));
}
let decoded = decode_session_events(&all_bytes);
assert_eq!(decoded.len(), events.len());
for (orig, dec) in events.iter().zip(decoded.iter()) {
assert_eq!(orig, dec);
}
}
#[test]
fn session_decode_truncated_stops_cleanly() {
let event = SessionWalEvent::Start {
session_id: 1,
user_id: 10,
started_at_ns: 100,
agent_id: "agent".to_string(),
policy_name: "policy".to_string(),
};
let encoded = encode_session_event(&event);
// Truncate the record mid-way.
let truncated = &encoded[..encoded.len() / 2];
let decoded = decode_session_events(truncated);
assert!(decoded.is_empty(), "truncated record should be skipped");
}
#[test]
fn session_decode_partial_second_record_stops() {
let e1 = SessionWalEvent::Close { session_id: 1 };
let e2 = SessionWalEvent::Start {
session_id: 2,
user_id: 20,
started_at_ns: 200,
agent_id: "agent".to_string(),
policy_name: "policy".to_string(),
};
let mut all_bytes = encode_session_event(&e1);
let e2_bytes = encode_session_event(&e2);
// Add partial second record.
all_bytes.extend_from_slice(&e2_bytes[..e2_bytes.len() / 2]);
let decoded = decode_session_events(&all_bytes);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], e1);
}
#[test]
fn session_decode_empty_bytes() {
let decoded = decode_session_events(&[]);
assert!(decoded.is_empty());
}
#[test]
fn session_seqno_ordering() {
assert!(SessionSeqNo(1) > SessionSeqNo(0));
assert_eq!(SessionSeqNo(5).next(), SessionSeqNo(6));
assert_eq!(SessionSeqNo::ZERO, SessionSeqNo(0));
}
#[test]
fn session_signal_with_seqno_roundtrip() {
let event = SessionWalEvent::Signal {
session_id: 1,
entity_id: 42,
weight: 1.0,
ts_ns: 1000,
signal_name: "view".to_string(),
annotation: None,
session_seqno: Some(SessionSeqNo(7)),
idempotency_key: Some(0xDEAD_BEEF_CAFE_BABE_u128),
};
let encoded = encode_session_event(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], event);
}
#[test]
fn session_signal_legacy_roundtrip_no_seqno() {
// Legacy events (seqno=None) decode correctly.
let event = SessionWalEvent::Signal {
session_id: 2,
entity_id: 99,
weight: 2.0,
ts_ns: 2000,
signal_name: "like".to_string(),
annotation: None,
session_seqno: None,
idempotency_key: None,
};
let encoded = encode_session_event(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded[0], event);
}
}