Skip to main content

uni_store/runtime/
wal.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4use crate::store_utils::{
5    DEFAULT_TIMEOUT, delete_with_timeout, get_with_timeout, list_with_timeout, put_with_timeout,
6};
7use anyhow::Result;
8use metrics;
9use object_store::ObjectStore;
10use object_store::path::Path;
11use serde::{Deserialize, Serialize};
12use std::sync::{Arc, Mutex};
13use tracing::{debug, info, instrument, warn};
14use uni_common::Properties;
15use uni_common::core::id::{Eid, Vid};
16use uni_common::sync::acquire_mutex;
17use uuid::Uuid;
18
19/// Parse LSN from WAL segment filename format `{:020}_{uuid}.wal`.
20/// Returns None if the filename doesn't match the expected format.
21fn parse_lsn_from_filename(path: &Path) -> Option<u64> {
22    let filename = path.filename()?;
23    if filename.len() < 20 {
24        return None;
25    }
26    // `str::get(..20)` yields `None` (instead of panicking) when byte index 20
27    // falls mid-UTF-8-character, so a foreign multibyte filename is skipped.
28    filename.get(..20).and_then(|s| s.parse::<u64>().ok())
29}
30
31/// Magic prefix of checksummed (v2) WAL segments.
32///
33/// v2 layout: `UNIWAL2\n<64-hex-char blake3 of payload>\n<payload JSON>`.
34/// Segments without the magic are legacy (pre-2.0.7) raw JSON and are still
35/// readable; they just have no integrity protection.
36const WAL_V2_MAGIC: &[u8] = b"UNIWAL2\n";
37
38/// Length of the hex-encoded blake3 checksum in the v2 header.
39const WAL_V2_HASH_HEX_LEN: usize = 64;
40
41/// Wrap a serialized segment payload in the checksummed v2 envelope.
42fn encode_segment_envelope(payload_json: &[u8]) -> Vec<u8> {
43    let hash = blake3::hash(payload_json);
44    let mut out =
45        Vec::with_capacity(WAL_V2_MAGIC.len() + WAL_V2_HASH_HEX_LEN + 1 + payload_json.len());
46    out.extend_from_slice(WAL_V2_MAGIC);
47    out.extend_from_slice(hash.to_hex().as_bytes());
48    out.push(b'\n');
49    out.extend_from_slice(payload_json);
50    out
51}
52
53/// Decode a WAL segment from its on-disk bytes, verifying the checksum for
54/// v2 envelopes and falling back to legacy raw-JSON parsing otherwise.
55///
56/// Returns a human-readable corruption description on failure — the caller
57/// decides whether that is fatal (corrupt middle segment) or a tolerated
58/// torn tail (see [`WriteAheadLog::replay_since`]).
59///
60/// `pub` + `doc(hidden)` solely so `fuzz/fuzz_targets/wal_decode.rs` can
61/// drive it with arbitrary bytes; it is not part of the public API.
62#[doc(hidden)]
63pub fn decode_segment(bytes: &[u8]) -> std::result::Result<WalSegment, String> {
64    if let Some(rest) = bytes.strip_prefix(WAL_V2_MAGIC) {
65        if rest.len() < WAL_V2_HASH_HEX_LEN + 1 || rest[WAL_V2_HASH_HEX_LEN] != b'\n' {
66            return Err("truncated v2 segment header".to_string());
67        }
68        let (hex, payload_nl) = rest.split_at(WAL_V2_HASH_HEX_LEN);
69        let payload = &payload_nl[1..];
70        let expected =
71            std::str::from_utf8(hex).map_err(|_| "non-utf8 checksum header".to_string())?;
72        let actual = blake3::hash(payload);
73        if actual.to_hex().as_str() != expected {
74            return Err(format!(
75                "checksum mismatch (expected {expected}, computed {})",
76                actual.to_hex()
77            ));
78        }
79        serde_json::from_slice(payload).map_err(|e| format!("v2 payload parse: {e}"))
80    } else {
81        // Legacy (pre-2.0.7) segment: raw JSON, no checksum.
82        serde_json::from_slice(bytes).map_err(|e| format!("legacy segment parse: {e}"))
83    }
84}
85
86/// Test-only fault injection: when set, the next local-store segment fsync is
87/// treated as having failed, exercising the clean-abort delete path (review H3)
88/// without needing a real disk fault. Process-isolated under nextest.
89#[cfg(test)]
90pub(crate) static FAIL_NEXT_FSYNC: std::sync::atomic::AtomicBool =
91    std::sync::atomic::AtomicBool::new(false);
92
93/// Fsync a freshly written file and its parent directory.
94///
95/// The directory fsync makes the new directory entry itself durable across
96/// a crash (pattern borrowed from uni-sidecar's atomic `store_value`).
97///
98/// `pub(crate)` so the snapshot durability barrier (review C4) can reuse the
99/// exact same file+parent fsync the WAL uses for its own segments.
100pub(crate) fn sync_file_and_parent(path: &std::path::Path) -> std::io::Result<()> {
101    std::fs::File::open(path)?.sync_all()?;
102    #[cfg(unix)]
103    if let Some(dir) = path.parent() {
104        std::fs::File::open(dir)?.sync_all()?;
105    }
106    Ok(())
107}
108
109/// Lossless WAL serialization for a property map.
110///
111/// `uni_common::Value` is `#[serde(untagged)]`, so serializing a `Properties`
112/// map straight to serde_json is **lossy**: a `Value::SparseVector` collapses to
113/// a `Map`, a `Value::Vector` to a `List`, nested temporals to strings, etc. —
114/// the variant cannot be recovered on replay. The WAL is a persistence path, so
115/// it must not rely on untagged serde (the exact hazard called out on the
116/// `Value` type).
117///
118/// This module encodes each property value through the explicit, tagged
119/// CypherValue codec (`cypher_value_codec`), stored as a base64 string with a
120/// sentinel prefix. On read, a value carrying the prefix is CV-decoded (lossless,
121/// new format); any other value is a pre-existing legacy segment and is decoded
122/// through the old untagged path unchanged (backward compatible — no WAL version
123/// bump, and old segments behave exactly as before).
124mod cv_props {
125    use base64::Engine;
126    use serde::{Deserialize, Deserializer, Serialize, Serializer};
127    use std::collections::HashMap;
128    use uni_common::{Properties, Value};
129
130    /// Sentinel marking a CV-encoded value. The leading control byte cannot
131    /// begin a legacy untagged-JSON string a user could realistically store.
132    const CV_PREFIX: &str = "\u{1}uni_cv:";
133
134    pub fn serialize<S: Serializer>(props: &Properties, s: S) -> Result<S::Ok, S::Error> {
135        let engine = base64::engine::general_purpose::STANDARD;
136        let encoded: HashMap<&String, String> = props
137            .iter()
138            .map(|(k, v)| {
139                let bytes = uni_common::cypher_value_codec::encode(v);
140                (k, format!("{CV_PREFIX}{}", engine.encode(bytes)))
141            })
142            .collect();
143        encoded.serialize(s)
144    }
145
146    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Properties, D::Error> {
147        let raw: HashMap<String, serde_json::Value> = HashMap::deserialize(d)?;
148        let engine = base64::engine::general_purpose::STANDARD;
149        let mut out = Properties::with_capacity(raw.len());
150        for (k, jv) in raw {
151            let value = match &jv {
152                serde_json::Value::String(s) if s.starts_with(CV_PREFIX) => {
153                    let bytes = engine
154                        .decode(&s[CV_PREFIX.len()..])
155                        .map_err(serde::de::Error::custom)?;
156                    uni_common::cypher_value_codec::decode(&bytes)
157                        .map_err(serde::de::Error::custom)?
158                }
159                // Legacy (pre-CV) segment: an untagged `Value` written directly.
160                // Decode through the old path (lossy for SparseVector/Vector, but
161                // that is exactly the pre-existing behavior for old segments).
162                _ => serde_json::from_value::<Value>(jv).map_err(serde::de::Error::custom)?,
163            };
164            out.insert(k, value);
165        }
166        Ok(out)
167    }
168}
169
170#[derive(Serialize, Deserialize, Debug, Clone)]
171pub enum Mutation {
172    InsertEdge {
173        src_vid: Vid,
174        dst_vid: Vid,
175        edge_type: u32,
176        eid: Eid,
177        version: u64,
178        #[serde(with = "cv_props")]
179        properties: Properties,
180        /// Edge type name for metadata recovery. Optional for backward compatibility.
181        #[serde(default)]
182        edge_type_name: Option<String>,
183    },
184    DeleteEdge {
185        eid: Eid,
186        src_vid: Vid,
187        dst_vid: Vid,
188        edge_type: u32,
189        version: u64,
190    },
191    InsertVertex {
192        vid: Vid,
193        #[serde(with = "cv_props")]
194        properties: Properties,
195        #[serde(default)]
196        labels: Vec<String>,
197    },
198    DeleteVertex {
199        vid: Vid,
200        #[serde(default)]
201        labels: Vec<String>,
202    },
203    /// Replaces a vertex's full label set (a `SET n:Label` / `REMOVE n:Label`
204    /// that touched no properties). Carries the complete resolved label set so
205    /// replay can REPLACE (removals included). Added after the original four
206    /// variants — externally-tagged serde_json, so old WAL segments (which never
207    /// contain it) deserialize unchanged.
208    SetVertexLabels { vid: Vid, labels: Vec<String> },
209}
210
211/// WAL segment with LSN for idempotent recovery
212#[derive(Serialize, Deserialize, Debug, Clone)]
213pub struct WalSegment {
214    /// Log Sequence Number - monotonically increasing per segment
215    pub lsn: u64,
216    /// Mutations in this segment
217    pub mutations: Vec<Mutation>,
218}
219
220/// Borrowed serialization view of [`WalSegment`].
221///
222/// Field names and order match `WalSegment` exactly, so the serde output is
223/// byte-identical; `flush` serializes through this to avoid deep-cloning the
224/// whole mutation batch per flush.
225#[derive(Serialize, Debug)]
226struct WalSegmentRef<'a> {
227    lsn: u64,
228    mutations: &'a [Mutation],
229}
230
231pub struct WriteAheadLog {
232    store: Arc<dyn ObjectStore>,
233    prefix: Path,
234    /// Filesystem root backing `store` when it is a local store. When set,
235    /// every flushed segment is fsync'd (file + parent directory) before the
236    /// flush is reported durable — `object_store::LocalFileSystem` does not
237    /// fsync on `put`, so without this a power loss can drop acknowledged
238    /// commits. `None` for remote stores (the PUT ack is the durability
239    /// point there).
240    local_root: Option<std::path::PathBuf>,
241    state: Mutex<WalState>,
242}
243
244struct WalState {
245    buffer: Vec<Mutation>,
246    /// Current LSN counter (incremented per flush)
247    next_lsn: u64,
248    /// Highest LSN successfully flushed
249    flushed_lsn: u64,
250}
251
252impl WriteAheadLog {
253    pub fn new(store: Arc<dyn ObjectStore>, prefix: Path) -> Self {
254        Self {
255            store,
256            prefix,
257            local_root: None,
258            state: Mutex::new(WalState {
259                buffer: Vec::new(),
260                next_lsn: 1, // Start at 1 so 0 means "no WAL"
261                flushed_lsn: 0,
262            }),
263        }
264    }
265
266    /// Set the local filesystem root backing the object store, enabling
267    /// fsync-on-flush. See the field docs on `local_root`.
268    #[must_use]
269    pub fn with_local_root(mut self, local_root: Option<std::path::PathBuf>) -> Self {
270        self.local_root = local_root;
271        self
272    }
273
274    /// Initialize WAL state from existing segments (called on startup)
275    pub async fn initialize(&self) -> Result<u64> {
276        let max_lsn = self.find_max_lsn().await?;
277        {
278            let mut state = acquire_mutex(&self.state, "wal_state")?;
279            state.next_lsn = max_lsn + 1;
280            state.flushed_lsn = max_lsn;
281        }
282        Ok(max_lsn)
283    }
284
285    /// Find the maximum LSN in existing WAL segments by parsing filenames.
286    /// Only downloads segments if filename parsing fails (fallback).
287    async fn find_max_lsn(&self) -> Result<u64> {
288        let metas = list_with_timeout(&self.store, Some(&self.prefix), DEFAULT_TIMEOUT).await?;
289        let mut max_lsn: u64 = 0;
290
291        for meta in metas {
292            // Try to parse LSN from filename first (fast path)
293            if let Some(lsn) = parse_lsn_from_filename(&meta.location) {
294                max_lsn = max_lsn.max(lsn);
295            } else {
296                // Fallback: download and parse segment if filename doesn't match expected format
297                warn!(
298                    path = %meta.location,
299                    "WAL filename doesn't match expected format, downloading segment"
300                );
301                let get_result =
302                    get_with_timeout(&self.store, &meta.location, DEFAULT_TIMEOUT).await?;
303                let bytes = get_result.bytes().await?;
304                if bytes.is_empty() {
305                    continue;
306                }
307                // This is only a max-LSN probe; a corrupt segment is skipped
308                // here (with a warning) and adjudicated by `replay_since`'s
309                // tail-vs-middle policy during actual recovery.
310                match decode_segment(&bytes) {
311                    Ok(segment) => max_lsn = max_lsn.max(segment.lsn),
312                    Err(reason) => {
313                        warn!(path = %meta.location, reason = %reason,
314                            "Skipping corrupt WAL segment during max-LSN probe");
315                    }
316                }
317            }
318        }
319
320        Ok(max_lsn)
321    }
322
323    #[instrument(skip(self, mutation), level = "trace")]
324    pub fn append(&self, mutation: Mutation) -> Result<()> {
325        let mut state = acquire_mutex(&self.state, "wal_state")?;
326        state.buffer.push(mutation);
327        metrics::counter!("uni_wal_entries_total").increment(1);
328        Ok(())
329    }
330
331    /// Flush buffered mutations to a WAL segment. Returns the LSN of the flushed segment.
332    #[instrument(skip(self), fields(lsn, mutations_count, size_bytes))]
333    pub async fn flush(&self) -> Result<u64> {
334        let start = std::time::Instant::now();
335        let (batch, lsn) = {
336            let mut state = acquire_mutex(&self.state, "wal_state")?;
337            if state.buffer.is_empty() {
338                return Ok(state.flushed_lsn);
339            }
340            let lsn = state.next_lsn;
341            state.next_lsn += 1;
342            (std::mem::take(&mut state.buffer), lsn)
343        };
344
345        tracing::Span::current().record("lsn", lsn);
346        tracing::Span::current().record("mutations_count", batch.len());
347
348        // Serialize a borrowed view of the segment (serde output is identical
349        // to `WalSegment` — see `wal_segment_ref_serializes_identically`), so
350        // the batch is not deep-cloned per flush. `batch` itself stays owned
351        // for the restore-on-failure paths below.
352        let segment = WalSegmentRef {
353            lsn,
354            mutations: &batch,
355        };
356
357        // Serialize segment; restore buffer on failure
358        let json = match serde_json::to_vec(&segment) {
359            Ok(j) => j,
360            Err(e) => {
361                warn!(lsn, error = %e, "Failed to serialize WAL segment, restoring buffer");
362                // Restore buffer on serialization failure
363                let mut state = acquire_mutex(&self.state, "wal_state")?;
364                let new_mutations = std::mem::take(&mut state.buffer);
365                state.buffer = batch;
366                state.buffer.extend(new_mutations);
367                // Don't roll back LSN - gap is harmless and maintains monotonicity
368                return Err(e.into());
369            }
370        };
371        // Wrap in the checksummed v2 envelope so torn/corrupt segments are
372        // detected at replay instead of surfacing as opaque parse errors.
373        let body = encode_segment_envelope(&json);
374        tracing::Span::current().record("size_bytes", body.len());
375        metrics::counter!("uni_wal_bytes_written_total").increment(body.len() as u64);
376
377        // Include LSN in filename for easy ordering and identification
378        let filename = format!("{:020}_{}.wal", lsn, Uuid::new_v4());
379        let path = self.prefix.clone().join(filename);
380
381        // Attempt to write; restore buffer on failure to prevent data loss
382        if let Err(e) = put_with_timeout(&self.store, &path, body.into(), DEFAULT_TIMEOUT).await {
383            warn!(
384                lsn,
385                error = %e,
386                "Failed to flush WAL segment, restoring buffer (LSN gap preserved for monotonicity)"
387            );
388            // Restore buffer so data isn't lost on transient failures
389            let mut state = acquire_mutex(&self.state, "wal_state")?;
390            // Prepend the failed batch to any new mutations that arrived
391            let new_mutations = std::mem::take(&mut state.buffer);
392            state.buffer = batch;
393            state.buffer.extend(new_mutations);
394            // Don't roll back LSN - gap is harmless and maintains strict monotonicity
395            // All WAL consumers use `>` / `<=` comparisons, not equality checks
396            return Err(e);
397        }
398
399        // Local stores: fsync the segment + its directory before reporting
400        // the flush durable. On fsync failure we report failure (durability
401        // cannot be guaranteed) AND delete the just-written segment: the bytes
402        // are already on disk, so leaving them would let a later crash + replay
403        // resurrect a transaction the caller was told had FAILED (ghost commit).
404        // `flushed_lsn` is intentionally left un-advanced. (review H3)
405        if let Some(root) = &self.local_root {
406            let file_path = root.join(path.as_ref());
407            #[cfg(test)]
408            let synced = if FAIL_NEXT_FSYNC.swap(false, std::sync::atomic::Ordering::SeqCst) {
409                Ok(Err(std::io::Error::other("injected fsync failure")))
410            } else {
411                tokio::task::spawn_blocking(move || sync_file_and_parent(&file_path)).await
412            };
413            #[cfg(not(test))]
414            let synced =
415                tokio::task::spawn_blocking(move || sync_file_and_parent(&file_path)).await;
416            let fsync_err: Option<anyhow::Error> = match synced {
417                Ok(Ok(())) => None,
418                Ok(Err(e)) => Some(e.into()),
419                Err(e) => Some(e.into()),
420            };
421            if let Some(err) = fsync_err {
422                warn!(
423                    lsn,
424                    error = %err,
425                    "WAL segment fsync failed — deleting the non-durable segment to avoid a ghost commit on replay"
426                );
427                // Best-effort clean abort. If the delete ALSO fails the WAL is
428                // in an indeterminate state (a non-durable segment may survive
429                // and replay); surface that as a hard error rather than a
430                // routine flush failure so the caller does not silently retry.
431                if let Err(del_err) = delete_with_timeout(&self.store, &path, DEFAULT_TIMEOUT).await
432                {
433                    return Err(anyhow::anyhow!(
434                        "WAL segment fsync failed ({err}) and the cleanup delete \
435                         of segment at lsn {lsn} also failed ({del_err}); the WAL \
436                         may contain a non-durable segment"
437                    ));
438                }
439                return Err(err);
440            }
441        }
442
443        // Update flushed LSN on success
444        {
445            let mut state = acquire_mutex(&self.state, "wal_state")?;
446            state.flushed_lsn = lsn;
447        }
448
449        let duration = start.elapsed();
450        metrics::histogram!("wal_flush_latency_ms").record(duration.as_millis() as f64);
451        metrics::histogram!("uni_wal_flush_duration_seconds").record(duration.as_secs_f64());
452
453        if duration.as_millis() > 100 {
454            warn!(
455                lsn,
456                duration_ms = duration.as_millis(),
457                "Slow WAL flush detected"
458            );
459        } else {
460            debug!(
461                lsn,
462                duration_ms = duration.as_millis(),
463                "WAL flush completed"
464            );
465        }
466
467        Ok(lsn)
468    }
469
470    /// Get the highest LSN that has been flushed.
471    ///
472    /// # Errors
473    ///
474    /// Returns error if the WAL state lock is poisoned (see issue #18/#150).
475    pub fn flushed_lsn(&self) -> Result<u64, uni_common::sync::LockPoisonedError> {
476        let guard = uni_common::sync::acquire_mutex(&self.state, "wal_state")?;
477        Ok(guard.flushed_lsn)
478    }
479
480    /// Replay WAL segments with LSN > high_water_mark.
481    /// Returns mutations from segments that haven't been applied yet.
482    /// Optimized to skip downloading segments with LSN <= high_water_mark (parsed from filename).
483    ///
484    /// Corruption policy: a corrupt (bad checksum / unparseable / empty)
485    /// segment at the **tail** of the log is the classic torn write from a
486    /// crash — it is logged prominently and treated as end-of-WAL, since the
487    /// commit it belonged to was never acknowledged. A corrupt segment with
488    /// valid segments **after** it is real data loss and fails recovery with
489    /// an error naming the file.
490    #[instrument(skip(self), level = "debug")]
491    pub async fn replay_since(&self, high_water_mark: u64) -> Result<Vec<Mutation>> {
492        let start = std::time::Instant::now();
493        debug!(high_water_mark, "Replaying WAL segments");
494        let metas = list_with_timeout(&self.store, Some(&self.prefix), DEFAULT_TIMEOUT).await?;
495        let mut mutations = Vec::new();
496
497        // Collect candidate paths and sort by LSN (filename prefix).
498        // Lexicographical sort works for the zero-padded LSN prefix.
499        let mut paths: Vec<_> = metas
500            .into_iter()
501            .map(|m| m.location)
502            .filter(|p| {
503                // Skip segments identifiable as <= high_water_mark without
504                // downloading. Unparseable filenames stay in (legacy safety).
505                parse_lsn_from_filename(p).is_none_or(|lsn| lsn > high_water_mark)
506            })
507            .collect();
508        paths.sort();
509
510        let mut segments_replayed = 0;
511
512        for (idx, path) in paths.iter().enumerate() {
513            // A segment can disappear between the listing above and this read.
514            // Two benign, unavoidable causes:
515            //   * a flush finalizer's `truncate_before` (writer.rs step K) is
516            //     deleting segments concurrently. `Drop for Uni` only *signals*
517            //     shutdown (`ShutdownHandle::shutdown_blocking` sends on a
518            //     channel and returns without joining), so a spawned finalizer
519            //     outlives the handle and can still be truncating while the next
520            //     open replays.
521            //   * an eventually-consistent object store returns an
522            //     already-deleted key in a listing.
523            //
524            // Skipping costs no durability: `truncate_before` only deletes
525            // segments whose mutations are already in L1, so a vanished segment
526            // carries nothing that is not recovered anyway — and a listing taken
527            // microseconds later would simply not have included it. Propagating
528            // instead turns the race into a failed database open.
529            //
530            // ONLY `NotFound` is skipped. Any other read error still fails
531            // recovery: treating a transient blip as "skip" would silently drop
532            // committed mutations. Pinned by
533            // `replay_still_fails_on_non_notfound_read_error`.
534            let get_result = match get_with_timeout(&self.store, path, DEFAULT_TIMEOUT).await {
535                Ok(result) => result,
536                Err(e) if crate::store_utils::is_not_found(&e) => {
537                    warn!(
538                        path = %path,
539                        "WAL segment vanished between listing and read (concurrent \
540                         truncation); skipping — its mutations are already durable in L1"
541                    );
542                    continue;
543                }
544                Err(e) => return Err(e),
545            };
546            let bytes = get_result.bytes().await?;
547
548            // Empty files and decode failures share one corruption policy.
549            let decoded = if bytes.is_empty() {
550                Err("empty segment file".to_string())
551            } else {
552                decode_segment(&bytes)
553            };
554
555            let segment = match decoded {
556                Ok(segment) => segment,
557                Err(reason) => {
558                    let is_tail = idx + 1 == paths.len();
559                    if is_tail {
560                        warn!(
561                            path = %path,
562                            reason = %reason,
563                            "Corrupt tail WAL segment — torn write from a crash; \
564                             treating as end of WAL (the commit was never acknowledged)"
565                        );
566                        break;
567                    }
568                    return Err(anyhow::anyhow!(
569                        "corrupt WAL segment '{path}' ({reason}) with {} later segment(s) \
570                         present; refusing to skip — manual inspection required",
571                        paths.len() - idx - 1
572                    ));
573                }
574            };
575
576            // Double-check LSN from segment content (handles fallback case)
577            if segment.lsn > high_water_mark {
578                mutations.extend(segment.mutations);
579                segments_replayed += 1;
580            }
581        }
582
583        info!(
584            segments_replayed,
585            mutations_count = mutations.len(),
586            "WAL replay completed"
587        );
588        metrics::histogram!("uni_wal_replay_duration_seconds")
589            .record(start.elapsed().as_secs_f64());
590
591        Ok(mutations)
592    }
593
594    /// Replay all WAL segments.
595    pub async fn replay(&self) -> Result<Vec<Mutation>> {
596        self.replay_since(0).await
597    }
598
599    /// Delete one WAL segment, treating "already absent" as success.
600    ///
601    /// `truncate_before` and `truncate` list segments and then delete them one
602    /// at a time. Between the listing and any given delete, another truncation
603    /// can remove the same segment: the flush finalizer runs `truncate_before`
604    /// on a spawned task (`writer.rs` step K) which outlives the `Uni` handle,
605    /// because `Drop for Uni` only *signals* shutdown
606    /// (`ShutdownHandle::shutdown_blocking` sends on a channel and returns
607    /// without joining). Reopening the same directory therefore gives two live
608    /// truncators, and the loser of the race previously failed its whole flush
609    /// with `Object at location ... not found`.
610    ///
611    /// A delete whose object is already gone has achieved its postcondition, so
612    /// treating it as an error is wrong. Only `NotFound` is tolerated — every
613    /// other delete error still propagates, so a permissions or I/O fault is
614    /// never mistaken for "already truncated".
615    ///
616    /// Returns `true` when this call is the one that removed the object, so
617    /// `deleted_count` stays an honest count rather than a count of attempts.
618    async fn delete_segment_if_present(&self, path: &Path) -> Result<bool> {
619        match delete_with_timeout(&self.store, path, DEFAULT_TIMEOUT).await {
620            Ok(()) => Ok(true),
621            Err(e) if crate::store_utils::is_not_found(&e) => {
622                debug!(
623                    path = %path,
624                    "WAL segment already removed by a concurrent truncation; nothing to do"
625                );
626                Ok(false)
627            }
628            Err(e) => Err(e),
629        }
630    }
631
632    /// Deletes WAL segments with LSN <= high_water_mark by parsing filenames.
633    /// Only downloads segments if filename parsing fails (fallback).
634    #[instrument(skip(self), level = "info")]
635    pub async fn truncate_before(&self, high_water_mark: u64) -> Result<()> {
636        info!(high_water_mark, "Truncating WAL segments");
637        let metas = list_with_timeout(&self.store, Some(&self.prefix), DEFAULT_TIMEOUT).await?;
638
639        let mut deleted_count = 0;
640        for meta in metas {
641            // Try to parse LSN from filename first (fast path)
642            let should_delete = if let Some(lsn) = parse_lsn_from_filename(&meta.location) {
643                lsn <= high_water_mark
644            } else {
645                // Fallback: download and parse segment if filename doesn't match expected format
646                warn!(
647                    path = %meta.location,
648                    "WAL filename doesn't match expected format, downloading segment"
649                );
650                let get_result =
651                    get_with_timeout(&self.store, &meta.location, DEFAULT_TIMEOUT).await?;
652                let bytes = get_result.bytes().await?;
653                if bytes.is_empty() {
654                    // Empty segments should be deleted
655                    true
656                } else {
657                    match decode_segment(&bytes) {
658                        Ok(segment) => segment.lsn <= high_water_mark,
659                        Err(reason) => {
660                            // Never delete a corrupt segment during
661                            // truncation — keep the evidence; replay's
662                            // tail-vs-middle policy adjudicates it.
663                            warn!(path = %meta.location, reason = %reason,
664                                "Keeping corrupt WAL segment during truncation");
665                            false
666                        }
667                    }
668                }
669            };
670
671            if should_delete && self.delete_segment_if_present(&meta.location).await? {
672                deleted_count += 1;
673            }
674        }
675        info!(deleted_count, "WAL truncation completed");
676        Ok(())
677    }
678
679    /// Check if any WAL segments exist (for detecting database with lost manifest).
680    pub async fn has_segments(&self) -> Result<bool> {
681        let metas = list_with_timeout(&self.store, Some(&self.prefix), DEFAULT_TIMEOUT).await?;
682        Ok(!metas.is_empty())
683    }
684
685    pub async fn truncate(&self) -> Result<()> {
686        info!("Truncating all WAL segments");
687        let metas = list_with_timeout(&self.store, Some(&self.prefix), DEFAULT_TIMEOUT).await?;
688
689        let mut deleted_count = 0;
690        for meta in metas {
691            if self.delete_segment_if_present(&meta.location).await? {
692                deleted_count += 1;
693            }
694        }
695        info!(deleted_count, "Full WAL truncation completed");
696        Ok(())
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use object_store::ObjectStoreExt;
704    use object_store::local::LocalFileSystem;
705    use std::collections::HashMap;
706    use tempfile::tempdir;
707
708    #[tokio::test]
709    async fn test_wal_append_replay() -> Result<()> {
710        let dir = tempdir()?;
711        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
712        let prefix = Path::from("wal");
713
714        let wal = WriteAheadLog::new(store, prefix);
715
716        let mutation = Mutation::InsertVertex {
717            vid: Vid::new(1),
718            properties: HashMap::new(),
719            labels: vec![],
720        };
721
722        wal.append(mutation)?;
723        wal.flush().await?;
724
725        let mutations = wal.replay().await?;
726        assert_eq!(mutations.len(), 1);
727        if let Mutation::InsertVertex { vid, .. } = &mutations[0] {
728            assert_eq!(vid.as_u64(), Vid::new(1).as_u64());
729        } else {
730            panic!("Wrong mutation type");
731        }
732
733        wal.truncate().await?;
734        let mutations2 = wal.replay().await?;
735        assert_eq!(mutations2.len(), 0);
736
737        Ok(())
738    }
739
740    #[tokio::test]
741    async fn test_lsn_monotonicity() -> Result<()> {
742        // Verify that LSN is strictly monotonic even across multiple flushes
743        let dir = tempdir()?;
744        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
745        let prefix = Path::from("wal");
746
747        let wal = WriteAheadLog::new(store, prefix);
748
749        let mutation1 = Mutation::InsertVertex {
750            vid: Vid::new(1),
751            properties: HashMap::new(),
752            labels: vec![],
753        };
754        let mutation2 = Mutation::InsertVertex {
755            vid: Vid::new(2),
756            properties: HashMap::new(),
757            labels: vec![],
758        };
759        let mutation3 = Mutation::InsertVertex {
760            vid: Vid::new(3),
761            properties: HashMap::new(),
762            labels: vec![],
763        };
764
765        // First flush
766        wal.append(mutation1)?;
767        let lsn1 = wal.flush().await?;
768
769        // Second flush
770        wal.append(mutation2)?;
771        let lsn2 = wal.flush().await?;
772
773        // Third flush
774        wal.append(mutation3)?;
775        let lsn3 = wal.flush().await?;
776
777        // Verify strict monotonicity
778        assert!(lsn2 > lsn1, "LSN2 ({}) should be > LSN1 ({})", lsn2, lsn1);
779        assert!(lsn3 > lsn2, "LSN3 ({}) should be > LSN2 ({})", lsn3, lsn2);
780
781        // Verify LSNs are consecutive
782        assert_eq!(lsn2, lsn1 + 1);
783        assert_eq!(lsn3, lsn2 + 1);
784
785        Ok(())
786    }
787
788    /// H3: when a segment's fsync fails, the just-written (non-durable) segment
789    /// must be deleted so a later crash + replay cannot resurrect a transaction
790    /// the caller was told had failed (ghost commit). `flush()` reports failure.
791    #[tokio::test]
792    async fn fsync_failure_deletes_segment_no_ghost_commit() -> Result<()> {
793        let dir = tempdir()?;
794        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
795        let prefix = Path::from("wal");
796        // local_root must be set for the fsync barrier (and thus the H3 path) to run.
797        let wal = WriteAheadLog::new(store, prefix).with_local_root(Some(dir.path().to_path_buf()));
798
799        wal.append(Mutation::InsertVertex {
800            vid: Vid::new(1),
801            properties: HashMap::new(),
802            labels: vec![],
803        })?;
804
805        // Force the next segment fsync to fail.
806        FAIL_NEXT_FSYNC.store(true, std::sync::atomic::Ordering::SeqCst);
807        let result = wal.flush().await;
808        assert!(
809            result.is_err(),
810            "flush must report failure when the segment fsync fails"
811        );
812
813        // The non-durable segment must have been deleted: replay surfaces nothing.
814        let replayed = wal.replay().await?;
815        assert!(
816            replayed.is_empty(),
817            "a segment whose fsync failed must not be replayable (ghost commit); got {} mutations",
818            replayed.len()
819        );
820        Ok(())
821    }
822
823    #[test]
824    fn test_parse_lsn_from_filename() {
825        // Standard format
826        let path = Path::from("00000000000000000042_a1b2c3d4.wal");
827        assert_eq!(parse_lsn_from_filename(&path), Some(42));
828
829        let path = Path::from("00000000000000001234_e5f6a7b8.wal");
830        assert_eq!(parse_lsn_from_filename(&path), Some(1234));
831
832        // Leading zeros
833        let path = Path::from("00000000000000000001_xyz.wal");
834        assert_eq!(parse_lsn_from_filename(&path), Some(1));
835
836        // Large LSN (within u64 range)
837        let path = Path::from("12345678901234567890_uuid.wal");
838        assert_eq!(parse_lsn_from_filename(&path), Some(12345678901234567890));
839
840        // Invalid formats
841        let path = Path::from("invalid.wal");
842        assert_eq!(parse_lsn_from_filename(&path), None);
843
844        let path = Path::from("123.wal"); // Too short
845        assert_eq!(parse_lsn_from_filename(&path), None);
846
847        let path = Path::from("abcdefghijklmnopqrst_uuid.wal"); // Non-numeric
848        assert_eq!(parse_lsn_from_filename(&path), None);
849
850        // Missing underscore separator (but first 20 chars are valid LSN)
851        let path = Path::from("00000000000000000100.wal");
852        assert_eq!(parse_lsn_from_filename(&path), Some(100));
853
854        // Empty path
855        let path = Path::from("");
856        assert_eq!(parse_lsn_from_filename(&path), None);
857    }
858
859    /// Regression for Bug #30: `parse_lsn_from_filename` must not panic on a
860    /// filename whose byte 20 falls in the middle of a multi-byte UTF-8 char.
861    ///
862    /// The length guard uses [`str::len`] (byte length), but the subsequent
863    /// `filename[..20]` slice requires byte 20 to be a char boundary. A name of
864    /// 19 ASCII bytes plus one 2-byte char ('é', bytes 19..21) has a byte length
865    /// of at least 20, passes the guard, then slices mid-'é' and panics today.
866    /// The correct behavior is to return `None` for a non-numeric/unparsable name.
867    ///
868    /// We use [`Path::parse`] (not [`Path::from`]) because `from` percent-encodes
869    /// non-ASCII so that `filename()` would be pure ASCII and never reach the
870    /// mid-char slice; `parse` preserves the raw multi-byte segment verbatim,
871    /// which is exactly what a real on-disk listing can surface.
872    #[test]
873    fn test_parse_lsn_from_filename_multibyte_no_panic() {
874        let name = format!("{}{}.wal", "0".repeat(19), "é"); // byte 20 falls mid-'é'
875        let path = Path::parse(name).expect("multi-byte segment is a valid object_store path");
876        assert_eq!(parse_lsn_from_filename(&path), None); // RED: panics today; correct = None
877    }
878
879    /// Test for Issue #6: WAL initialization should parse LSN from filenames
880    /// without downloading all segments
881    #[tokio::test]
882    async fn test_find_max_lsn_scalability() -> Result<()> {
883        let dir = tempdir()?;
884        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
885        let prefix = Path::from("wal");
886
887        let wal = WriteAheadLog::new(store, prefix);
888
889        // Create 100 WAL segments with increasing LSNs
890        for i in 1..=100 {
891            let mutation = Mutation::InsertVertex {
892                vid: Vid::new(i),
893                properties: HashMap::new(),
894                labels: vec![],
895            };
896            wal.append(mutation)?;
897            wal.flush().await?;
898        }
899
900        // Measure initialization time - should be fast (parsing filenames, not downloading)
901        let start = std::time::Instant::now();
902        let max_lsn = wal.find_max_lsn().await?;
903        let duration = start.elapsed();
904
905        // Verify correctness
906        assert_eq!(max_lsn, 100, "Max LSN should be 100");
907
908        // Verify performance - should complete quickly even with many segments
909        assert!(
910            duration.as_millis() < 1000,
911            "find_max_lsn took {}ms, expected < 1000ms (filename parsing should be fast)",
912            duration.as_millis()
913        );
914
915        Ok(())
916    }
917
918    /// Test for Issue #11: LSN gaps are preserved on flush failures (watermark pattern)
919    #[tokio::test]
920    async fn test_lsn_gaps_preserved_on_flush_failure() -> Result<()> {
921        let dir = tempdir()?;
922        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
923        let prefix = Path::from("wal");
924
925        let wal = WriteAheadLog::new(store.clone(), prefix.clone());
926
927        // Flush mutation 1 successfully
928        wal.append(Mutation::InsertVertex {
929            vid: Vid::new(1),
930            properties: HashMap::new(),
931            labels: vec![],
932        })?;
933        let lsn1 = wal.flush().await?;
934        assert_eq!(lsn1, 1);
935
936        // Flush mutation 2 successfully
937        wal.append(Mutation::InsertVertex {
938            vid: Vid::new(2),
939            properties: HashMap::new(),
940            labels: vec![],
941        })?;
942        let lsn2 = wal.flush().await?;
943        assert_eq!(lsn2, 2);
944
945        // Simulate a scenario where flush might fail by creating a read-only store
946        // (In real scenario, network failures would cause this)
947        // For now, verify that LSN assignment happens BEFORE write attempt
948        // by checking that next_lsn increments even if we don't flush
949
950        // Append mutation 3 but DON'T flush
951        wal.append(Mutation::InsertVertex {
952            vid: Vid::new(3),
953            properties: HashMap::new(),
954            labels: vec![],
955        })?;
956
957        // Now flush mutation 4
958        wal.append(Mutation::InsertVertex {
959            vid: Vid::new(4),
960            properties: HashMap::new(),
961            labels: vec![],
962        })?;
963        let lsn4 = wal.flush().await?;
964
965        // LSN should be 3 (both mutations 3 and 4 flushed together)
966        assert_eq!(lsn4, 3, "LSN should increment monotonically");
967
968        // Verify all mutations can be replayed
969        let mutations = wal.replay().await?;
970        assert_eq!(mutations.len(), 4, "All 4 mutations should be replayed");
971
972        Ok(())
973    }
974
975    /// Test for Issue #11: Verify LSN watermark pattern - no LSN reuse
976    #[tokio::test]
977    async fn test_lsn_watermark_no_reuse() -> Result<()> {
978        let dir = tempdir()?;
979        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
980        let prefix = Path::from("wal");
981
982        let wal = WriteAheadLog::new(store, prefix);
983
984        // Track all LSNs we've seen
985        let mut seen_lsns = std::collections::HashSet::new();
986
987        // Perform 50 flushes
988        for i in 1..=50 {
989            wal.append(Mutation::InsertVertex {
990                vid: Vid::new(i),
991                properties: HashMap::new(),
992                labels: vec![],
993            })?;
994            let lsn = wal.flush().await?;
995
996            // Verify no LSN reuse
997            assert!(
998                !seen_lsns.contains(&lsn),
999                "LSN {} was reused! This violates monotonicity.",
1000                lsn
1001            );
1002            seen_lsns.insert(lsn);
1003
1004            // Verify LSN is strictly increasing
1005            assert_eq!(lsn, i, "LSN should be {}, got {}", i, lsn);
1006        }
1007
1008        Ok(())
1009    }
1010
1011    /// Test for Issue #33: WAL truncation should parse LSN from filenames
1012    /// without downloading all segments
1013    #[tokio::test]
1014    async fn test_truncate_scalability() -> Result<()> {
1015        let dir = tempdir()?;
1016        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
1017        let prefix = Path::from("wal");
1018
1019        let wal = WriteAheadLog::new(store, prefix);
1020
1021        // Create 100 WAL segments
1022        for i in 1..=100 {
1023            let mutation = Mutation::InsertVertex {
1024                vid: Vid::new(i),
1025                properties: HashMap::new(),
1026                labels: vec![],
1027            };
1028            wal.append(mutation)?;
1029            wal.flush().await?;
1030        }
1031
1032        // Truncate segments with LSN <= 50
1033        let start = std::time::Instant::now();
1034        wal.truncate_before(50).await?;
1035        let duration = start.elapsed();
1036
1037        // Verify only segments 51-100 remain
1038        let mutations = wal.replay().await?;
1039        assert_eq!(
1040            mutations.len(),
1041            50,
1042            "Should have 50 mutations remaining (51-100)"
1043        );
1044
1045        // Verify performance - should be fast (filename parsing, not downloading)
1046        assert!(
1047            duration.as_millis() < 1000,
1048            "truncate_before took {}ms, expected < 1000ms (filename parsing should be fast)",
1049            duration.as_millis()
1050        );
1051
1052        Ok(())
1053    }
1054
1055    /// Test for Issue #6: replay_since should skip old segments by filename
1056    #[tokio::test]
1057    async fn test_replay_since_skips_old_segments() -> Result<()> {
1058        let dir = tempdir()?;
1059        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
1060        let prefix = Path::from("wal");
1061
1062        let wal = WriteAheadLog::new(store, prefix);
1063
1064        // Create 100 WAL segments
1065        for i in 1..=100 {
1066            let mutation = Mutation::InsertVertex {
1067                vid: Vid::new(i),
1068                properties: HashMap::new(),
1069                labels: vec![],
1070            };
1071            wal.append(mutation)?;
1072            wal.flush().await?;
1073        }
1074
1075        // Replay only segments with LSN > 90 (should skip 90 segments by filename)
1076        let start = std::time::Instant::now();
1077        let mutations = wal.replay_since(90).await?;
1078        let duration = start.elapsed();
1079
1080        // Verify only 10 mutations returned (LSN 91-100)
1081        assert_eq!(mutations.len(), 10, "Should replay only LSNs 91-100");
1082
1083        // Verify performance - should be fast (skips 90 segments by filename)
1084        assert!(
1085            duration.as_millis() < 500,
1086            "replay_since took {}ms, expected < 500ms (should skip by filename)",
1087            duration.as_millis()
1088        );
1089
1090        Ok(())
1091    }
1092
1093    /// Test for Issue #23: Vertex labels preserved through WAL replay
1094    #[tokio::test]
1095    async fn test_wal_replay_preserves_vertex_labels() -> Result<()> {
1096        let dir = tempdir()?;
1097        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
1098        let prefix = Path::from("wal");
1099
1100        let wal = Arc::new(WriteAheadLog::new(store, prefix));
1101
1102        // Append InsertVertex with labels
1103        wal.append(Mutation::InsertVertex {
1104            vid: Vid::new(42),
1105            properties: {
1106                let mut props = HashMap::new();
1107                props.insert(
1108                    "name".to_string(),
1109                    uni_common::Value::String("Alice".to_string()),
1110                );
1111                props
1112            },
1113            labels: vec!["Person".to_string(), "User".to_string()],
1114        })?;
1115
1116        // Flush to WAL
1117        wal.flush().await?;
1118
1119        // Replay mutations
1120        let mutations = wal.replay().await?;
1121        assert_eq!(mutations.len(), 1);
1122
1123        // Verify labels are preserved
1124        if let Mutation::InsertVertex { vid, labels, .. } = &mutations[0] {
1125            assert_eq!(vid.as_u64(), 42);
1126            assert_eq!(labels.len(), 2);
1127            assert!(labels.contains(&"Person".to_string()));
1128            assert!(labels.contains(&"User".to_string()));
1129        } else {
1130            panic!("Expected InsertVertex mutation");
1131        }
1132
1133        Ok(())
1134    }
1135
1136    /// Test for Issue #23: DeleteVertex labels preserved through WAL replay
1137    #[tokio::test]
1138    async fn test_wal_replay_preserves_delete_vertex_labels() -> Result<()> {
1139        let dir = tempdir()?;
1140        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
1141        let prefix = Path::from("wal");
1142
1143        let wal = Arc::new(WriteAheadLog::new(store, prefix));
1144
1145        // Append DeleteVertex with labels (needed for tombstone flushing - Issue #76)
1146        wal.append(Mutation::DeleteVertex {
1147            vid: Vid::new(99),
1148            labels: vec!["Person".to_string(), "Admin".to_string()],
1149        })?;
1150
1151        // Flush to WAL
1152        wal.flush().await?;
1153
1154        // Replay mutations
1155        let mutations = wal.replay().await?;
1156        assert_eq!(mutations.len(), 1);
1157
1158        // Verify labels are preserved in DeleteVertex
1159        if let Mutation::DeleteVertex { vid, labels } = &mutations[0] {
1160            assert_eq!(vid.as_u64(), 99);
1161            assert_eq!(labels.len(), 2);
1162            assert!(labels.contains(&"Person".to_string()));
1163            assert!(labels.contains(&"Admin".to_string()));
1164        } else {
1165            panic!("Expected DeleteVertex mutation");
1166        }
1167
1168        Ok(())
1169    }
1170
1171    /// Test for Issue #28: Edge type name preserved through WAL replay
1172    #[tokio::test]
1173    async fn test_wal_replay_preserves_edge_type_name() -> Result<()> {
1174        let dir = tempdir()?;
1175        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
1176        let prefix = Path::from("wal");
1177
1178        let wal = Arc::new(WriteAheadLog::new(store, prefix));
1179
1180        // Append InsertEdge with edge_type_name
1181        wal.append(Mutation::InsertEdge {
1182            src_vid: Vid::new(1),
1183            dst_vid: Vid::new(2),
1184            edge_type: 100,
1185            eid: Eid::new(500),
1186            version: 1,
1187            properties: {
1188                let mut props = HashMap::new();
1189                props.insert("since".to_string(), uni_common::Value::Int(2020));
1190                props
1191            },
1192            edge_type_name: Some("KNOWS".to_string()),
1193        })?;
1194
1195        // Flush to WAL
1196        wal.flush().await?;
1197
1198        // Replay mutations
1199        let mutations = wal.replay().await?;
1200        assert_eq!(mutations.len(), 1);
1201
1202        // Verify edge_type_name is preserved
1203        if let Mutation::InsertEdge {
1204            eid,
1205            edge_type_name,
1206            ..
1207        } = &mutations[0]
1208        {
1209            assert_eq!(eid.as_u64(), 500);
1210            assert_eq!(edge_type_name.as_deref(), Some("KNOWS"));
1211        } else {
1212            panic!("Expected InsertEdge mutation");
1213        }
1214
1215        Ok(())
1216    }
1217
1218    /// Test for Issue #23: Backward compatibility with old WAL segments (no labels)
1219    #[tokio::test]
1220    async fn test_wal_backward_compatibility_labels() -> Result<()> {
1221        let dir = tempdir()?;
1222        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
1223        let prefix = Path::from("wal");
1224
1225        // Manually create a WAL segment with old format (no labels field)
1226        let old_format_json = r#"{
1227            "lsn": 1,
1228            "mutations": [
1229                {
1230                    "InsertVertex": {
1231                        "vid": 123,
1232                        "properties": {}
1233                    }
1234                }
1235            ]
1236        }"#;
1237
1238        let path = prefix.clone().join("00000000000000000001_test.wal");
1239        store.put(&path, old_format_json.into()).await?;
1240
1241        // Create WAL and replay
1242        let wal = WriteAheadLog::new(store, prefix);
1243        let mutations = wal.replay().await?;
1244
1245        // Verify old format deserializes with empty labels (via #[serde(default)])
1246        assert_eq!(mutations.len(), 1);
1247        if let Mutation::InsertVertex { vid, labels, .. } = &mutations[0] {
1248            assert_eq!(vid.as_u64(), 123);
1249            assert_eq!(
1250                labels.len(),
1251                0,
1252                "Old format should deserialize with empty labels"
1253            );
1254        } else {
1255            panic!("Expected InsertVertex mutation");
1256        }
1257
1258        Ok(())
1259    }
1260
1261    /// `flush` serializes through the borrowed `WalSegmentRef`; its bytes must
1262    /// be identical to the owned `WalSegment` so replay (which deserializes
1263    /// `WalSegment`) is unaffected.
1264    #[test]
1265    fn wal_segment_ref_serializes_identically() {
1266        let mut props = HashMap::new();
1267        props.insert("p".to_string(), uni_common::Value::Int(7));
1268        let mutations = vec![
1269            Mutation::InsertVertex {
1270                vid: Vid::new(1),
1271                properties: props,
1272                labels: vec!["L".to_string()],
1273            },
1274            Mutation::DeleteEdge {
1275                eid: Eid::new(2),
1276                src_vid: Vid::new(1),
1277                dst_vid: Vid::new(3),
1278                edge_type: 4,
1279                version: 5,
1280            },
1281        ];
1282        let owned = WalSegment {
1283            lsn: 42,
1284            mutations: mutations.clone(),
1285        };
1286        let borrowed = WalSegmentRef {
1287            lsn: 42,
1288            mutations: &mutations,
1289        };
1290        assert_eq!(
1291            serde_json::to_vec(&owned).unwrap(),
1292            serde_json::to_vec(&borrowed).unwrap()
1293        );
1294    }
1295
1296    /// An `ObjectStore` that lists a chosen path but returns `NotFound` when it
1297    /// is read — modelling the window between `replay_since`'s listing and its
1298    /// per-segment `get`.
1299    ///
1300    /// Two real situations produce exactly this, and neither is a fault:
1301    ///   * a flush finalizer's `truncate_before` (`writer.rs` step K) deleting a
1302    ///     segment concurrently — `Drop for Uni` only *signals* shutdown
1303    ///     (`ShutdownHandle::shutdown_blocking` sends on a channel and returns),
1304    ///     so a spawned finalizer outlives the handle and can still be deleting
1305    ///     while the next open replays;
1306    ///   * an eventually-consistent object store returning a already-deleted key
1307    ///     in a listing.
1308    #[derive(Debug)]
1309    struct VanishingStore {
1310        inner: Arc<dyn ObjectStore>,
1311        vanish: Path,
1312    }
1313
1314    impl std::fmt::Display for VanishingStore {
1315        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1316            write!(f, "VanishingStore")
1317        }
1318    }
1319
1320    #[async_trait::async_trait]
1321    impl ObjectStore for VanishingStore {
1322        async fn put_opts(
1323            &self,
1324            location: &Path,
1325            payload: object_store::PutPayload,
1326            opts: object_store::PutOptions,
1327        ) -> object_store::Result<object_store::PutResult> {
1328            self.inner.put_opts(location, payload, opts).await
1329        }
1330
1331        async fn put_multipart_opts(
1332            &self,
1333            location: &Path,
1334            opts: object_store::PutMultipartOptions,
1335        ) -> object_store::Result<Box<dyn object_store::MultipartUpload>> {
1336            self.inner.put_multipart_opts(location, opts).await
1337        }
1338
1339        async fn get_opts(
1340            &self,
1341            location: &Path,
1342            options: object_store::GetOptions,
1343        ) -> object_store::Result<object_store::GetResult> {
1344            if location == &self.vanish {
1345                return Err(object_store::Error::NotFound {
1346                    path: location.to_string(),
1347                    source: Box::new(std::io::Error::from(std::io::ErrorKind::NotFound)),
1348                });
1349            }
1350            self.inner.get_opts(location, options).await
1351        }
1352
1353        fn delete_stream(
1354            &self,
1355            locations: futures::stream::BoxStream<'static, object_store::Result<Path>>,
1356        ) -> futures::stream::BoxStream<'static, object_store::Result<Path>> {
1357            self.inner.delete_stream(locations)
1358        }
1359
1360        fn list(
1361            &self,
1362            prefix: Option<&Path>,
1363        ) -> futures::stream::BoxStream<'static, object_store::Result<object_store::ObjectMeta>>
1364        {
1365            self.inner.list(prefix)
1366        }
1367
1368        async fn list_with_delimiter(
1369            &self,
1370            prefix: Option<&Path>,
1371        ) -> object_store::Result<object_store::ListResult> {
1372            self.inner.list_with_delimiter(prefix).await
1373        }
1374
1375        async fn copy_opts(
1376            &self,
1377            from: &Path,
1378            to: &Path,
1379            options: object_store::CopyOptions,
1380        ) -> object_store::Result<()> {
1381            self.inner.copy_opts(from, to, options).await
1382        }
1383    }
1384
1385    /// Build a WAL with `n` single-mutation segments and return their paths in
1386    /// LSN order.
1387    async fn seed_segments(store: &Arc<dyn ObjectStore>, prefix: &Path, n: u64) -> Vec<Path> {
1388        let wal = WriteAheadLog::new(store.clone(), prefix.clone());
1389        for i in 1..=n {
1390            wal.append(Mutation::InsertVertex {
1391                vid: Vid::new(i),
1392                properties: HashMap::new(),
1393                labels: vec![],
1394            })
1395            .unwrap();
1396            wal.flush().await.unwrap();
1397        }
1398        let mut paths: Vec<Path> = list_with_timeout(store, Some(prefix), DEFAULT_TIMEOUT)
1399            .await
1400            .unwrap()
1401            .into_iter()
1402            .map(|m| m.location)
1403            .collect();
1404        paths.sort();
1405        paths
1406    }
1407
1408    /// A WAL segment that disappears between the listing and its read must not
1409    /// fail recovery.
1410    ///
1411    /// `replay_since` lists segments and then `get`s each one. A concurrent
1412    /// `truncate_before` deletes segments whose mutations are already durable in
1413    /// L1, so a segment vanishing in that window carries nothing that is not
1414    /// already recovered — and a listing taken microseconds later would simply
1415    /// not have included it. Propagating the `NotFound` turns that benign,
1416    /// unavoidable race into a hard open failure.
1417    ///
1418    /// Observed in the wild as a load-dependent flake in
1419    /// `recovery_index_no_rebuild::scalar_recovered_delta_queryable_without_rebuild`:
1420    /// `Object at location .../wal/00000000000000000002_<uuid>.wal not found`.
1421    #[tokio::test]
1422    async fn replay_skips_segment_truncated_between_list_and_read() -> Result<()> {
1423        let dir = tempdir()?;
1424        let prefix = Path::from("wal");
1425        let base: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
1426        let paths = seed_segments(&base, &prefix, 3).await;
1427
1428        // Vanish a NON-tail segment: the tail has its own corrupt-tail policy,
1429        // so using the tail would pass for the wrong reason.
1430        let store: Arc<dyn ObjectStore> = Arc::new(VanishingStore {
1431            inner: base,
1432            vanish: paths[0].clone(),
1433        });
1434        let wal = WriteAheadLog::new(store, prefix);
1435
1436        let mutations = wal.replay().await?;
1437
1438        // The two readable segments still replay; only the vanished one is skipped.
1439        assert_eq!(
1440            mutations.len(),
1441            2,
1442            "a concurrently-truncated segment must be skipped, not fail the replay"
1443        );
1444        Ok(())
1445    }
1446
1447    /// A store whose `list` hands back entries that it has *already* removed —
1448    /// the other truncator winning the race in the window between our listing
1449    /// and our deletes. The `NotFound` the truncation then sees is a real one
1450    /// raised by `LocalFileSystem`, not a synthesised error.
1451    #[derive(Debug)]
1452    struct DeletedAfterListStore {
1453        inner: Arc<dyn ObjectStore>,
1454    }
1455
1456    impl std::fmt::Display for DeletedAfterListStore {
1457        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1458            write!(f, "DeletedAfterListStore")
1459        }
1460    }
1461
1462    #[async_trait::async_trait]
1463    impl ObjectStore for DeletedAfterListStore {
1464        async fn put_opts(
1465            &self,
1466            location: &Path,
1467            payload: object_store::PutPayload,
1468            opts: object_store::PutOptions,
1469        ) -> object_store::Result<object_store::PutResult> {
1470            self.inner.put_opts(location, payload, opts).await
1471        }
1472
1473        async fn put_multipart_opts(
1474            &self,
1475            location: &Path,
1476            opts: object_store::PutMultipartOptions,
1477        ) -> object_store::Result<Box<dyn object_store::MultipartUpload>> {
1478            self.inner.put_multipart_opts(location, opts).await
1479        }
1480
1481        async fn get_opts(
1482            &self,
1483            location: &Path,
1484            options: object_store::GetOptions,
1485        ) -> object_store::Result<object_store::GetResult> {
1486            self.inner.get_opts(location, options).await
1487        }
1488
1489        fn delete_stream(
1490            &self,
1491            locations: futures::stream::BoxStream<'static, object_store::Result<Path>>,
1492        ) -> futures::stream::BoxStream<'static, object_store::Result<Path>> {
1493            self.inner.delete_stream(locations)
1494        }
1495
1496        fn list(
1497            &self,
1498            prefix: Option<&Path>,
1499        ) -> futures::stream::BoxStream<'static, object_store::Result<object_store::ObjectMeta>>
1500        {
1501            use futures::{StreamExt, TryStreamExt};
1502            let inner = self.inner.clone();
1503            let prefix = prefix.cloned();
1504            Box::pin(
1505                futures::stream::once(async move {
1506                    let metas: Vec<object_store::ObjectMeta> = inner
1507                        .list(prefix.as_ref())
1508                        .try_collect()
1509                        .await
1510                        .unwrap_or_default();
1511                    // The competing truncation completes here.
1512                    for m in &metas {
1513                        let _ = inner.delete(&m.location).await;
1514                    }
1515                    futures::stream::iter(metas.into_iter().map(Ok))
1516                })
1517                .flatten(),
1518            )
1519        }
1520
1521        async fn list_with_delimiter(
1522            &self,
1523            prefix: Option<&Path>,
1524        ) -> object_store::Result<object_store::ListResult> {
1525            self.inner.list_with_delimiter(prefix).await
1526        }
1527
1528        async fn copy_opts(
1529            &self,
1530            from: &Path,
1531            to: &Path,
1532            options: object_store::CopyOptions,
1533        ) -> object_store::Result<()> {
1534            self.inner.copy_opts(from, to, options).await
1535        }
1536    }
1537
1538    /// Truncation must not fail when a segment it listed was already deleted by
1539    /// a concurrent truncation.
1540    ///
1541    /// Two truncators exist whenever a spawned flush finalizer (`writer.rs`
1542    /// step K) outlives its `Uni` — `Drop for Uni` only signals shutdown — and
1543    /// the directory is reopened. The loser used to fail the enclosing flush
1544    /// with `Object at location .../wal/...wal not found`, which is how this
1545    /// surfaced as a load-dependent flake across the
1546    /// `recovery_index_no_rebuild` tests.
1547    ///
1548    /// Deleting an object that is already absent has achieved its
1549    /// postcondition, so it is success, not failure.
1550    #[tokio::test]
1551    async fn truncate_tolerates_segment_deleted_by_concurrent_truncation() -> Result<()> {
1552        let dir = tempdir()?;
1553        let prefix = Path::from("wal");
1554        let base: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
1555        seed_segments(&base, &prefix, 3).await;
1556
1557        let store: Arc<dyn ObjectStore> = Arc::new(DeletedAfterListStore { inner: base });
1558        let wal = WriteAheadLog::new(store, prefix);
1559
1560        // Every segment this lists has already been removed by the time the
1561        // delete runs.
1562        wal.truncate_before(u64::MAX)
1563            .await
1564            .expect("truncate_before must tolerate an already-deleted segment");
1565        wal.truncate()
1566            .await
1567            .expect("truncate must tolerate an already-deleted segment");
1568        Ok(())
1569    }
1570
1571    /// Inverse guard: a `NotFound` is the *only* read error that may be skipped.
1572    /// A transient read failure must still fail recovery loudly, or a blip
1573    /// silently drops committed mutations.
1574    #[tokio::test]
1575    async fn replay_still_fails_on_non_notfound_read_error() -> Result<()> {
1576        struct FailingStore(Arc<dyn ObjectStore>);
1577
1578        impl std::fmt::Debug for FailingStore {
1579            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1580                write!(f, "FailingStore")
1581            }
1582        }
1583        impl std::fmt::Display for FailingStore {
1584            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1585                write!(f, "FailingStore")
1586            }
1587        }
1588
1589        #[async_trait::async_trait]
1590        impl ObjectStore for FailingStore {
1591            async fn put_opts(
1592                &self,
1593                location: &Path,
1594                payload: object_store::PutPayload,
1595                opts: object_store::PutOptions,
1596            ) -> object_store::Result<object_store::PutResult> {
1597                self.0.put_opts(location, payload, opts).await
1598            }
1599            async fn put_multipart_opts(
1600                &self,
1601                location: &Path,
1602                opts: object_store::PutMultipartOptions,
1603            ) -> object_store::Result<Box<dyn object_store::MultipartUpload>> {
1604                self.0.put_multipart_opts(location, opts).await
1605            }
1606            async fn get_opts(
1607                &self,
1608                _location: &Path,
1609                _options: object_store::GetOptions,
1610            ) -> object_store::Result<object_store::GetResult> {
1611                Err(object_store::Error::Generic {
1612                    store: "FailingStore",
1613                    source: Box::new(std::io::Error::other("injected transient read failure")),
1614                })
1615            }
1616            fn delete_stream(
1617                &self,
1618                locations: futures::stream::BoxStream<'static, object_store::Result<Path>>,
1619            ) -> futures::stream::BoxStream<'static, object_store::Result<Path>> {
1620                self.0.delete_stream(locations)
1621            }
1622            fn list(
1623                &self,
1624                prefix: Option<&Path>,
1625            ) -> futures::stream::BoxStream<'static, object_store::Result<object_store::ObjectMeta>>
1626            {
1627                self.0.list(prefix)
1628            }
1629            async fn list_with_delimiter(
1630                &self,
1631                prefix: Option<&Path>,
1632            ) -> object_store::Result<object_store::ListResult> {
1633                self.0.list_with_delimiter(prefix).await
1634            }
1635            async fn copy_opts(
1636                &self,
1637                from: &Path,
1638                to: &Path,
1639                options: object_store::CopyOptions,
1640            ) -> object_store::Result<()> {
1641                self.0.copy_opts(from, to, options).await
1642            }
1643        }
1644
1645        let dir = tempdir()?;
1646        let prefix = Path::from("wal");
1647        let base: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
1648        seed_segments(&base, &prefix, 2).await;
1649
1650        let store: Arc<dyn ObjectStore> = Arc::new(FailingStore(base));
1651        let wal = WriteAheadLog::new(store, prefix);
1652
1653        assert!(
1654            wal.replay().await.is_err(),
1655            "a non-NotFound read error must still fail recovery"
1656        );
1657        Ok(())
1658    }
1659}