Skip to main content

kevy_cli/
migrate.rs

1//! Migration toolchain: `export` / `import`.
2//!
3//! Wire format = a RESP command stream of rebuild frames (SET / HSET /
4//! RPUSH / SADD / ZADD / PEXPIREAT) — bidirectionally compatible with
5//! `redis-cli --pipe`. Export walks SCAN cursors (per-key
6//! point-in-time; SCAN-class consistency). Import pipelines 512
7//! commands per batch with a fsynced progress file for `--resume`.
8//! Every key's frames start with DEL, so replay REBUILDS the key from
9//! scratch — genuinely idempotent for every type (RPUSH would
10//! otherwise append on re-import; the round-trip test caught exactly
11//! that). No cross-batch atomicity to lose.
12
13use std::fs::{File, OpenOptions};
14use std::io::{self, BufWriter, Read, Seek, SeekFrom, Write};
15use std::path::Path;
16
17use kevy_resp::{Reply, encode_command_borrowed};
18use kevy_resp_client::RespClient;
19
20const PIPELINE: usize = 512;
21
22/// Run `export` — walk the keyspace (optionally under `prefix`) and
23/// write rebuild frames to `out_path`. Returns exported key count.
24/// What an export did — including what it did NOT do. The skipped map
25/// is the half that used to be invisible: a type with no rebuild verb
26/// produced no frames, no error and no mention, so a migration could
27/// report success while leaving a whole type behind.
28pub struct Export {
29    /// Keys whose frames are in the file.
30    pub keys: u64,
31    /// Type name -> keys left out because nothing here rebuilds them.
32    pub skipped: std::collections::BTreeMap<Vec<u8>, u64>,
33}
34
35/// Walk the keyspace (optionally under `prefix`) and write rebuild
36/// frames to `out_path`, reporting both what went in and what could
37/// not.
38pub fn run_export(
39    client: &mut RespClient,
40    prefix: Option<&[u8]>,
41    out_path: &Path,
42) -> io::Result<Export> {
43    let mut out = BufWriter::new(File::create(out_path)?);
44    let mut cursor: Vec<u8> = b"0".to_vec();
45    let mut pattern = prefix.unwrap_or_default().to_vec();
46    pattern.push(b'*');
47    let mut n = 0u64;
48    // Types with no rebuild verb, counted by name. Silence here is how
49    // a migration loses a whole type and reports success.
50    let mut skipped: std::collections::BTreeMap<Vec<u8>, u64> = Default::default();
51    loop {
52        let reply = client.request_borrowed(&[b"SCAN", &cursor, b"MATCH", &pattern, b"COUNT", b"512"])?;
53        let Reply::Array(items) = reply else {
54            return Err(io::Error::new(io::ErrorKind::InvalidData, "SCAN reply shape"));
55        };
56        let (Some(Reply::Bulk(next)), Some(Reply::Array(keys))) = (items.first(), items.get(1))
57        else {
58            return Err(io::Error::new(io::ErrorKind::InvalidData, "SCAN reply shape"));
59        };
60        let next = next.clone();
61        for k in keys {
62            let Reply::Bulk(key) = k else { continue };
63            let key = key.clone();
64            match export_key(client, &key, &mut out)? {
65                Some(None) => n += 1,
66                Some(Some(ty)) => *skipped.entry(ty).or_insert(0u64) += 1,
67                None => {}
68            }
69        }
70        cursor = next;
71        if cursor == b"0" {
72            break;
73        }
74    }
75    out.flush()?;
76    Ok(Export { keys: n, skipped })
77}
78
79/// Emit one key's rebuild frames. `Ok(None)` = nothing written; the
80/// type name comes back when the reason was "no rebuild verb", so the
81/// caller can report what it is leaving behind rather than count it as
82/// a key that happened to vanish.
83fn export_key(
84    client: &mut RespClient,
85    key: &[u8],
86    out: &mut impl Write,
87) -> io::Result<Option<Option<Vec<u8>>>> {
88    match rebuild_frames(client, key, key)? {
89        Rebuilt::Frames(frame) => {
90            out.write_all(&frame)?;
91            Ok(Some(None))
92        }
93        Rebuilt::Vanished => Ok(None),
94        Rebuilt::UnsupportedType(ty) => Ok(Some(Some(ty))),
95    }
96}
97
98/// Why a key produced no frames. The two reasons are not the same and
99/// were indistinguishable: a vanished key is a race the walk expects,
100/// an unsupported type is data the caller is about to leave behind.
101pub(crate) enum Rebuilt {
102    /// The rebuild frames, ready to write.
103    Frames(Vec<u8>),
104    /// The key was gone between SCAN and read — expected, uncounted.
105    Vanished,
106    /// The type has no rebuild verb here. Carries the type name so the
107    /// caller can tell someone rather than skip in silence.
108    UnsupportedType(Vec<u8>),
109}
110
111/// Read `key` and produce DEL+rebuild frames addressed to `dst`
112/// (`dst == key` for export; a re-prefixed name for copy-prefix —
113/// the server has no COPY verb, so copying IS read+rebuild).
114pub(crate) fn rebuild_frames(
115    client: &mut RespClient,
116    key: &[u8],
117    dst: &[u8],
118) -> io::Result<Rebuilt> {
119    let ty = match client.request_borrowed(&[b"TYPE", key])? {
120        Reply::Simple(t) => t,
121        _ => return Ok(Rebuilt::Vanished),
122    };
123    if ty == b"none" {
124        return Ok(Rebuilt::Vanished);
125    }
126    let mut frame = Vec::new();
127    // DEL first: replay rebuilds from scratch (idempotence for
128    // append-shaped verbs like RPUSH).
129    encode_command_borrowed(&mut frame, &[b"DEL", dst]);
130    match encode_body(client, key, dst, &ty, &mut frame)? {
131        Some(()) => {}
132        None => return Ok(Rebuilt::Vanished),
133    }
134    if frame.len() == encoded_del_len(dst) {
135        return Ok(Rebuilt::UnsupportedType(ty));
136    }
137    append_ttl_frame(client, key, dst, &mut frame)?;
138    Ok(Rebuilt::Frames(frame))
139}
140
141/// The `DEL <dst>` prologue's encoded length — how `rebuild_frames`
142/// tells "the body wrote nothing" from "the body wrote frames".
143fn encoded_del_len(dst: &[u8]) -> usize {
144    let mut probe = Vec::new();
145    encode_command_borrowed(&mut probe, &[b"DEL", dst]);
146    probe.len()
147}
148
149/// Append the type's rebuild verbs to `frame`. `None` = the key
150/// vanished mid-read; leaving `frame` untouched = no verb for this
151/// type, which the caller turns into `UnsupportedType`.
152fn encode_body(
153    client: &mut RespClient,
154    key: &[u8],
155    dst: &[u8],
156    ty: &[u8],
157    frame: &mut Vec<u8>,
158) -> io::Result<Option<()>> {
159    match ty {
160        b"string" => {
161            let Reply::Bulk(v) = client.request_borrowed(&[b"GET", key])? else {
162                return Ok(None);
163            };
164            encode_command_borrowed(frame, &[b"SET", dst, &v]);
165        }
166        b"hash" => {
167            let Some(items) = fetch_bulks(client, &[b"HGETALL", key])? else {
168                return Ok(None);
169            };
170            encode_multi(frame, b"HSET", dst, &items);
171        }
172        b"list" => {
173            let Some(vals) = fetch_bulks(client, &[b"LRANGE", key, b"0", b"-1"])? else {
174                return Ok(None);
175            };
176            encode_multi(frame, b"RPUSH", dst, &vals);
177        }
178        b"set" => {
179            let Some(ms) = fetch_bulks(client, &[b"SMEMBERS", key])? else {
180                return Ok(None);
181            };
182            encode_multi(frame, b"SADD", dst, &ms);
183        }
184        b"zset" => {
185            let zrange: &[&[u8]] = &[b"ZRANGE", key, b"0", b"-1", b"WITHSCORES"];
186            let Some(flat) = fetch_bulks(client, zrange)? else {
187                return Ok(None);
188            };
189            encode_zadd(frame, dst, &flat);
190        }
191        // Streams and anything added later: no rebuild verb here. The
192        // caller reports it by name — a migration that drops a type
193        // must say which one.
194        _ => return Ok(Some(())),
195    }
196    Ok(Some(()))
197}
198
199/// Issue `cmd` and unwrap its Array reply into bulk payloads.
200/// `None` when the reply isn't an array or the array is empty (the key
201/// vanished / changed type between TYPE and read).
202fn fetch_bulks(client: &mut RespClient, cmd: &[&[u8]]) -> io::Result<Option<Vec<Vec<u8>>>> {
203    let Reply::Array(items) = client.request_borrowed(cmd)? else {
204        return Ok(None);
205    };
206    if items.is_empty() {
207        return Ok(None);
208    }
209    Ok(Some(
210        items
211            .into_iter()
212            .filter_map(|r| if let Reply::Bulk(b) = r { Some(b) } else { None })
213            .collect(),
214    ))
215}
216
217/// Encode `<verb> <dst> <vals…>` onto `frame` (HSET / RPUSH / SADD).
218fn encode_multi(frame: &mut Vec<u8>, verb: &[u8], dst: &[u8], vals: &[Vec<u8>]) {
219    let mut argv: Vec<&[u8]> = vec![verb, dst];
220    argv.extend(vals.iter().map(Vec::as_slice));
221    encode_command_borrowed(frame, &argv);
222}
223
224/// Encode `ZADD <dst> score member …` onto `frame`.
225/// ZADD wants score member; ZRANGE gives member score.
226fn encode_zadd(frame: &mut Vec<u8>, dst: &[u8], flat: &[Vec<u8>]) {
227    let mut argv: Vec<&[u8]> = vec![b"ZADD", dst];
228    for pair in flat.chunks(2) {
229        if pair.len() == 2 {
230            argv.push(&pair[1]);
231            argv.push(&pair[0]);
232        }
233    }
234    encode_command_borrowed(frame, &argv);
235}
236
237/// TTL rides as an absolute PEXPIREAT follow-up.
238fn append_ttl_frame(
239    client: &mut RespClient,
240    key: &[u8],
241    dst: &[u8],
242    frame: &mut Vec<u8>,
243) -> io::Result<()> {
244    if let Reply::Int(ms) = client.request_borrowed(&[b"PTTL", key])?
245        && ms > 0
246    {
247        let now = std::time::SystemTime::now()
248            .duration_since(std::time::UNIX_EPOCH)
249            .map_err(io::Error::other)?
250            .as_millis() as i64;
251        encode_command_borrowed(
252            frame,
253            &[b"PEXPIREAT", dst, (now + ms).to_string().as_bytes()],
254        );
255    }
256    Ok(())
257}
258
259/// Import stats.
260pub struct ImportReport {
261    /// Commands sent successfully.
262    pub sent: u64,
263    /// -ERR replies (counted, not fatal unless `strict`).
264    pub errors: u64,
265    /// Byte offset reached in the source file.
266    pub offset: u64,
267}
268
269/// Run `import` — stream `src` (a RESP command file) into the server,
270/// `PIPELINE` commands per batch. The progress file `<src>.progress`
271/// records the safely-applied byte offset after every batch (fsynced);
272/// `resume` starts there. Idempotent replay.
273pub fn run_import(
274    client: &mut RespClient,
275    src: &Path,
276    resume: bool,
277    strict: bool,
278) -> io::Result<ImportReport> {
279    let progress_path = src.with_extension("progress");
280    let mut start = 0u64;
281    if resume && let Ok(text) = std::fs::read_to_string(&progress_path) {
282        start = text.trim().parse().unwrap_or(0);
283    }
284    let mut f = File::open(src)?;
285    f.seek(SeekFrom::Start(start))?;
286    let mut pending: Vec<u8> = Vec::with_capacity(1 << 20);
287    let mut report = ImportReport { sent: 0, errors: 0, offset: start };
288    let mut chunk = vec![0u8; 1 << 20];
289    let mut batch_bytes = 0usize;
290    let mut batch_cmds = 0usize;
291    let mut progress = OpenOptions::new().create(true).truncate(false).write(true).open(&progress_path)?;
292    loop {
293        let n = f.read(&mut chunk)?;
294        if n == 0 {
295            break;
296        }
297        pending.extend_from_slice(&chunk[..n]);
298        // carve complete commands off `pending`
299        while let Some(used) = command_len(&pending[batch_bytes..]) {
300            batch_bytes += used;
301            batch_cmds += 1;
302            if batch_cmds == PIPELINE {
303                flush_batch(client, &pending[..batch_bytes], batch_cmds, strict, &mut report)?;
304                pending.drain(..batch_bytes);
305                write_progress(&mut progress, report.offset)?;
306                batch_bytes = 0;
307                batch_cmds = 0;
308            }
309        }
310    }
311    if batch_cmds > 0 {
312        flush_batch(client, &pending[..batch_bytes], batch_cmds, strict, &mut report)?;
313        write_progress(&mut progress, report.offset)?;
314    }
315    Ok(report)
316}
317
318fn flush_batch(
319    client: &mut RespClient,
320    raw: &[u8],
321    n: usize,
322    strict: bool,
323    report: &mut ImportReport,
324) -> io::Result<()> {
325    let replies = client.pipeline_raw(raw, n)?;
326    for r in replies {
327        if let Reply::Error(e) = r {
328            report.errors += 1;
329            if strict {
330                return Err(io::Error::new(
331                    io::ErrorKind::InvalidData,
332                    format!("server error (strict): {}", String::from_utf8_lossy(&e)),
333                ));
334            }
335        } else {
336            report.sent += 1;
337        }
338    }
339    report.offset += raw.len() as u64;
340    Ok(())
341}
342
343fn write_progress(f: &mut File, offset: u64) -> io::Result<()> {
344    f.set_len(0)?;
345    f.seek(SeekFrom::Start(0))?;
346    f.write_all(offset.to_string().as_bytes())?;
347    f.sync_data()
348}
349
350/// Crate-visible alias for [`command_len`] (bulk copy counts frames).
351pub(crate) fn command_len_pub(b: &[u8]) -> Option<usize> {
352    command_len(b)
353}
354
355/// Length of one complete RESP command at the head of `b`, or `None`.
356fn command_len(b: &[u8]) -> Option<usize> {
357    let mut pos = 0usize;
358    let line = take_line(b, &mut pos)?;
359    if line.first() != Some(&b'*') {
360        return None;
361    }
362    let n: usize = std::str::from_utf8(&line[1..]).ok()?.trim().parse().ok()?;
363    for _ in 0..n {
364        let hdr = take_line(b, &mut pos)?;
365        if hdr.first() != Some(&b'$') {
366            return None;
367        }
368        let len: usize = std::str::from_utf8(&hdr[1..]).ok()?.trim().parse().ok()?;
369        if b.len() < pos + len + 2 {
370            return None;
371        }
372        pos += len + 2;
373    }
374    Some(pos)
375}
376
377fn take_line<'b>(b: &'b [u8], pos: &mut usize) -> Option<&'b [u8]> {
378    let rest = &b[*pos..];
379    let idx = rest.windows(2).position(|w| w == b"\r\n")?;
380    let line = &rest[..idx];
381    *pos += idx + 2;
382    Some(line)
383}