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