Skip to main content

kevy_cli/
bulk.rs

1//! Prefix bulk ops + diagnostics:
2//! `copy-prefix` / `delete-prefix` (token-bucket rate limit,
3//! `--dry-run`), `digest`, `diff`, `inspect`.
4
5use std::io::{self, Write};
6use std::time::{Duration, Instant};
7
8use kevy_resp::Reply;
9use kevy_resp_client::RespClient;
10
11/// Token bucket: `rate` ops/second, starting EMPTY (strict pacing —
12/// a full-bucket start lets a small job burn its whole burst
13/// unthrottled, defeating the point of `--rate` for short sweeps).
14/// `rate == 0` = unlimited.
15pub struct RateLimiter {
16    rate: u64,
17    tokens: f64,
18    last: Instant,
19}
20
21impl RateLimiter {
22    /// New limiter at `rate` ops/s (0 = off).
23    pub fn new(rate: u64) -> Self {
24        Self { rate, tokens: 0.0, last: Instant::now() }
25    }
26
27    /// Block until one op is admitted.
28    pub fn take(&mut self) {
29        if self.rate == 0 {
30            return;
31        }
32        loop {
33            let now = Instant::now();
34            self.tokens = (self.tokens
35                + now.duration_since(self.last).as_secs_f64() * self.rate as f64)
36                .min(self.rate as f64);
37            self.last = now;
38            if self.tokens >= 1.0 {
39                self.tokens -= 1.0;
40                return;
41            }
42            std::thread::sleep(Duration::from_millis(2));
43        }
44    }
45}
46
47fn scan_page(
48    client: &mut RespClient,
49    cursor: &[u8],
50    pattern: &[u8],
51) -> io::Result<(Vec<u8>, Vec<Vec<u8>>)> {
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)) else {
57        return Err(io::Error::new(io::ErrorKind::InvalidData, "SCAN reply shape"));
58    };
59    let keys = keys
60        .iter()
61        .filter_map(|k| if let Reply::Bulk(b) = k { Some(b.clone()) } else { None })
62        .collect();
63    Ok((next.clone(), keys))
64}
65
66/// `delete-prefix`: SCAN + UNLINK, rate-limited. Returns deleted count.
67pub fn run_delete_prefix(
68    client: &mut RespClient,
69    prefix: &[u8],
70    rate: u64,
71    dry_run: bool,
72) -> io::Result<u64> {
73    let mut pattern = prefix.to_vec();
74    pattern.push(b'*');
75    let mut cursor: Vec<u8> = b"0".to_vec();
76    let mut limiter = RateLimiter::new(rate);
77    let mut n = 0u64;
78    loop {
79        let (next, keys) = scan_page(client, &cursor, &pattern)?;
80        for key in &keys {
81            if dry_run {
82                n += 1;
83                continue;
84            }
85            limiter.take();
86            if let Reply::Int(d) = client.request_borrowed(&[b"UNLINK", key])? {
87                n += d as u64;
88            }
89        }
90        cursor = next;
91        if cursor == b"0" {
92            return Ok(n);
93        }
94    }
95}
96
97/// `copy-prefix`: SCAN src prefix, re-key under dst prefix via
98/// read+rebuild frames (the server has no COPY verb; TTL carried as
99/// absolute PEXPIREAT). Rate-limited per source key.
100///
101/// Returns what it copied **and what it could not** — same contract as
102/// `export`, for the same reason: the rebuild set does not cover every
103/// type, and a copy that quietly drops one is worse than a refusal.
104pub fn run_copy_prefix(
105    client: &mut RespClient,
106    src_prefix: &[u8],
107    dst_prefix: &[u8],
108    rate: u64,
109) -> io::Result<crate::migrate::Export> {
110    let mut pattern = src_prefix.to_vec();
111    pattern.push(b'*');
112    let mut cursor: Vec<u8> = b"0".to_vec();
113    let mut limiter = RateLimiter::new(rate);
114    let mut n = 0u64;
115    // Same skip as `export`, and it must be as loud: a copy that leaves
116    // a type behind and says "copied N keys" is the same silence.
117    let mut skipped: std::collections::BTreeMap<Vec<u8>, u64> = Default::default();
118    loop {
119        let (next, keys) = scan_page(client, &cursor, &pattern)?;
120        for key in &keys {
121            limiter.take();
122            let mut dst = dst_prefix.to_vec();
123            dst.extend_from_slice(&key[src_prefix.len()..]);
124            let frames = match crate::migrate::rebuild_frames(client, key, &dst)? {
125                crate::migrate::Rebuilt::Frames(f) => f,
126                crate::migrate::Rebuilt::Vanished => continue,
127                crate::migrate::Rebuilt::UnsupportedType(ty) => {
128                    *skipped.entry(ty).or_insert(0u64) += 1;
129                    continue;
130                }
131            };
132            let n_cmds = count_commands(&frames);
133            for r in client.pipeline_raw(&frames, n_cmds)? {
134                if let Reply::Error(e) = r {
135                    return Err(io::Error::new(
136                        io::ErrorKind::InvalidData,
137                        String::from_utf8_lossy(&e).into_owned(),
138                    ));
139                }
140            }
141            n += 1;
142        }
143        cursor = next;
144        if cursor == b"0" {
145            return Ok(crate::migrate::Export { keys: n, skipped });
146        }
147    }
148}
149
150/// Number of RESP commands in a frame buffer (top-level '*' headers).
151fn count_commands(mut b: &[u8]) -> usize {
152    let mut n = 0;
153    while let Some(len) = crate::migrate::command_len_pub(b) {
154        n += 1;
155        b = &b[len..];
156    }
157    n
158}
159
160/// `digest <prefix>` → (count, hex).
161pub fn run_digest(client: &mut RespClient, prefix: &[u8]) -> io::Result<(i64, String)> {
162    let r = client.request_borrowed(&[b"PREFIX.DIGEST", prefix])?;
163    let Reply::Array(items) = r else {
164        return Err(io::Error::new(io::ErrorKind::InvalidData, "PREFIX.DIGEST reply"));
165    };
166    let (Some(Reply::Int(n)), Some(Reply::Bulk(hex))) = (items.first(), items.get(1)) else {
167        return Err(io::Error::new(io::ErrorKind::InvalidData, "PREFIX.DIGEST reply"));
168    };
169    Ok((*n, String::from_utf8_lossy(hex).into_owned()))
170}
171
172/// `diff`: compare prefixes across two servers. Returns mismatching
173/// prefixes.
174pub fn run_diff(
175    a: &mut RespClient,
176    b: &mut RespClient,
177    prefixes: &[Vec<u8>],
178    out: &mut impl Write,
179) -> io::Result<Vec<Vec<u8>>> {
180    let mut bad = Vec::new();
181    for p in prefixes {
182        let (na, da) = run_digest(a, p)?;
183        let (nb, db) = run_digest(b, p)?;
184        let ok = na == nb && da == db;
185        writeln!(
186            out,
187            "{}  A: {na} keys {da}  B: {nb} keys {db}  {}",
188            String::from_utf8_lossy(p),
189            if ok { "OK" } else { "MISMATCH" }
190        )?;
191        if !ok {
192            bad.push(p.clone());
193        }
194    }
195    Ok(bad)
196}
197
198/// `inspect <prefix>`: sample keys, type distribution, sizes.
199pub fn run_inspect(client: &mut RespClient, prefix: &[u8], out: &mut impl Write) -> io::Result<()> {
200    let mut pattern = prefix.to_vec();
201    pattern.push(b'*');
202    let mut cursor: Vec<u8> = b"0".to_vec();
203    let mut total = 0u64;
204    let mut by_type: Vec<(String, u64)> = Vec::new();
205    let mut samples: Vec<String> = Vec::new();
206    loop {
207        let (next, keys) = scan_page(client, &cursor, &pattern)?;
208        for key in &keys {
209            total += 1;
210            if samples.len() < 8 {
211                samples.push(String::from_utf8_lossy(key).into_owned());
212            }
213            if let Reply::Simple(t) = client.request_borrowed(&[b"TYPE", key])? {
214                let t = String::from_utf8_lossy(&t).into_owned();
215                match by_type.iter_mut().find(|(n, _)| *n == t) {
216                    Some((_, c)) => *c += 1,
217                    None => by_type.push((t, 1)),
218                }
219            }
220        }
221        cursor = next;
222        if cursor == b"0" {
223            break;
224        }
225    }
226    writeln!(out, "prefix {}: {total} keys", String::from_utf8_lossy(prefix))?;
227    by_type.sort_by_key(|(_, c)| std::cmp::Reverse(*c));
228    for (t, c) in &by_type {
229        writeln!(out, "  {t}: {c}")?;
230    }
231    if !samples.is_empty() {
232        writeln!(out, "  samples: {}", samples.join(", "))?;
233    }
234    Ok(())
235}