1use 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
19fn parse_lsn_from_filename(path: &Path) -> Option<u64> {
22 let filename = path.filename()?;
23 if filename.len() < 20 {
24 return None;
25 }
26 filename.get(..20).and_then(|s| s.parse::<u64>().ok())
29}
30
31const WAL_V2_MAGIC: &[u8] = b"UNIWAL2\n";
37
38const WAL_V2_HASH_HEX_LEN: usize = 64;
40
41fn 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#[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 serde_json::from_slice(bytes).map_err(|e| format!("legacy segment parse: {e}"))
83 }
84}
85
86#[cfg(test)]
90pub(crate) static FAIL_NEXT_FSYNC: std::sync::atomic::AtomicBool =
91 std::sync::atomic::AtomicBool::new(false);
92
93pub(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
109mod 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 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 _ => 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 #[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 SetVertexLabels { vid: Vid, labels: Vec<String> },
209}
210
211#[derive(Serialize, Deserialize, Debug, Clone)]
213pub struct WalSegment {
214 pub lsn: u64,
216 pub mutations: Vec<Mutation>,
218}
219
220#[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 local_root: Option<std::path::PathBuf>,
241 state: Mutex<WalState>,
242}
243
244struct WalState {
245 buffer: Vec<Mutation>,
246 next_lsn: u64,
248 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, flushed_lsn: 0,
262 }),
263 }
264 }
265
266 #[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 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 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 if let Some(lsn) = parse_lsn_from_filename(&meta.location) {
294 max_lsn = max_lsn.max(lsn);
295 } else {
296 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 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 #[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 let segment = WalSegmentRef {
353 lsn,
354 mutations: &batch,
355 };
356
357 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 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 return Err(e.into());
369 }
370 };
371 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 let filename = format!("{:020}_{}.wal", lsn, Uuid::new_v4());
379 let path = self.prefix.clone().join(filename);
380
381 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 let mut state = acquire_mutex(&self.state, "wal_state")?;
390 let new_mutations = std::mem::take(&mut state.buffer);
392 state.buffer = batch;
393 state.buffer.extend(new_mutations);
394 return Err(e);
397 }
398
399 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 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 {
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 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 #[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 let mut paths: Vec<_> = metas
500 .into_iter()
501 .map(|m| m.location)
502 .filter(|p| {
503 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 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 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 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 pub async fn replay(&self) -> Result<Vec<Mutation>> {
596 self.replay_since(0).await
597 }
598
599 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 #[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 let should_delete = if let Some(lsn) = parse_lsn_from_filename(&meta.location) {
643 lsn <= high_water_mark
644 } else {
645 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 true
656 } else {
657 match decode_segment(&bytes) {
658 Ok(segment) => segment.lsn <= high_water_mark,
659 Err(reason) => {
660 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 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 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 wal.append(mutation1)?;
767 let lsn1 = wal.flush().await?;
768
769 wal.append(mutation2)?;
771 let lsn2 = wal.flush().await?;
772
773 wal.append(mutation3)?;
775 let lsn3 = wal.flush().await?;
776
777 assert!(lsn2 > lsn1, "LSN2 ({}) should be > LSN1 ({})", lsn2, lsn1);
779 assert!(lsn3 > lsn2, "LSN3 ({}) should be > LSN2 ({})", lsn3, lsn2);
780
781 assert_eq!(lsn2, lsn1 + 1);
783 assert_eq!(lsn3, lsn2 + 1);
784
785 Ok(())
786 }
787
788 #[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 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 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 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 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 let path = Path::from("00000000000000000001_xyz.wal");
834 assert_eq!(parse_lsn_from_filename(&path), Some(1));
835
836 let path = Path::from("12345678901234567890_uuid.wal");
838 assert_eq!(parse_lsn_from_filename(&path), Some(12345678901234567890));
839
840 let path = Path::from("invalid.wal");
842 assert_eq!(parse_lsn_from_filename(&path), None);
843
844 let path = Path::from("123.wal"); assert_eq!(parse_lsn_from_filename(&path), None);
846
847 let path = Path::from("abcdefghijklmnopqrst_uuid.wal"); assert_eq!(parse_lsn_from_filename(&path), None);
849
850 let path = Path::from("00000000000000000100.wal");
852 assert_eq!(parse_lsn_from_filename(&path), Some(100));
853
854 let path = Path::from("");
856 assert_eq!(parse_lsn_from_filename(&path), None);
857 }
858
859 #[test]
873 fn test_parse_lsn_from_filename_multibyte_no_panic() {
874 let name = format!("{}{}.wal", "0".repeat(19), "é"); let path = Path::parse(name).expect("multi-byte segment is a valid object_store path");
876 assert_eq!(parse_lsn_from_filename(&path), None); }
878
879 #[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 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 let start = std::time::Instant::now();
902 let max_lsn = wal.find_max_lsn().await?;
903 let duration = start.elapsed();
904
905 assert_eq!(max_lsn, 100, "Max LSN should be 100");
907
908 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 #[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 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 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 wal.append(Mutation::InsertVertex {
952 vid: Vid::new(3),
953 properties: HashMap::new(),
954 labels: vec![],
955 })?;
956
957 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 assert_eq!(lsn4, 3, "LSN should increment monotonically");
967
968 let mutations = wal.replay().await?;
970 assert_eq!(mutations.len(), 4, "All 4 mutations should be replayed");
971
972 Ok(())
973 }
974
975 #[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 let mut seen_lsns = std::collections::HashSet::new();
986
987 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 assert!(
998 !seen_lsns.contains(&lsn),
999 "LSN {} was reused! This violates monotonicity.",
1000 lsn
1001 );
1002 seen_lsns.insert(lsn);
1003
1004 assert_eq!(lsn, i, "LSN should be {}, got {}", i, lsn);
1006 }
1007
1008 Ok(())
1009 }
1010
1011 #[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 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 let start = std::time::Instant::now();
1034 wal.truncate_before(50).await?;
1035 let duration = start.elapsed();
1036
1037 let mutations = wal.replay().await?;
1039 assert_eq!(
1040 mutations.len(),
1041 50,
1042 "Should have 50 mutations remaining (51-100)"
1043 );
1044
1045 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 #[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 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 let start = std::time::Instant::now();
1077 let mutations = wal.replay_since(90).await?;
1078 let duration = start.elapsed();
1079
1080 assert_eq!(mutations.len(), 10, "Should replay only LSNs 91-100");
1082
1083 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 #[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 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 wal.flush().await?;
1118
1119 let mutations = wal.replay().await?;
1121 assert_eq!(mutations.len(), 1);
1122
1123 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 #[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 wal.append(Mutation::DeleteVertex {
1147 vid: Vid::new(99),
1148 labels: vec!["Person".to_string(), "Admin".to_string()],
1149 })?;
1150
1151 wal.flush().await?;
1153
1154 let mutations = wal.replay().await?;
1156 assert_eq!(mutations.len(), 1);
1157
1158 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 #[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 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 wal.flush().await?;
1197
1198 let mutations = wal.replay().await?;
1200 assert_eq!(mutations.len(), 1);
1201
1202 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 #[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 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 let wal = WriteAheadLog::new(store, prefix);
1243 let mutations = wal.replay().await?;
1244
1245 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 #[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 #[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 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 #[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 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 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 #[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 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 #[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 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 #[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}