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