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