1use std::fs::{File, OpenOptions};
7use std::io::{Read, Seek, SeekFrom, Write};
8use std::path::{Path, PathBuf};
9
10use crate::MemoryError;
11
12const WAL_MAGIC: [u8; 4] = [0x45, 0x48, 0x57, 0x4C]; const WAL_VERSION: u8 = 1;
14
15#[repr(u8)]
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum WalEntryType {
18 Save = 0x01,
19 Tombstone = 0x02,
20 ActivationUpdate = 0x03,
21}
22
23impl WalEntryType {
24 fn from_u8(v: u8) -> Option<Self> {
25 match v {
26 0x01 => Some(Self::Save),
27 0x02 => Some(Self::Tombstone),
28 0x03 => Some(Self::ActivationUpdate),
29 _ => None,
30 }
31 }
32}
33
34#[derive(Debug, Clone)]
35pub struct WalEntry {
36 pub entry_type: WalEntryType,
37 pub timestamp: f64,
38 pub chunk: String,
39 pub embedding: Vec<f32>,
40 pub source_channel: String,
41 pub session_id: String,
42 pub tags: String,
43 pub tombstone_index: Option<usize>,
45}
46
47#[derive(Debug)]
48pub struct WalFile {
49 path: PathBuf,
50 file: Option<File>,
51 entry_count: u32,
52}
53
54impl WalFile {
55 pub fn open(path: &Path) -> Result<Self, MemoryError> {
57 if path.exists() {
58 let mut f = OpenOptions::new()
60 .read(true)
61 .write(true)
62 .append(false)
63 .open(path)?;
64 let mut magic = [0u8; 4];
65 f.read_exact(&mut magic)?;
66 if magic != WAL_MAGIC {
67 return Err(MemoryError::Schema("invalid WAL magic bytes".into()));
68 }
69 let mut ver = [0u8; 1];
70 f.read_exact(&mut ver)?;
71 if ver[0] != WAL_VERSION {
72 return Err(MemoryError::Schema(format!(
73 "unsupported WAL version {}",
74 ver[0]
75 )));
76 }
77 let mut count_buf = [0u8; 4];
78 f.read_exact(&mut count_buf)?;
79 let entry_count = u32::from_le_bytes(count_buf);
80 f.seek(SeekFrom::End(0))?;
82 Ok(Self {
83 path: path.to_path_buf(),
84 file: Some(f),
85 entry_count,
86 })
87 } else {
88 let mut f = File::create(path)?;
90 f.write_all(&WAL_MAGIC)?;
91 f.write_all(&[WAL_VERSION])?;
92 f.write_all(&0u32.to_le_bytes())?;
93 f.flush()?;
94 Ok(Self {
95 path: path.to_path_buf(),
96 file: Some(f),
97 entry_count: 0,
98 })
99 }
100 }
101
102 pub fn append_save(&mut self, entry: &WalEntry) -> Result<(), MemoryError> {
104 let f = self.file.as_mut().ok_or_else(|| {
105 MemoryError::Io(std::io::Error::new(
106 std::io::ErrorKind::Other,
107 "WAL file not open",
108 ))
109 })?;
110 f.write_all(&[WalEntryType::Save as u8])?;
112 f.write_all(&entry.timestamp.to_le_bytes())?;
114 write_len_prefixed_str(f, &entry.chunk)?;
116 let emb_len = entry.embedding.len() as u32;
118 f.write_all(&emb_len.to_le_bytes())?;
119 for &val in &entry.embedding {
120 f.write_all(&val.to_le_bytes())?;
121 }
122 write_len_prefixed_str(f, &entry.source_channel)?;
124 write_len_prefixed_str(f, &entry.session_id)?;
126 write_len_prefixed_str(f, &entry.tags)?;
128 f.flush()?;
129
130 self.entry_count += 1;
131 self.write_entry_count()?;
132 Ok(())
133 }
134
135 pub fn append_tombstone(
137 &mut self,
138 index: usize,
139 timestamp: f64,
140 ) -> Result<(), MemoryError> {
141 let f = self.file.as_mut().ok_or_else(|| {
142 MemoryError::Io(std::io::Error::new(
143 std::io::ErrorKind::Other,
144 "WAL file not open",
145 ))
146 })?;
147 f.write_all(&[WalEntryType::Tombstone as u8])?;
148 f.write_all(×tamp.to_le_bytes())?;
149 f.write_all(&(index as u32).to_le_bytes())?;
150 f.flush()?;
151
152 self.entry_count += 1;
153 self.write_entry_count()?;
154 Ok(())
155 }
156
157 pub fn read_entries(path: &Path) -> Result<Vec<WalEntry>, MemoryError> {
159 if !path.exists() {
160 return Ok(Vec::new());
161 }
162 let mut f = File::open(path)?;
163 let mut header = [0u8; 9];
165 f.read_exact(&mut header)?;
166 if header[0..4] != WAL_MAGIC {
167 return Err(MemoryError::Schema("invalid WAL magic bytes".into()));
168 }
169 let entry_count = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
170 let mut entries = Vec::with_capacity(entry_count as usize);
171
172 for _ in 0..entry_count {
173 let mut type_buf = [0u8; 1];
174 f.read_exact(&mut type_buf)?;
175 let entry_type = WalEntryType::from_u8(type_buf[0]).ok_or_else(|| {
176 MemoryError::Schema(format!("unknown WAL entry type: {}", type_buf[0]))
177 })?;
178
179 let mut ts_buf = [0u8; 8];
180 f.read_exact(&mut ts_buf)?;
181 let timestamp = f64::from_le_bytes(ts_buf);
182
183 match entry_type {
184 WalEntryType::Save => {
185 let chunk = read_len_prefixed_str(&mut f)?;
186 let embedding = read_embedding(&mut f)?;
187 let source_channel = read_len_prefixed_str(&mut f)?;
188 let session_id = read_len_prefixed_str(&mut f)?;
189 let tags = read_len_prefixed_str(&mut f)?;
190 entries.push(WalEntry {
191 entry_type,
192 timestamp,
193 chunk,
194 embedding,
195 source_channel,
196 session_id,
197 tags,
198 tombstone_index: None,
199 });
200 }
201 WalEntryType::Tombstone => {
202 let mut idx_buf = [0u8; 4];
203 f.read_exact(&mut idx_buf)?;
204 let idx = u32::from_le_bytes(idx_buf) as usize;
205 entries.push(WalEntry {
206 entry_type,
207 timestamp,
208 chunk: String::new(),
209 embedding: Vec::new(),
210 source_channel: String::new(),
211 session_id: String::new(),
212 tags: String::new(),
213 tombstone_index: Some(idx),
214 });
215 }
216 WalEntryType::ActivationUpdate => {
217 }
219 }
220 }
221 Ok(entries)
222 }
223
224 pub fn truncate(&mut self) -> Result<(), MemoryError> {
226 self.file = None;
228 let mut f = File::create(&self.path)?;
229 f.write_all(&WAL_MAGIC)?;
230 f.write_all(&[WAL_VERSION])?;
231 f.write_all(&0u32.to_le_bytes())?;
232 f.flush()?;
233 self.file = Some(f);
234 self.entry_count = 0;
235 Ok(())
236 }
237
238 pub fn pending_count(&self) -> u32 {
240 self.entry_count
241 }
242
243 pub fn is_empty(&self) -> bool {
245 self.entry_count == 0
246 }
247
248 fn write_entry_count(&mut self) -> Result<(), MemoryError> {
250 let f = self.file.as_mut().ok_or_else(|| {
251 MemoryError::Io(std::io::Error::new(
252 std::io::ErrorKind::Other,
253 "WAL file not open",
254 ))
255 })?;
256 let pos = f.stream_position()?;
257 f.seek(SeekFrom::Start(5))?;
258 f.write_all(&self.entry_count.to_le_bytes())?;
259 f.flush()?;
260 f.seek(SeekFrom::Start(pos))?;
261 Ok(())
262 }
263}
264
265pub fn replay_into_cache(entries: &[WalEntry], cache: &mut crate::cache::MemoryCache) {
267 for entry in entries {
268 match entry.entry_type {
269 WalEntryType::Save => {
270 cache.push(
271 entry.chunk.clone(),
272 entry.embedding.clone(),
273 entry.source_channel.clone(),
274 entry.timestamp,
275 entry.session_id.clone(),
276 entry.tags.clone(),
277 );
278 }
279 WalEntryType::Tombstone => {
280 if let Some(idx) = entry.tombstone_index {
281 cache.mark_deleted(idx);
282 }
283 }
284 WalEntryType::ActivationUpdate => {}
285 }
286 }
287}
288
289fn write_len_prefixed_str(f: &mut File, s: &str) -> Result<(), MemoryError> {
292 let bytes = s.as_bytes();
293 f.write_all(&(bytes.len() as u32).to_le_bytes())?;
294 f.write_all(bytes)?;
295 Ok(())
296}
297
298fn read_len_prefixed_str(f: &mut File) -> Result<String, MemoryError> {
299 let mut len_buf = [0u8; 4];
300 f.read_exact(&mut len_buf)?;
301 let len = u32::from_le_bytes(len_buf) as usize;
302 let mut buf = vec![0u8; len];
303 f.read_exact(&mut buf)?;
304 String::from_utf8(buf).map_err(|e| MemoryError::Schema(format!("invalid UTF-8 in WAL: {e}")))
305}
306
307fn read_embedding(f: &mut File) -> Result<Vec<f32>, MemoryError> {
308 let mut len_buf = [0u8; 4];
309 f.read_exact(&mut len_buf)?;
310 let count = u32::from_le_bytes(len_buf) as usize;
311 let mut vals = Vec::with_capacity(count);
312 for _ in 0..count {
313 let mut val_buf = [0u8; 4];
314 f.read_exact(&mut val_buf)?;
315 vals.push(f32::from_le_bytes(val_buf));
316 }
317 Ok(vals)
318}
319
320#[cfg(test)]
323mod tests {
324 use super::*;
325 use tempfile::TempDir;
326
327 fn make_wal_entry(chunk: &str, embedding: &[f32]) -> WalEntry {
328 WalEntry {
329 entry_type: WalEntryType::Save,
330 timestamp: 1234567.89,
331 chunk: chunk.to_string(),
332 embedding: embedding.to_vec(),
333 source_channel: "test-channel".to_string(),
334 session_id: "sess-001".to_string(),
335 tags: "tag1,tag2".to_string(),
336 tombstone_index: None,
337 }
338 }
339
340 #[test]
341 fn test_wal_create_and_header() {
342 let dir = TempDir::new().unwrap();
343 let wal_path = dir.path().join("test.h5.wal");
344 let wal = WalFile::open(&wal_path).unwrap();
345 assert_eq!(wal.pending_count(), 0);
346 assert!(wal.is_empty());
347 drop(wal);
348
349 let bytes = std::fs::read(&wal_path).unwrap();
351 assert_eq!(&bytes[0..4], &WAL_MAGIC);
352 assert_eq!(bytes[4], WAL_VERSION);
353 assert_eq!(&bytes[5..9], &0u32.to_le_bytes());
354 }
355
356 #[test]
357 fn test_wal_append_and_read() {
358 let dir = TempDir::new().unwrap();
359 let wal_path = dir.path().join("test.h5.wal");
360 {
361 let mut wal = WalFile::open(&wal_path).unwrap();
362 wal.append_save(&make_wal_entry("first", &[1.0, 2.0]))
363 .unwrap();
364 wal.append_save(&make_wal_entry("second", &[3.0, 4.0]))
365 .unwrap();
366 wal.append_save(&make_wal_entry("third", &[5.0, 6.0]))
367 .unwrap();
368 assert_eq!(wal.pending_count(), 3);
369 }
370
371 let entries = WalFile::read_entries(&wal_path).unwrap();
372 assert_eq!(entries.len(), 3);
373 assert_eq!(entries[0].chunk, "first");
374 assert_eq!(entries[0].embedding, vec![1.0, 2.0]);
375 assert_eq!(entries[1].chunk, "second");
376 assert_eq!(entries[2].chunk, "third");
377 assert_eq!(entries[2].embedding, vec![5.0, 6.0]);
378 }
379
380 #[test]
381 fn test_wal_truncate() {
382 let dir = TempDir::new().unwrap();
383 let wal_path = dir.path().join("test.h5.wal");
384 let mut wal = WalFile::open(&wal_path).unwrap();
385 for i in 0..5 {
386 wal.append_save(&make_wal_entry(&format!("entry {i}"), &[i as f32]))
387 .unwrap();
388 }
389 assert_eq!(wal.pending_count(), 5);
390
391 wal.truncate().unwrap();
392 assert_eq!(wal.pending_count(), 0);
393 assert!(wal.is_empty());
394
395 let entries = WalFile::read_entries(&wal_path).unwrap();
396 assert!(entries.is_empty());
397 }
398
399 #[test]
400 fn test_wal_append_tombstone() {
401 let dir = TempDir::new().unwrap();
402 let wal_path = dir.path().join("test.h5.wal");
403 {
404 let mut wal = WalFile::open(&wal_path).unwrap();
405 wal.append_tombstone(42, 9999.0).unwrap();
406 assert_eq!(wal.pending_count(), 1);
407 }
408
409 let entries = WalFile::read_entries(&wal_path).unwrap();
410 assert_eq!(entries.len(), 1);
411 assert_eq!(entries[0].entry_type, WalEntryType::Tombstone);
412 assert_eq!(entries[0].tombstone_index, Some(42));
413 assert!((entries[0].timestamp - 9999.0).abs() < 1e-6);
414 }
415
416 #[test]
417 fn test_wal_binary_roundtrip() {
418 let dir = TempDir::new().unwrap();
419 let wal_path = dir.path().join("test.h5.wal");
420 let unicode_chunk = "Hello 世界! 🌍 émojis & ünïcödé";
421 let embedding = vec![0.1, -0.2, 3.14159, f32::MAX, f32::MIN_POSITIVE];
422 {
423 let mut wal = WalFile::open(&wal_path).unwrap();
424 let entry = WalEntry {
425 entry_type: WalEntryType::Save,
426 timestamp: std::f64::consts::PI,
427 chunk: unicode_chunk.to_string(),
428 embedding: embedding.clone(),
429 source_channel: "channel/with/slashes".to_string(),
430 session_id: "sess-öö-123".to_string(),
431 tags: "α,β,γ".to_string(),
432 tombstone_index: None,
433 };
434 wal.append_save(&entry).unwrap();
435 }
436
437 let entries = WalFile::read_entries(&wal_path).unwrap();
438 assert_eq!(entries.len(), 1);
439 let e = &entries[0];
440 assert_eq!(e.entry_type, WalEntryType::Save);
441 assert!((e.timestamp - std::f64::consts::PI).abs() < 1e-15);
442 assert_eq!(e.chunk, unicode_chunk);
443 assert_eq!(e.embedding, embedding);
444 assert_eq!(e.source_channel, "channel/with/slashes");
445 assert_eq!(e.session_id, "sess-öö-123");
446 assert_eq!(e.tags, "α,β,γ");
447 }
448
449 #[test]
450 fn test_wal_empty_on_create() {
451 let dir = TempDir::new().unwrap();
452 let wal_path = dir.path().join("test.h5.wal");
453 let wal = WalFile::open(&wal_path).unwrap();
454 assert_eq!(wal.pending_count(), 0);
455 assert!(wal.is_empty());
456 }
457
458 use crate::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
461
462 fn make_config(dir: &TempDir) -> MemoryConfig {
463 let mut config = MemoryConfig::new(dir.path().join("test.h5"), "agent-test", 4);
464 config.wal_enabled = true;
465 config
466 }
467
468 fn make_entry(chunk: &str, embedding: &[f32]) -> MemoryEntry {
469 MemoryEntry {
470 chunk: chunk.to_string(),
471 embedding: embedding.to_vec(),
472 source_channel: "test".to_string(),
473 timestamp: 1000000.0,
474 session_id: "session-1".to_string(),
475 tags: "tag1,tag2".to_string(),
476 }
477 }
478
479 #[test]
480 fn test_save_with_wal() {
481 let dir = TempDir::new().unwrap();
482 let config = make_config(&dir);
483 let h5_path = config.path.clone();
484 let mut mem = HDF5Memory::create(config).unwrap();
485
486 let initial_size = std::fs::metadata(&h5_path).unwrap().len();
488
489 mem.save(make_entry("a", &[1.0, 0.0, 0.0, 0.0])).unwrap();
490 mem.save(make_entry("b", &[0.0, 1.0, 0.0, 0.0])).unwrap();
491 mem.save(make_entry("c", &[0.0, 0.0, 1.0, 0.0])).unwrap();
492
493 assert_eq!(mem.count(), 3);
495
496 let after_size = std::fs::metadata(&h5_path).unwrap().len();
498 assert_eq!(initial_size, after_size, ".h5 should not grow with WAL enabled");
499
500 let wal_path = h5_path.with_extension("h5.wal");
502 assert!(wal_path.exists(), ".wal file should exist");
503 assert_eq!(mem.wal_pending_count(), 3);
504 }
505
506 #[test]
507 fn test_wal_auto_merge() {
508 let dir = TempDir::new().unwrap();
509 let mut config = make_config(&dir);
510 config.wal_max_entries = 5;
511 let h5_path = config.path.clone();
512 let mut mem = HDF5Memory::create(config).unwrap();
513
514 for i in 0..5 {
516 mem.save(make_entry(&format!("entry {i}"), &[i as f32, 0.0, 0.0, 0.0]))
517 .unwrap();
518 }
519 assert_eq!(mem.wal_pending_count(), 5);
521
522 mem.save(make_entry("entry 5", &[5.0, 0.0, 0.0, 0.0]))
524 .unwrap();
525
526 assert_eq!(mem.wal_pending_count(), 0);
528 assert_eq!(mem.count(), 6);
529
530 let wal_path = h5_path.with_extension("h5.wal");
532 let entries = WalFile::read_entries(&wal_path).unwrap();
533 assert!(entries.is_empty(), "WAL should be empty after auto-merge");
534 }
535
536 #[test]
537 fn test_wal_flush_explicit() {
538 let dir = TempDir::new().unwrap();
539 let config = make_config(&dir);
540 let h5_path = config.path.clone();
541 let mut mem = HDF5Memory::create(config).unwrap();
542
543 mem.save(make_entry("a", &[1.0, 0.0, 0.0, 0.0])).unwrap();
544 mem.save(make_entry("b", &[0.0, 1.0, 0.0, 0.0])).unwrap();
545 mem.save(make_entry("c", &[0.0, 0.0, 1.0, 0.0])).unwrap();
546
547 assert_eq!(mem.wal_pending_count(), 3);
548
549 mem.flush_wal().unwrap();
550
551 assert_eq!(mem.wal_pending_count(), 0);
553 assert_eq!(mem.count(), 3);
555 let wal_path = h5_path.with_extension("h5.wal");
557 let entries = WalFile::read_entries(&wal_path).unwrap();
558 assert!(entries.is_empty());
559 }
560
561 #[test]
562 fn test_wal_replay_on_open() {
563 let dir = TempDir::new().unwrap();
566 let config = make_config(&dir);
567 let h5_path = config.path.clone();
568
569 {
570 let mut mem = HDF5Memory::create(config).unwrap();
571 mem.save(make_entry("replay-a", &[1.0, 0.0, 0.0, 0.0]))
572 .unwrap();
573 mem.save(make_entry("replay-b", &[0.0, 1.0, 0.0, 0.0]))
574 .unwrap();
575 mem.save(make_entry("replay-c", &[0.0, 0.0, 1.0, 0.0]))
576 .unwrap();
577 assert_eq!(mem.wal_pending_count(), 3);
578 }
580
581 let wal_path = h5_path.with_extension("h5.wal");
583 assert!(wal_path.exists());
584 let entries = WalFile::read_entries(&wal_path).unwrap();
585 assert_eq!(entries.len(), 3);
586 assert_eq!(entries[0].chunk, "replay-a");
587 assert_eq!(entries[1].chunk, "replay-b");
588 assert_eq!(entries[2].chunk, "replay-c");
589
590 let mut cache = crate::cache::MemoryCache::new(4);
592 super::replay_into_cache(&entries, &mut cache);
593 assert_eq!(cache.len(), 3);
594 assert_eq!(cache.chunks[0], "replay-a");
595 assert_eq!(cache.chunks[1], "replay-b");
596 assert_eq!(cache.chunks[2], "replay-c");
597 assert_eq!(cache.count_active(), 3);
598 }
599
600 #[test]
601 fn test_tick_session_merges_wal() {
602 let dir = TempDir::new().unwrap();
603 let config = make_config(&dir);
604 let h5_path = config.path.clone();
605 let mut mem = HDF5Memory::create(config).unwrap();
606
607 mem.save(make_entry("tick-a", &[1.0, 0.0, 0.0, 0.0]))
608 .unwrap();
609 mem.save(make_entry("tick-b", &[0.0, 1.0, 0.0, 0.0]))
610 .unwrap();
611 mem.save(make_entry("tick-c", &[0.0, 0.0, 1.0, 0.0]))
612 .unwrap();
613
614 assert_eq!(mem.wal_pending_count(), 3);
615
616 mem.tick_session().unwrap();
617
618 assert_eq!(mem.wal_pending_count(), 0);
620 assert_eq!(mem.count(), 3);
622 let wal_path = h5_path.with_extension("h5.wal");
624 let entries = WalFile::read_entries(&wal_path).unwrap();
625 assert!(entries.is_empty());
626 }
627
628 #[test]
629 fn test_wal_disabled() {
630 let dir = TempDir::new().unwrap();
631 let mut config = make_config(&dir);
632 config.wal_enabled = false;
633 let h5_path = config.path.clone();
634 let mut mem = HDF5Memory::create(config).unwrap();
635
636 mem.save(make_entry("no-wal", &[1.0, 0.0, 0.0, 0.0]))
637 .unwrap();
638
639 assert_eq!(mem.count(), 1);
641 let wal_path = h5_path.with_extension("h5.wal");
643 assert!(!wal_path.exists(), "no .wal file when WAL disabled");
644 assert_eq!(mem.wal_pending_count(), 0);
645 }
646}