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 crate::KevyResult;
8use std::io;
9use std::sync::RwLock;
10use std::time::Instant;
11
12use kevy_persist::{Argv, RewriteStats};
13
14use crate::metric::KevyMetric;
15use crate::store::{Inner, Store, lock_write};
16
17impl Store {
18    /// Whether a 3.x binary could still open this data directory —
19    /// the downgrade window `UPGRADING.md` describes, as a readable
20    /// state instead of an assumption.
21    ///
22    /// `Some(true)`: every shard's AOF still speaks `KEVYAOF1`, so
23    /// downgrading is a binary swap back. `Some(false)`: at least one
24    /// shard has upgraded to v2 (a rewrite ran), so a downgrade needs a
25    /// keyspace export through a client. `None`: no AOF is configured —
26    /// the question has no meaning for a memory-only store.
27    ///
28    /// From an embedder's dogfood report: their `doctor`
29    /// command exists to tell users "where you are and what to do
30    /// next", and this state was `pub(crate)` — so their CHANGELOG had
31    /// to declare the window closed unconditionally when it was open,
32    /// observable, and per-directory.
33    #[cfg(all(feature = "persist", not(target_arch = "wasm32")))]
34    pub fn downgradeable_to_v3(&self) -> Option<bool> {
35        let mut any = false;
36        let mut all_v1 = true;
37        for shard in self.shards.iter() {
38            let g = crate::store::lock_write(shard);
39            if let Some(aof) = g.aof.as_ref() {
40                any = true;
41                if aof.format() != kevy_persist::AofFormat::V1 {
42                    all_v1 = false;
43                }
44            }
45        }
46        any.then_some(all_v1)
47    }
48
49    /// Durability barrier: flush + `fdatasync` every shard's
50    /// AOF now, regardless of the configured `appendfsync` policy. On
51    /// `Ok(())`, every write acknowledged before this call is on
52    /// stable storage. The `EverySec` serving-store idiom:
53    ///
54    /// ```ignore
55    /// store.atomic(|c| { /* critical write */ Ok(()) })?;
56    /// store.fsync_aof()?; // durable-on-ack for THIS block only
57    /// ```
58    ///
59    /// Cost: one `fdatasync` per dirty shard; a no-op on clean shards.
60    /// Under `appendfsync = always` it is a no-op (already durable).
61    pub fn fsync_aof(&self) -> KevyResult<()> {
62        for shard in self.shards.iter() {
63            let mut g = lock_write(shard);
64            if let Some(aof) = &mut g.aof {
65                aof.sync_now()?;
66            }
67        }
68        Ok(())
69    }
70
71    /// `BGREWRITEAOF`: rebuild every shard's AOF from current state.
72    /// Synchronous (despite the RESP name). Returns the summed stats
73    /// (`None` if persistence is off / no shard rewrote). Shards mid
74    /// rewrite are skipped. Emits [`KevyMetric::Rewrite`] per shard.
75    pub fn rewrite_aof(&self) -> KevyResult<Option<RewriteStats>> {
76        let mut agg: Option<RewriteStats> = None;
77        for shard in self.shards.iter() {
78            if let Some(stats) = self.rewrite_one_shard(shard)? {
79                let acc = agg.get_or_insert(RewriteStats { keys: 0, bytes: 0 });
80                acc.keys += stats.keys;
81                acc.bytes += stats.bytes;
82            }
83        }
84        Ok(agg)
85    }
86
87    /// One shard's synchronous three-phase rewrite. `Ok(None)` =
88    /// skipped (persistence off / already mid-rewrite).
89    fn rewrite_one_shard(&self, shard: &RwLock<Inner>) -> KevyResult<Option<RewriteStats>> {
90        let start = Instant::now();
91        // Phase 1 (locked): freeze the COW view + start the tee —
92        // O(n)-shallow, no serialization under the lock.
93        let (view, tmp, before_bytes) = {
94            let mut g = lock_write(shard);
95            let Inner { store, aof, .. } = &mut *g;
96            let Some(aof) = aof else { return Ok(None) };
97            if aof.is_rewriting() {
98                return Ok(None);
99            }
100            let before = aof.size_bytes();
101            let view = store.collect_snapshot();
102            (view, aof.begin_view_rewrite()?, before)
103        };
104        // Phase 2 (unlocked): serialize + fsync the compacted log.
105        let keys = match kevy_persist::dump_aof(&tmp, &view) {
106            Ok((keys, _)) => keys,
107            Err(e) => {
108                let mut g = lock_write(shard);
109                if let Some(aof) = &mut g.aof {
110                    aof.abort_concurrent_rewrite();
111                }
112                let _ = std::fs::remove_file(&tmp);
113                return Err(e.into());
114            }
115        };
116        // Phase 3 (locked): append the tee'd diff and swap.
117        let mut g = lock_write(shard);
118        let Some(aof) = &mut g.aof else { return Ok(None) };
119        let stats = match aof.finish_concurrent_rewrite(&tmp, keys) {
120            Ok(s) => s,
121            Err(e) => {
122                aof.abort_concurrent_rewrite();
123                let _ = std::fs::remove_file(&tmp);
124                return Err(e.into());
125            }
126        };
127        if let Some(sink) = &self.config.metric_sink {
128            sink.emit(KevyMetric::Rewrite {
129                keys: stats.keys,
130                before_bytes,
131                after_bytes: stats.bytes,
132                elapsed_ms: start.elapsed().as_millis() as u64,
133            });
134        }
135        Ok(Some(stats))
136    }
137
138    /// Apply one AOF-format command frame directly to the keyspace — the
139    /// programmatic face of AOF replay, for hosts that store the log
140    /// themselves (targets without a filesystem, where the embedding
141    /// host reads the log back and feeds it in frame by frame on open).
142    ///
143    /// Speaks the same verb set `Store::open` replays from an on-disk
144    /// AOF; unknown verbs are skipped (forward compatibility with logs
145    /// written by a newer kevy). Keyed verbs route to the owning shard;
146    /// `FLUSHALL`/`FLUSHDB` reach every shard. The frame is **not**
147    /// re-appended to any AOF — this is the read-back half of the pump,
148    /// so re-logging would double-apply on the next replay.
149    pub fn apply_frame(&self, args: &Argv) {
150        let Some(verb) = args.first() else { return };
151        if verb.eq_ignore_ascii_case(b"FLUSHALL") || verb.eq_ignore_ascii_case(b"FLUSHDB") {
152            for shard in self.shards.iter() {
153                crate::replay::apply(&mut lock_write(shard).store, args);
154            }
155            return;
156        }
157        if let Some(key) = args.get(1) {
158            crate::replay::apply(&mut self.wshard(key).store, args);
159        }
160    }
161
162    /// Serialize the whole keyspace into an in-memory compacted AOF image
163    /// (magic header + one command stream per key — the same bytes an
164    /// AOF rewrite puts on disk). The write half of host-mediated
165    /// persistence: hosts without a filesystem hand this buffer to their
166    /// own storage, replacing the accumulated append log, and feed it
167    /// back through [`Self::apply_frame`] on the next open.
168    ///
169    /// Each shard is frozen copy-on-write and serialized off-lock, so
170    /// concurrent readers and writers on other shards are not blocked
171    /// for the duration of the dump.
172    pub fn dump_aof_buf(&self) -> Vec<u8> {
173        let mut out = Vec::new();
174        for (i, shard) in self.shards.iter().enumerate() {
175            let view = lock_write(shard).store.collect_snapshot();
176            // V2, like every other rewrite output: the wasm door's
177            // host-mediated pump replays both formats, and its dump is
178            // the log's upgrade point (mirroring the native
179            // first-rewrite upgrade).
180            let (buf, _keys) =
181                kevy_persist::dump_store_to_buf(&view, kevy_persist::AofFormat::V2);
182            if i == 0 {
183                out = buf;
184            } else {
185                // One magic header per image, not per shard.
186                out.extend_from_slice(&buf[kevy_persist::AOF2_MAGIC.len()..]);
187            }
188        }
189        out
190    }
191
192    /// Snapshot every shard to its `dump-{i}.rdb`, atomically. `Ok(false)`
193    /// when persistence is disabled.
194    pub fn save_snapshot(&self) -> KevyResult<bool> {
195        let Some(dir) = self.config.data_dir.as_ref() else {
196            return Ok(false);
197        };
198        for (i, shard) in self.shards.iter().enumerate() {
199            save_shard_snapshot(shard, &kevy_persist::layout::snapshot_path(dir, i))?;
200        }
201        Ok(true)
202    }
203}
204
205/// Save one shard's snapshot with the snapshot+log contract intact:
206/// after a successful save the AOF holds **only post-collect writes**,
207/// so a restart replays them over the snapshot without double-applying
208/// history (non-idempotent commands like RPUSH duplicated before this).
209///
210/// Phase 1 (write lock): freeze the COW view + start the AOF tee — no
211/// write may land between the two (the tee atomicity contract). Phase 2
212/// (unlocked): serialize the view to the snapshot's durable tmp.
213/// Phase 3 (write lock): commit — snapshot rename and tee'd AOF reset
214/// adjacent, so the snapshot/log commit window stays microseconds.
215pub(crate) fn save_shard_snapshot(
216    shard: &RwLock<Inner>,
217    path: &std::path::Path,
218) -> KevyResult<()> {
219    let (view, reset_tmp) = freeze_for_save(shard)?;
220    let tmp = match kevy_persist::write_snapshot_tmp(&view, path) {
221        Ok(t) => t,
222        Err(e) => {
223            if reset_tmp.is_some()
224                && let Some(aof) = &mut lock_write(shard).aof
225            {
226                aof.abort_concurrent_rewrite();
227            }
228            return Err(e.into());
229        }
230    };
231    let mut g = lock_write(shard);
232    std::fs::rename(&tmp, path)?;
233    if let (Some(reset), Some(aof)) = (reset_tmp, &mut g.aof) {
234        let swap = kevy_persist::write_aof_base(&reset)
235            .and_then(|()| aof.finish_concurrent_rewrite(&reset, 0));
236        if let Err(e) = swap {
237            aof.abort_concurrent_rewrite();
238            let _ = std::fs::remove_file(&reset);
239            return Err(e.into());
240        }
241    }
242    Ok(())
243}
244
245/// Phase-1 helper: collect the view and start the tee under one write
246/// lock. A racing background auto-rewrite owns the tee; it runs its
247/// slow half off-lock and finishes in milliseconds, so wait it out
248/// (bounded) rather than saving a snapshot whose log would double-
249/// apply on replay.
250fn freeze_for_save(
251    shard: &RwLock<Inner>,
252) -> KevyResult<(kevy_store::SnapshotView, Option<std::path::PathBuf>)> {
253    for _ in 0..2000 {
254        {
255            let mut g = lock_write(shard);
256            let Inner { store, aof, .. } = &mut *g;
257            match aof {
258                Some(a) if a.is_rewriting() => {} // racing rewrite — retry
259                Some(a) => {
260                    let view = store.collect_snapshot();
261                    return Ok((view, Some(a.begin_view_rewrite()?)));
262                }
263                None => return Ok((store.collect_snapshot(), None)),
264            }
265        }
266        std::thread::sleep(std::time::Duration::from_millis(5));
267    }
268    Err(io::Error::new(
269        io::ErrorKind::TimedOut,
270        "kevy-embedded: AOF rewrite still in flight after 10s; snapshot aborted",
271    )
272    .into())
273}