Skip to main content

storage/
wal.rs

1//! Write-Ahead Log (WAL) for Buffer
2//!
3//! Provides durability and crash recovery through sequential logging:
4//! - All mutations logged before application
5//! - Supports checkpointing for log compaction
6//! - Recovery from incomplete transactions
7
8use crate::snapshot::{SnapshotConfig, SnapshotManager};
9use crate::traits::VectorStorage;
10use common::{DakeraError, NamespaceId, Result, Vector, VectorId};
11use parking_lot::RwLock;
12use serde::{Deserialize, Serialize};
13use std::fs::{self, File, OpenOptions};
14use std::io::{BufRead, BufReader, BufWriter, Write};
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, Ordering};
17
18/// WAL configuration
19#[derive(Debug, Clone)]
20pub struct WalConfig {
21    /// Directory for WAL files
22    pub wal_dir: PathBuf,
23    /// Maximum WAL segment size in bytes
24    pub max_segment_size: u64,
25    /// Sync mode for durability
26    pub sync_mode: WalSyncMode,
27    /// Maximum entries before forced checkpoint
28    pub checkpoint_threshold: u64,
29}
30
31impl Default for WalConfig {
32    fn default() -> Self {
33        Self {
34            wal_dir: PathBuf::from("./data/wal"),
35            max_segment_size: 64 * 1024 * 1024, // 64MB
36            sync_mode: WalSyncMode::EveryWrite,
37            checkpoint_threshold: 10000,
38        }
39    }
40}
41
42/// Sync mode for WAL durability
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum WalSyncMode {
45    /// Sync after every write (safest)
46    EveryWrite,
47    /// Sync every N writes
48    Periodic(u32),
49    /// Sync on explicit flush only
50    Manual,
51}
52
53/// WAL entry types
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub enum WalEntry {
56    /// Insert or update vectors
57    Upsert {
58        namespace: String,
59        vectors: Vec<SerializedVector>,
60    },
61    /// Delete vectors
62    Delete { namespace: String, ids: Vec<String> },
63    /// Create namespace
64    CreateNamespace { namespace: String },
65    /// Delete namespace
66    DeleteNamespace { namespace: String },
67    /// Checkpoint marker
68    Checkpoint { lsn: u64 },
69    /// Transaction begin
70    TxnBegin { txn_id: u64 },
71    /// Transaction commit
72    TxnCommit { txn_id: u64 },
73    /// Transaction rollback
74    TxnRollback { txn_id: u64 },
75}
76
77/// Serialized vector for WAL storage
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct SerializedVector {
80    pub id: String,
81    pub values: Vec<f32>,
82    pub metadata: Option<String>,
83    /// TTL in seconds, preserved across crash recovery (DAK-7428).
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub ttl_seconds: Option<u64>,
86    /// Absolute expiry timestamp, preserved across crash recovery (DAK-7428).
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub expires_at: Option<u64>,
89}
90
91impl SerializedVector {
92    /// Project a `Vector` into its WAL-serializable form.
93    pub fn from_vector(v: &Vector) -> Self {
94        Self {
95            id: v.id.clone(),
96            values: v.values.clone(),
97            metadata: v.metadata.as_ref().map(|m| m.to_string()),
98            ttl_seconds: v.ttl_seconds,
99            expires_at: v.expires_at,
100        }
101    }
102
103    /// Reconstruct a `Vector` from a replayed WAL entry.
104    pub fn into_vector(self) -> Vector {
105        Vector {
106            id: self.id,
107            values: self.values,
108            metadata: self.metadata.and_then(|s| serde_json::from_str(&s).ok()),
109            ttl_seconds: self.ttl_seconds,
110            expires_at: self.expires_at,
111        }
112    }
113}
114
115/// WAL segment file
116#[derive(Debug)]
117struct WalSegment {
118    /// Current size
119    size: u64,
120    /// Starting LSN
121    start_lsn: u64,
122    /// Ending LSN
123    end_lsn: u64,
124    /// On-disk path of this segment file. Used to positively identify the
125    /// segment currently being appended to so truncation never deletes it
126    /// (deleting the active file would unlink the inode the open writer still
127    /// targets, silently losing every post-checkpoint write — DAK-7428).
128    path: PathBuf,
129}
130
131/// Write-Ahead Log manager
132pub struct WriteAheadLog {
133    config: WalConfig,
134    /// Current log sequence number
135    lsn: AtomicU64,
136    /// Current active segment
137    current_segment: RwLock<Option<WalSegment>>,
138    /// Active writer
139    writer: RwLock<Option<BufWriter<File>>>,
140    /// Entries since last checkpoint
141    entries_since_checkpoint: AtomicU64,
142    /// Last checkpointed LSN
143    last_checkpoint_lsn: AtomicU64,
144    /// Write count for periodic sync
145    write_count: AtomicU64,
146    /// Signalled by `append` when `entries_since_checkpoint` first reaches
147    /// `checkpoint_threshold`, so a checkpoint driver can bound WAL growth by
148    /// entry count under a write burst instead of waiting for the next timed
149    /// tick. Without this, `checkpoint_threshold` was tracked but never acted
150    /// on — the log could grow without bound between periodic checkpoints
151    /// (DAK-7428).
152    checkpoint_needed: tokio::sync::Notify,
153}
154
155impl WriteAheadLog {
156    /// Create a new WAL
157    pub fn new(config: WalConfig) -> Result<Self> {
158        // Ensure WAL directory exists
159        fs::create_dir_all(&config.wal_dir)
160            .map_err(|e| DakeraError::Storage(format!("Failed to create WAL dir: {}", e)))?;
161
162        let wal = Self {
163            config,
164            lsn: AtomicU64::new(0),
165            current_segment: RwLock::new(None),
166            writer: RwLock::new(None),
167            entries_since_checkpoint: AtomicU64::new(0),
168            last_checkpoint_lsn: AtomicU64::new(0),
169            write_count: AtomicU64::new(0),
170            checkpoint_needed: tokio::sync::Notify::new(),
171        };
172
173        // Recover LSN from existing segments
174        wal.recover_lsn()?;
175
176        Ok(wal)
177    }
178
179    /// Recover LSN from existing WAL segments
180    fn recover_lsn(&self) -> Result<()> {
181        let segments = self.list_segments()?;
182        if let Some(last_segment) = segments.last() {
183            // Read the last segment to find the highest LSN
184            let entries = self.read_segment(last_segment)?;
185            if let Some(last_entry) = entries.last() {
186                self.lsn.store(last_entry.0 + 1, Ordering::SeqCst);
187            }
188        }
189        Ok(())
190    }
191
192    /// List all WAL segment files
193    fn list_segments(&self) -> Result<Vec<PathBuf>> {
194        let mut segments = Vec::new();
195
196        if let Ok(entries) = fs::read_dir(&self.config.wal_dir) {
197            for entry in entries.flatten() {
198                let path = entry.path();
199                if path.extension().map(|e| e == "wal").unwrap_or(false) {
200                    segments.push(path);
201                }
202            }
203        }
204
205        segments.sort();
206        Ok(segments)
207    }
208
209    /// Read entries from a segment file
210    fn read_segment(&self, path: &Path) -> Result<Vec<(u64, WalEntry)>> {
211        let file = File::open(path)
212            .map_err(|e| DakeraError::Storage(format!("Failed to open WAL: {}", e)))?;
213
214        let reader = BufReader::new(file);
215        let mut entries = Vec::new();
216
217        for line_result in reader.lines() {
218            let line =
219                line_result.map_err(|e| DakeraError::Storage(format!("WAL read error: {}", e)))?;
220
221            if line.trim().is_empty() {
222                continue;
223            }
224
225            // Format: LSN|JSON_ENTRY
226            if let Some((lsn_str, entry_json)) = line.split_once('|') {
227                let lsn: u64 = lsn_str.parse().unwrap_or(0);
228                if let Ok(entry) = serde_json::from_str::<WalEntry>(entry_json) {
229                    entries.push((lsn, entry));
230                }
231            }
232        }
233
234        Ok(entries)
235    }
236
237    /// Append an entry to the WAL
238    pub fn append(&self, entry: WalEntry) -> Result<u64> {
239        let lsn = self.lsn.fetch_add(1, Ordering::SeqCst);
240
241        // Ensure we have an active segment
242        self.ensure_segment()?;
243
244        // Serialize and write
245        let entry_json = serde_json::to_string(&entry)
246            .map_err(|e| DakeraError::Storage(format!("WAL serialize error: {}", e)))?;
247
248        let line = format!("{}|{}\n", lsn, entry_json);
249
250        {
251            let mut writer_guard = self.writer.write();
252            let writer = writer_guard.as_mut().ok_or_else(|| {
253                DakeraError::Storage("WAL writer not available after ensure_segment".to_string())
254            })?;
255
256            writer
257                .write_all(line.as_bytes())
258                .map_err(|e| DakeraError::Storage(format!("WAL write error: {}", e)))?;
259
260            // Handle sync based on mode. `flush()` only drains the userspace
261            // BufWriter into the OS page cache — an OS crash or power loss would
262            // still lose "durably logged" entries. A write-ahead log's whole job
263            // is to survive that, so the syncing modes additionally fsync the
264            // file data to physical storage via `sync_data()` (fdatasync). This
265            // makes `EveryWrite` ("safest") and `Periodic` honour the crash
266            // durability their docs promise instead of only page-cache flushing
267            // (DAK-7428). `Manual` deliberately defers all persistence to an
268            // explicit `flush()`/checkpoint for throughput.
269            let write_count = self.write_count.fetch_add(1, Ordering::Relaxed) + 1;
270            match self.config.sync_mode {
271                WalSyncMode::EveryWrite => {
272                    if let Err(e) = writer.flush() {
273                        tracing::warn!(error = %e, "WAL flush failed during EveryWrite sync");
274                    } else if let Err(e) = writer.get_ref().sync_data() {
275                        tracing::warn!(error = %e, "WAL fsync failed during EveryWrite sync");
276                    }
277                }
278                WalSyncMode::Periodic(n) if n > 0 && write_count.is_multiple_of(n as u64) => {
279                    if let Err(e) = writer.flush() {
280                        tracing::warn!(error = %e, write_count, "WAL flush failed during periodic sync");
281                    } else if let Err(e) = writer.get_ref().sync_data() {
282                        tracing::warn!(error = %e, write_count, "WAL fsync failed during periodic sync");
283                    }
284                }
285                _ => {}
286            }
287        }
288
289        // Update segment size
290        {
291            let mut segment_guard = self.current_segment.write();
292            if let Some(ref mut segment) = *segment_guard {
293                segment.size += line.len() as u64;
294                segment.end_lsn = lsn;
295            }
296        }
297
298        // Track entries for checkpoint threshold. When the count first crosses
299        // the configured threshold, wake the checkpoint driver so WAL growth is
300        // bounded by entry count (not just by the periodic timer) under bursty
301        // writes (DAK-7428).
302        let entries = self
303            .entries_since_checkpoint
304            .fetch_add(1, Ordering::Relaxed)
305            + 1;
306        if self.config.checkpoint_threshold > 0 && entries == self.config.checkpoint_threshold {
307            self.checkpoint_needed.notify_one();
308        }
309
310        Ok(lsn)
311    }
312
313    /// Ensure we have an active segment for writing
314    fn ensure_segment(&self) -> Result<()> {
315        let needs_new_segment = {
316            let segment_guard = self.current_segment.read();
317            match &*segment_guard {
318                None => true,
319                Some(seg) => seg.size >= self.config.max_segment_size,
320            }
321        };
322
323        if needs_new_segment {
324            self.rotate_segment()?;
325        }
326
327        Ok(())
328    }
329
330    /// Rotate to a new segment file
331    fn rotate_segment(&self) -> Result<()> {
332        // Close current writer
333        {
334            let mut writer_guard = self.writer.write();
335            if let Some(ref mut writer) = *writer_guard {
336                if let Err(e) = writer.flush() {
337                    tracing::warn!(error = %e, "WAL flush failed during segment rotation");
338                }
339            }
340            *writer_guard = None;
341        }
342
343        // Create new segment
344        let current_lsn = self.lsn.load(Ordering::SeqCst);
345        let segment_id = current_lsn;
346        let segment_path = self.config.wal_dir.join(format!("{:020}.wal", segment_id));
347
348        let file = OpenOptions::new()
349            .create(true)
350            .append(true)
351            .open(&segment_path)
352            .map_err(|e| DakeraError::Storage(format!("Failed to create WAL segment: {}", e)))?;
353
354        let writer = BufWriter::new(file);
355
356        // Update state
357        {
358            let mut segment_guard = self.current_segment.write();
359            *segment_guard = Some(WalSegment {
360                size: 0,
361                start_lsn: current_lsn,
362                end_lsn: current_lsn,
363                path: segment_path.clone(),
364            });
365        }
366
367        tracing::debug!(
368            segment_id = segment_id,
369            path = %segment_path.display(),
370            "Created new WAL segment"
371        );
372
373        {
374            let mut writer_guard = self.writer.write();
375            *writer_guard = Some(writer);
376        }
377
378        Ok(())
379    }
380
381    /// Write a checkpoint marker
382    pub fn checkpoint(&self) -> Result<u64> {
383        let lsn = self.lsn.load(Ordering::SeqCst);
384
385        // Write checkpoint entry
386        self.append(WalEntry::Checkpoint { lsn })?;
387
388        // Update checkpoint tracking
389        self.last_checkpoint_lsn.store(lsn, Ordering::SeqCst);
390        self.entries_since_checkpoint.store(0, Ordering::SeqCst);
391
392        // Flush writer
393        {
394            let mut writer_guard = self.writer.write();
395            if let Some(ref mut writer) = *writer_guard {
396                if let Err(e) = writer.flush() {
397                    tracing::warn!(error = %e, lsn, "WAL flush failed during checkpoint");
398                }
399            }
400        }
401
402        Ok(lsn)
403    }
404
405    /// Recover entries since last checkpoint
406    pub fn recover(&self) -> Result<Vec<WalEntry>> {
407        let segments = self.list_segments()?;
408        let checkpoint_lsn = self.last_checkpoint_lsn.load(Ordering::SeqCst);
409
410        let mut entries = Vec::new();
411
412        for segment_path in segments {
413            let segment_entries = self.read_segment(&segment_path)?;
414
415            for (lsn, entry) in segment_entries {
416                // Skip entries before checkpoint (but only if checkpoint was done)
417                if checkpoint_lsn > 0 && lsn <= checkpoint_lsn {
418                    // But track checkpoint LSN updates
419                    if let WalEntry::Checkpoint { lsn: cp_lsn } = entry {
420                        self.last_checkpoint_lsn.store(cp_lsn, Ordering::SeqCst);
421                    }
422                    continue;
423                }
424
425                // Skip transaction control entries for recovery
426                match entry {
427                    WalEntry::TxnBegin { .. }
428                    | WalEntry::TxnCommit { .. }
429                    | WalEntry::TxnRollback { .. }
430                    | WalEntry::Checkpoint { .. } => continue,
431                    _ => entries.push(entry),
432                }
433            }
434        }
435
436        Ok(entries)
437    }
438
439    /// Truncate WAL up to (and including) the given LSN
440    pub fn truncate(&self, up_to_lsn: u64) -> Result<u64> {
441        let segments = self.list_segments()?;
442        let mut removed_count = 0u64;
443
444        // Identify the active segment by its on-disk path. We must NEVER delete
445        // the file the open writer is appending to: its LSN range still grows
446        // with post-checkpoint writes, and unlinking it would drop those writes
447        // into a deleted inode (the exact bug that lost the WAL tail — DAK-7428).
448        // Path identity is used deliberately instead of comparing start LSNs,
449        // which do not line up with a segment's first on-disk entry LSN.
450        let active_path = {
451            let segment_guard = self.current_segment.read();
452            segment_guard.as_ref().map(|s| s.path.clone())
453        };
454
455        for segment_path in segments {
456            // Never truncate the segment currently being written to.
457            if active_path.as_deref() == Some(segment_path.as_path()) {
458                continue;
459            }
460
461            // Check if entire segment is before truncation point
462            let segment_entries = self.read_segment(&segment_path)?;
463
464            if let Some((last_lsn, _)) = segment_entries.last() {
465                if *last_lsn <= up_to_lsn {
466                    // Safe to remove this segment
467                    fs::remove_file(&segment_path).ok();
468                    removed_count += segment_entries.len() as u64;
469                }
470            }
471        }
472
473        Ok(removed_count)
474    }
475
476    /// Get current LSN
477    pub fn current_lsn(&self) -> u64 {
478        self.lsn.load(Ordering::SeqCst)
479    }
480
481    /// Notify handle that fires when pending entries reach `checkpoint_threshold`.
482    /// A checkpoint driver awaits `checkpoint_needed().notified()` to trigger a
483    /// size-bounded checkpoint between periodic ticks (DAK-7428).
484    pub fn checkpoint_needed(&self) -> &tokio::sync::Notify {
485        &self.checkpoint_needed
486    }
487
488    /// Get WAL statistics
489    pub fn stats(&self) -> WalStats {
490        let segment_count = self.list_segments().map(|s| s.len()).unwrap_or(0);
491
492        let (current_segment_size, current_segment_entries) = {
493            let segment_guard = self.current_segment.read();
494            match &*segment_guard {
495                Some(seg) => (seg.size, seg.end_lsn.saturating_sub(seg.start_lsn)),
496                None => (0, 0),
497            }
498        };
499
500        WalStats {
501            current_lsn: self.lsn.load(Ordering::SeqCst),
502            last_checkpoint_lsn: self.last_checkpoint_lsn.load(Ordering::SeqCst),
503            segment_count,
504            current_segment_size,
505            current_segment_entries,
506            entries_since_checkpoint: self.entries_since_checkpoint.load(Ordering::Relaxed),
507        }
508    }
509
510    /// Flush WAL to disk
511    pub fn flush(&self) -> Result<()> {
512        let mut writer_guard = self.writer.write();
513        if let Some(ref mut writer) = *writer_guard {
514            writer
515                .flush()
516                .map_err(|e| DakeraError::Storage(format!("WAL flush error: {}", e)))?;
517        }
518        Ok(())
519    }
520}
521
522/// WAL statistics
523#[derive(Debug, Clone)]
524pub struct WalStats {
525    /// Current log sequence number
526    pub current_lsn: u64,
527    /// Last checkpointed LSN
528    pub last_checkpoint_lsn: u64,
529    /// Number of segment files
530    pub segment_count: usize,
531    /// Current segment size in bytes
532    pub current_segment_size: u64,
533    /// Entries in current segment
534    pub current_segment_entries: u64,
535    /// Entries since last checkpoint
536    pub entries_since_checkpoint: u64,
537}
538
539/// WAL-wrapped storage that logs all mutations
540pub struct WalStorage<S> {
541    /// Underlying storage
542    inner: S,
543    /// Write-ahead log
544    wal: WriteAheadLog,
545    /// Optional snapshot manager enabling log truncation without losing the ability
546    /// to reconstruct a volatile inner store (DAK-7428). `None` => full-log replay.
547    snapshot_mgr: Option<SnapshotManager>,
548    /// Serializes checkpoints against writes: mutations take a read lock (concurrent),
549    /// `snapshot_and_checkpoint` takes the write lock so the snapshot sees a quiescent
550    /// inner store (no in-flight write can hold an LSN that is not yet applied when we
551    /// truncate). This is what makes truncation crash-safe under concurrency.
552    write_gate: tokio::sync::RwLock<()>,
553}
554
555impl<S> WalStorage<S> {
556    /// Create a new WAL-wrapped storage
557    pub fn new(inner: S, wal_config: WalConfig) -> Result<Self> {
558        let wal = WriteAheadLog::new(wal_config)?;
559        Ok(Self {
560            inner,
561            wal,
562            snapshot_mgr: None,
563            write_gate: tokio::sync::RwLock::new(()),
564        })
565    }
566
567    /// Enable snapshot-backed checkpointing. With snapshots, `snapshot_and_checkpoint`
568    /// can persist inner state and truncate the log to bound its growth; without them
569    /// the log is retained in full so a volatile inner can always be replayed (DAK-7428).
570    pub fn with_snapshots(mut self, snapshot_config: SnapshotConfig) -> Result<Self> {
571        self.snapshot_mgr = Some(SnapshotManager::new(snapshot_config)?);
572        Ok(self)
573    }
574
575    /// Get WAL reference
576    pub fn wal(&self) -> &WriteAheadLog {
577        &self.wal
578    }
579
580    /// Get inner storage reference
581    pub fn inner(&self) -> &S {
582        &self.inner
583    }
584
585    /// Checkpoint the WAL
586    pub fn checkpoint(&self) -> Result<u64> {
587        self.wal.checkpoint()
588    }
589}
590
591impl<S: VectorStorage> WalStorage<S> {
592    /// Replay the WAL into the inner storage at startup so a volatile inner
593    /// (e.g. `InMemoryStorage`) is fully reconstructed after a crash (DAK-7428).
594    /// Applies every logged mutation not superseded by a checkpoint, in LSN
595    /// order; returns the number of entries applied.
596    pub async fn recover_into_inner(&self) -> Result<usize> {
597        // If checkpointing is enabled, first restore the most recent snapshot so the
598        // inner store starts from the last checkpoint; the retained WAL tail is then
599        // replayed on top (idempotently) to reach the latest state (DAK-7428).
600        if let Some(mgr) = &self.snapshot_mgr {
601            if let Some(latest) = mgr
602                .list_snapshots()?
603                .into_iter()
604                .max_by_key(|m| m.created_at)
605            {
606                let restored = mgr.restore_snapshot(&self.inner, &latest.id).await?;
607                tracing::info!(
608                    snapshot_id = %latest.id,
609                    namespaces = restored.namespaces_restored,
610                    vectors = restored.vectors_restored,
611                    "WAL recovery restored base snapshot"
612                );
613            }
614        }
615
616        let entries = self.wal.recover()?;
617        let mut applied = 0usize;
618        for entry in entries {
619            match entry {
620                WalEntry::CreateNamespace { namespace } => {
621                    self.inner.ensure_namespace(&namespace).await?;
622                }
623                WalEntry::Upsert { namespace, vectors } => {
624                    let vecs: Vec<Vector> = vectors
625                        .into_iter()
626                        .map(SerializedVector::into_vector)
627                        .collect();
628                    if !vecs.is_empty() {
629                        self.inner.upsert(&namespace, vecs).await?;
630                    }
631                }
632                WalEntry::Delete { namespace, ids } => {
633                    self.inner.delete(&namespace, &ids).await?;
634                }
635                WalEntry::DeleteNamespace { namespace } => {
636                    self.inner.delete_namespace(&namespace).await?;
637                }
638                // recover() already filters Txn*/Checkpoint markers; ignore any.
639                WalEntry::Checkpoint { .. }
640                | WalEntry::TxnBegin { .. }
641                | WalEntry::TxnCommit { .. }
642                | WalEntry::TxnRollback { .. } => continue,
643            }
644            applied += 1;
645        }
646        if applied > 0 {
647            tracing::info!(
648                entries = applied,
649                "WAL recovery replayed mutations into inner storage"
650            );
651        }
652        Ok(applied)
653    }
654
655    /// Persist inner state to a durable snapshot, checkpoint the WAL, and truncate the
656    /// now-snapshotted prefix — bounding log growth. The write gate is held exclusively
657    /// so no mutation is in flight: the snapshot therefore reflects every write up to
658    /// `current_lsn`, making the subsequent truncate crash-safe. A crash at any point
659    /// still recovers (snapshot + idempotent replay of the retained tail). Returns
660    /// `Ok(None)` when snapshots are not enabled.
661    pub async fn snapshot_and_checkpoint(&self) -> Result<Option<u64>> {
662        let Some(mgr) = &self.snapshot_mgr else {
663            return Ok(None);
664        };
665        // Exclusive: block writers so the inner store is quiescent for the snapshot.
666        let _gate = self.write_gate.write().await;
667        let lsn = self.wal.current_lsn();
668        mgr.create_snapshot(&self.inner, Some(format!("wal-checkpoint-{lsn}")))
669            .await?;
670        self.wal.checkpoint()?;
671        let removed = self.wal.truncate(lsn)?;
672        tracing::info!(
673            lsn,
674            segments_removed = removed,
675            "WAL snapshot + checkpoint + truncate complete"
676        );
677        Ok(Some(lsn))
678    }
679}
680
681#[async_trait::async_trait]
682impl<S: VectorStorage> VectorStorage for WalStorage<S> {
683    async fn upsert(&self, namespace: &NamespaceId, vectors: Vec<Vector>) -> Result<usize> {
684        // Shared gate: concurrent with other writes, but excluded during a checkpoint
685        // snapshot so truncation stays crash-safe (DAK-7428).
686        let _gate = self.write_gate.read().await;
687        // Write-ahead: durably log the mutation BEFORE applying it, so a crash
688        // between the log append and the (volatile) apply is recovered by replay.
689        let serialized: Vec<SerializedVector> =
690            vectors.iter().map(SerializedVector::from_vector).collect();
691        self.wal.append(WalEntry::Upsert {
692            namespace: namespace.clone(),
693            vectors: serialized,
694        })?;
695        self.inner.upsert(namespace, vectors).await
696    }
697
698    async fn get(&self, namespace: &NamespaceId, ids: &[VectorId]) -> Result<Vec<Vector>> {
699        self.inner.get(namespace, ids).await
700    }
701
702    async fn get_all(&self, namespace: &NamespaceId) -> Result<Vec<Vector>> {
703        self.inner.get_all(namespace).await
704    }
705
706    async fn get_all_meta(&self, namespace: &NamespaceId) -> Result<Vec<Vector>> {
707        self.inner.get_all_meta(namespace).await
708    }
709
710    async fn delete(&self, namespace: &NamespaceId, ids: &[VectorId]) -> Result<usize> {
711        let _gate = self.write_gate.read().await;
712        self.wal.append(WalEntry::Delete {
713            namespace: namespace.clone(),
714            ids: ids.to_vec(),
715        })?;
716        self.inner.delete(namespace, ids).await
717    }
718
719    async fn namespace_exists(&self, namespace: &NamespaceId) -> Result<bool> {
720        self.inner.namespace_exists(namespace).await
721    }
722
723    async fn ensure_namespace(&self, namespace: &NamespaceId) -> Result<()> {
724        let _gate = self.write_gate.read().await;
725        self.wal.append(WalEntry::CreateNamespace {
726            namespace: namespace.clone(),
727        })?;
728        self.inner.ensure_namespace(namespace).await
729    }
730
731    async fn count(&self, namespace: &NamespaceId) -> Result<usize> {
732        self.inner.count(namespace).await
733    }
734
735    async fn dimension(&self, namespace: &NamespaceId) -> Result<Option<usize>> {
736        self.inner.dimension(namespace).await
737    }
738
739    async fn list_namespaces(&self) -> Result<Vec<NamespaceId>> {
740        self.inner.list_namespaces().await
741    }
742
743    async fn delete_namespace(&self, namespace: &NamespaceId) -> Result<bool> {
744        let _gate = self.write_gate.read().await;
745        self.wal.append(WalEntry::DeleteNamespace {
746            namespace: namespace.clone(),
747        })?;
748        self.inner.delete_namespace(namespace).await
749    }
750
751    async fn cleanup_expired(&self, namespace: &NamespaceId) -> Result<usize> {
752        // TTL expiry is deterministic from vector state, so it is reconstructed by
753        // replay + a subsequent sweep — no need to WAL-log derived cleanups.
754        self.inner.cleanup_expired(namespace).await
755    }
756
757    async fn cleanup_all_expired(&self) -> Result<usize> {
758        self.inner.cleanup_all_expired().await
759    }
760
761    async fn get_page(
762        &self,
763        namespace: &NamespaceId,
764        offset: usize,
765        limit: usize,
766    ) -> Result<Vec<Vector>> {
767        self.inner.get_page(namespace, offset, limit).await
768    }
769
770    async fn reclaim_derived_caches(&self) {
771        self.inner.reclaim_derived_caches().await;
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use tempfile::TempDir;
779
780    fn test_config(dir: &Path) -> WalConfig {
781        WalConfig {
782            wal_dir: dir.to_path_buf(),
783            max_segment_size: 1024,
784            sync_mode: WalSyncMode::EveryWrite,
785            checkpoint_threshold: 100,
786        }
787    }
788
789    fn tv(id: &str) -> Vector {
790        Vector {
791            id: id.to_string(),
792            values: vec![0.1, 0.2, 0.3],
793            metadata: None,
794            ttl_seconds: None,
795            expires_at: None,
796        }
797    }
798
799    // DAK-7428: snapshot + checkpoint + truncate must not lose data across a crash.
800    // Writes before a checkpoint survive via the snapshot; writes after survive via
801    // the retained WAL tail; a post-checkpoint delete stays applied.
802    #[tokio::test]
803    async fn wal_snapshot_checkpoint_recovers_all_data() {
804        use crate::InMemoryStorage;
805        let wal_dir = TempDir::new().unwrap();
806        let snap_dir = TempDir::new().unwrap();
807        let ns = "ns1".to_string();
808        let wal_cfg = test_config(wal_dir.path());
809        let snap_cfg = SnapshotConfig {
810            snapshot_dir: snap_dir.path().to_path_buf(),
811            ..Default::default()
812        };
813
814        // Phase 1: write, checkpoint (snapshot a,b + truncate), then more writes.
815        {
816            let store = WalStorage::new(InMemoryStorage::new(), wal_cfg.clone())
817                .unwrap()
818                .with_snapshots(snap_cfg.clone())
819                .unwrap();
820            store.ensure_namespace(&ns).await.unwrap();
821            store.upsert(&ns, vec![tv("a"), tv("b")]).await.unwrap();
822            store.snapshot_and_checkpoint().await.unwrap();
823            store.upsert(&ns, vec![tv("c")]).await.unwrap();
824            store.delete(&ns, &["a".to_string()]).await.unwrap();
825        }
826
827        // Phase 2: simulate crash/restart — fresh empty inner, same wal + snapshot dirs.
828        let store2 = WalStorage::new(InMemoryStorage::new(), wal_cfg)
829            .unwrap()
830            .with_snapshots(snap_cfg)
831            .unwrap();
832        store2.recover_into_inner().await.unwrap();
833
834        let ids: std::collections::HashSet<String> = store2
835            .get_all(&ns)
836            .await
837            .unwrap()
838            .into_iter()
839            .map(|v| v.id)
840            .collect();
841        assert!(ids.contains("b"), "b (from snapshot) must survive: {ids:?}");
842        assert!(ids.contains("c"), "c (from WAL tail) must survive: {ids:?}");
843        assert!(
844            !ids.contains("a"),
845            "a (deleted post-checkpoint) must stay deleted"
846        );
847    }
848
849    // DAK-7428: crossing checkpoint_threshold must wake the checkpoint driver so
850    // WAL growth is bounded by entry count, not only by the periodic timer.
851    #[tokio::test]
852    async fn checkpoint_threshold_signals_driver() {
853        let dir = TempDir::new().unwrap();
854        let mut cfg = test_config(dir.path());
855        cfg.checkpoint_threshold = 3;
856        let wal = WriteAheadLog::new(cfg).unwrap();
857        let entry = || WalEntry::Upsert {
858            namespace: "n".to_string(),
859            vectors: vec![],
860        };
861        // First two appends stay below the threshold — no signal yet.
862        wal.append(entry()).unwrap();
863        wal.append(entry()).unwrap();
864        assert!(
865            tokio::time::timeout(
866                std::time::Duration::from_millis(50),
867                wal.checkpoint_needed().notified()
868            )
869            .await
870            .is_err(),
871            "must not signal before the threshold is reached"
872        );
873        // Third append crosses the threshold and stores a wake permit.
874        wal.append(entry()).unwrap();
875        tokio::time::timeout(
876            std::time::Duration::from_millis(100),
877            wal.checkpoint_needed().notified(),
878        )
879        .await
880        .expect("crossing checkpoint_threshold must signal the driver");
881    }
882
883    #[test]
884    fn test_wal_basic_operations() {
885        let temp_dir = TempDir::new().unwrap();
886        let config = test_config(temp_dir.path());
887        let wal = WriteAheadLog::new(config).unwrap();
888
889        // Append some entries
890        let lsn1 = wal
891            .append(WalEntry::CreateNamespace {
892                namespace: "test".to_string(),
893            })
894            .unwrap();
895
896        let lsn2 = wal
897            .append(WalEntry::Upsert {
898                namespace: "test".to_string(),
899                vectors: vec![SerializedVector {
900                    id: "v1".to_string(),
901                    values: vec![1.0, 2.0, 3.0],
902                    metadata: None,
903                    ttl_seconds: None,
904                    expires_at: None,
905                }],
906            })
907            .unwrap();
908
909        assert_eq!(lsn1, 0);
910        assert_eq!(lsn2, 1);
911        assert_eq!(wal.current_lsn(), 2);
912    }
913
914    #[test]
915    fn test_wal_recovery() {
916        let temp_dir = TempDir::new().unwrap();
917        let config = test_config(temp_dir.path());
918
919        // Write some entries
920        {
921            let wal = WriteAheadLog::new(config.clone()).unwrap();
922            wal.append(WalEntry::CreateNamespace {
923                namespace: "test".to_string(),
924            })
925            .unwrap();
926            wal.append(WalEntry::Upsert {
927                namespace: "test".to_string(),
928                vectors: vec![SerializedVector {
929                    id: "v1".to_string(),
930                    values: vec![1.0, 2.0],
931                    metadata: None,
932                    ttl_seconds: None,
933                    expires_at: None,
934                }],
935            })
936            .unwrap();
937            wal.flush().unwrap();
938        }
939
940        // Recover entries
941        {
942            let wal = WriteAheadLog::new(config).unwrap();
943            let entries = wal.recover().unwrap();
944
945            assert_eq!(entries.len(), 2);
946            assert!(matches!(entries[0], WalEntry::CreateNamespace { .. }));
947            assert!(matches!(entries[1], WalEntry::Upsert { .. }));
948        }
949    }
950
951    #[test]
952    fn test_wal_checkpoint() {
953        let temp_dir = TempDir::new().unwrap();
954        let config = test_config(temp_dir.path());
955        let wal = WriteAheadLog::new(config).unwrap();
956
957        // Write entries and checkpoint
958        wal.append(WalEntry::CreateNamespace {
959            namespace: "test".to_string(),
960        })
961        .unwrap();
962        let _checkpoint_lsn = wal.checkpoint().unwrap();
963
964        wal.append(WalEntry::Upsert {
965            namespace: "test".to_string(),
966            vectors: vec![],
967        })
968        .unwrap();
969
970        let stats = wal.stats();
971        assert!(stats.last_checkpoint_lsn > 0);
972        assert_eq!(stats.entries_since_checkpoint, 1); // One entry after checkpoint
973    }
974
975    #[test]
976    fn test_wal_stats() {
977        let temp_dir = TempDir::new().unwrap();
978        let config = test_config(temp_dir.path());
979        let wal = WriteAheadLog::new(config).unwrap();
980
981        for i in 0..5 {
982            wal.append(WalEntry::Upsert {
983                namespace: "test".to_string(),
984                vectors: vec![SerializedVector {
985                    id: format!("v{}", i),
986                    values: vec![i as f32],
987                    metadata: None,
988                    ttl_seconds: None,
989                    expires_at: None,
990                }],
991            })
992            .unwrap();
993        }
994
995        let stats = wal.stats();
996        assert_eq!(stats.current_lsn, 5);
997        assert_eq!(stats.entries_since_checkpoint, 5);
998    }
999
1000    #[test]
1001    fn test_segment_rotation() {
1002        let temp_dir = TempDir::new().unwrap();
1003        let config = WalConfig {
1004            wal_dir: temp_dir.path().to_path_buf(),
1005            max_segment_size: 100, // Very small to force rotation
1006            sync_mode: WalSyncMode::EveryWrite,
1007            checkpoint_threshold: 1000,
1008        };
1009
1010        let wal = WriteAheadLog::new(config).unwrap();
1011
1012        // Write enough to trigger rotation
1013        for i in 0..10 {
1014            wal.append(WalEntry::Upsert {
1015                namespace: "test".to_string(),
1016                vectors: vec![SerializedVector {
1017                    id: format!("v{}", i),
1018                    values: vec![i as f32; 10],
1019                    metadata: Some("some metadata here".to_string()),
1020                    ttl_seconds: None,
1021                    expires_at: None,
1022                }],
1023            })
1024            .unwrap();
1025        }
1026
1027        let stats = wal.stats();
1028        assert!(stats.segment_count > 1);
1029    }
1030}