Skip to main content

kevy_embedded/
store_persist.rs

1//! Durability methods on [`Store`] — `BGREWRITEAOF` and `SAVE` — plus
2//! their per-shard helpers. Extracted from `store.rs` to keep that file
3//! under the 500-LOC project ceiling. Behaviour is unchanged from the
4//! pre-split layout; this module hosts long-running disk paths
5//! separately from the hot lock/dispatch surface in `store.rs`.
6
7use std::io;
8use std::sync::RwLock;
9use std::time::Instant;
10
11use kevy_persist::RewriteStats;
12
13use crate::metric::KevyMetric;
14use crate::store::{Inner, Store, lock_write};
15
16impl Store {
17    /// Durability barrier (v2.1): flush + `fdatasync` every shard's
18    /// AOF now, regardless of the configured `appendfsync` policy. On
19    /// `Ok(())`, every write acknowledged before this call is on
20    /// stable storage. The `EverySec` serving-store idiom:
21    ///
22    /// ```ignore
23    /// store.atomic(|c| { /* critical write */ Ok(()) })?;
24    /// store.fsync_aof()?; // durable-on-ack for THIS block only
25    /// ```
26    ///
27    /// Cost: one `fdatasync` per dirty shard; a no-op on clean shards.
28    /// Under `appendfsync = always` it is a no-op (already durable).
29    pub fn fsync_aof(&self) -> io::Result<()> {
30        for shard in self.shards.iter() {
31            let mut g = lock_write(shard);
32            if let Some(aof) = &mut g.aof {
33                aof.sync_now()?;
34            }
35        }
36        Ok(())
37    }
38
39    /// `BGREWRITEAOF`: rebuild every shard's AOF from current state.
40    /// Synchronous (despite the RESP name). Returns the summed stats
41    /// (`None` if persistence is off / no shard rewrote). Shards mid
42    /// rewrite are skipped. Emits [`KevyMetric::Rewrite`] per shard.
43    pub fn rewrite_aof(&self) -> io::Result<Option<RewriteStats>> {
44        let mut agg: Option<RewriteStats> = None;
45        for shard in self.shards.iter() {
46            if let Some(stats) = self.rewrite_one_shard(shard)? {
47                let acc = agg.get_or_insert(RewriteStats { keys: 0, bytes: 0 });
48                acc.keys += stats.keys;
49                acc.bytes += stats.bytes;
50            }
51        }
52        Ok(agg)
53    }
54
55    /// One shard's synchronous three-phase rewrite. `Ok(None)` =
56    /// skipped (persistence off / already mid-rewrite).
57    fn rewrite_one_shard(&self, shard: &RwLock<Inner>) -> io::Result<Option<RewriteStats>> {
58        let start = Instant::now();
59        // Phase 1 (locked): freeze the COW view + start the tee —
60        // O(n)-shallow, no serialization under the lock.
61        let (view, tmp, before_bytes) = {
62            let mut g = lock_write(shard);
63            let Inner { store, aof, .. } = &mut *g;
64            let Some(aof) = aof else { return Ok(None) };
65            if aof.is_rewriting() {
66                return Ok(None);
67            }
68            let before = aof.size_bytes();
69            let view = store.collect_snapshot();
70            (view, aof.begin_view_rewrite()?, before)
71        };
72        // Phase 2 (unlocked): serialize + fsync the compacted log.
73        let keys = match kevy_persist::dump_aof(&tmp, &view) {
74            Ok((keys, _)) => keys,
75            Err(e) => {
76                let mut g = lock_write(shard);
77                if let Some(aof) = &mut g.aof {
78                    aof.abort_concurrent_rewrite();
79                }
80                let _ = std::fs::remove_file(&tmp);
81                return Err(e);
82            }
83        };
84        // Phase 3 (locked): append the tee'd diff and swap.
85        let mut g = lock_write(shard);
86        let Some(aof) = &mut g.aof else { return Ok(None) };
87        let stats = match aof.finish_concurrent_rewrite(&tmp, keys) {
88            Ok(s) => s,
89            Err(e) => {
90                aof.abort_concurrent_rewrite();
91                let _ = std::fs::remove_file(&tmp);
92                return Err(e);
93            }
94        };
95        if let Some(sink) = &self.config.metric_sink {
96            sink.emit(KevyMetric::Rewrite {
97                keys: stats.keys,
98                before_bytes,
99                after_bytes: stats.bytes,
100                elapsed_ms: start.elapsed().as_millis() as u64,
101            });
102        }
103        Ok(Some(stats))
104    }
105
106    /// Snapshot every shard to its `dump-{i}.rdb` (single shard: the configured
107    /// name), atomically. `Ok(false)` when persistence is disabled.
108    pub fn save_snapshot(&self) -> io::Result<bool> {
109        let Some(dir) = self.config.data_dir.as_ref() else {
110            return Ok(false);
111        };
112        let n = self.shards.len();
113        for (i, shard) in self.shards.iter().enumerate() {
114            let name = if n == 1 {
115                self.config.snapshot_filename.clone()
116            } else {
117                kevy_persist::layout::snapshot_file(i)
118            };
119            save_shard_snapshot(shard, &dir.join(name))?;
120        }
121        Ok(true)
122    }
123}
124
125/// Save one shard's snapshot with the snapshot+log contract intact:
126/// after a successful save the AOF holds **only post-collect writes**,
127/// so a restart replays them over the snapshot without double-applying
128/// history (non-idempotent commands like RPUSH duplicated before this).
129///
130/// Phase 1 (write lock): freeze the COW view + start the AOF tee — no
131/// write may land between the two (the tee atomicity contract). Phase 2
132/// (unlocked): serialize the view to the snapshot's durable tmp.
133/// Phase 3 (write lock): commit — snapshot rename and tee'd AOF reset
134/// adjacent, so the snapshot/log commit window stays microseconds.
135pub(crate) fn save_shard_snapshot(
136    shard: &RwLock<Inner>,
137    path: &std::path::Path,
138) -> io::Result<()> {
139    let (view, reset_tmp) = freeze_for_save(shard)?;
140    let tmp = match kevy_persist::write_snapshot_tmp(&view, path) {
141        Ok(t) => t,
142        Err(e) => {
143            if reset_tmp.is_some()
144                && let Some(aof) = &mut lock_write(shard).aof
145            {
146                aof.abort_concurrent_rewrite();
147            }
148            return Err(e);
149        }
150    };
151    let mut g = lock_write(shard);
152    std::fs::rename(&tmp, path)?;
153    if let (Some(reset), Some(aof)) = (reset_tmp, &mut g.aof) {
154        let swap = kevy_persist::write_aof_base(&reset)
155            .and_then(|()| aof.finish_concurrent_rewrite(&reset, 0));
156        if let Err(e) = swap {
157            aof.abort_concurrent_rewrite();
158            let _ = std::fs::remove_file(&reset);
159            return Err(e);
160        }
161    }
162    Ok(())
163}
164
165/// Phase-1 helper: collect the view and start the tee under one write
166/// lock. A racing background auto-rewrite owns the tee; it runs its
167/// slow half off-lock and finishes in milliseconds, so wait it out
168/// (bounded) rather than saving a snapshot whose log would double-
169/// apply on replay.
170fn freeze_for_save(
171    shard: &RwLock<Inner>,
172) -> io::Result<(kevy_store::SnapshotView, Option<std::path::PathBuf>)> {
173    for _ in 0..2000 {
174        {
175            let mut g = lock_write(shard);
176            let Inner { store, aof, .. } = &mut *g;
177            match aof {
178                Some(a) if a.is_rewriting() => {} // racing rewrite — retry
179                Some(a) => {
180                    let view = store.collect_snapshot();
181                    return Ok((view, Some(a.begin_view_rewrite()?)));
182                }
183                None => return Ok((store.collect_snapshot(), None)),
184            }
185        }
186        std::thread::sleep(std::time::Duration::from_millis(5));
187    }
188    Err(io::Error::new(
189        io::ErrorKind::TimedOut,
190        "kevy-embedded: AOF rewrite still in flight after 10s; snapshot aborted",
191    ))
192}