use std::io::{Read, Write};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
static START_GATE: Mutex<()> = Mutex::new(());
const NSHARDS: usize = 8;
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
fn req(parts: &[&[u8]]) -> Vec<u8> {
let mut v = format!("*{}\r\n", parts.len()).into_bytes();
for p in parts {
v.extend_from_slice(format!("${}\r\n", p.len()).as_bytes());
v.extend_from_slice(p);
v.extend_from_slice(b"\r\n");
}
v
}
fn cmd(s: &mut std::net::TcpStream, parts: &[&[u8]]) -> Vec<u8> {
s.write_all(&req(parts)).unwrap();
std::thread::sleep(std::time::Duration::from_millis(30));
let mut buf = [0u8; 65536];
let n = s.read(&mut buf).unwrap();
buf[..n].to_vec()
}
struct Feed {
port: u16,
dir: std::path::PathBuf,
stop: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl Feed {
fn boot(dir: std::path::PathBuf) -> Self {
let port = free_port();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let dir_thread = dir.clone();
let handle = std::thread::spawn(move || {
let rt = kevy_rt::Runtime::new([127, 0, 0, 1], port, NSHARDS, kevy::KevyCommands)
.with_data_dir(dir_thread)
.with_feed(true, 0);
rt.run(stop_thread).unwrap();
});
for _ in 0..400 {
if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
Self { port, dir, stop, handle: Some(handle) }
}
fn start() -> Self {
let _gate = START_GATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = std::env::temp_dir().join(format!(
"kevy-feed-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
Self::boot(dir)
}
fn connect(&self) -> std::net::TcpStream {
let s = std::net::TcpStream::connect(("127.0.0.1", self.port)).unwrap();
s.set_read_timeout(Some(std::time::Duration::from_secs(8))).unwrap();
s
}
fn shutdown(mut self) -> std::path::PathBuf {
self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = std::net::TcpStream::connect(("127.0.0.1", self.port));
if let Some(h) = self.handle.take() {
let _ = h.join();
}
let dir = self.dir.clone();
std::mem::forget(self); dir
}
}
impl Drop for Feed {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = std::net::TcpStream::connect(("127.0.0.1", self.port));
if let Some(h) = self.handle.take() {
let _ = h.join();
}
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn parse_tail(reply: &[u8]) -> (u64, u64) {
let s = String::from_utf8_lossy(reply);
let mut nums = s
.lines()
.filter(|l| l.starts_with(':'))
.map(|l| l[1..].trim().parse::<u64>().unwrap());
(nums.next().unwrap(), nums.next().unwrap())
}
#[test]
fn feed_shards_tail_read_roundtrip() {
let srv = Feed::start();
let mut c = srv.connect();
assert_eq!(cmd(&mut c, &[b"FEED.SHARDS"]), format!(":{NSHARDS}\r\n").into_bytes());
for i in 0..32 {
cmd(&mut c, &[b"SET", format!("fk{i}").as_bytes(), b"v"]);
}
let mut hit = None;
for sh in 0..NSHARDS {
let (g, off) = parse_tail(&cmd(&mut c, &[b"FEED.TAIL", sh.to_string().as_bytes()]));
assert_eq!(g, 1, "fresh dir starts at generation 1");
if off > 0 {
hit = Some((sh, off));
break;
}
}
let (sh, off) = hit.expect("some shard saw writes");
let r = cmd(
&mut c,
&[b"FEED.READ", sh.to_string().as_bytes(), b"1", b"0", b"COUNT", b"100"],
);
let s = String::from_utf8_lossy(&r);
assert!(s.starts_with("*3\r\n:1\r\n"), "got {s}");
assert!(s.contains("SET"), "frames carry the effect argv: {s}");
assert!(s.contains(&format!(":{off}\r\n")), "next cursor = tail: {s}");
let r2 = cmd(
&mut c,
&[b"FEED.READ", sh.to_string().as_bytes(), b"1", off.to_string().as_bytes()],
);
let s2 = String::from_utf8_lossy(&r2);
assert!(s2.ends_with("*0\r\n"), "caught up: {s2}");
}
#[test]
fn feed_prefix_filter_and_errors() {
let srv = Feed::start();
let mut c = srv.connect();
for i in 0..16 {
cmd(&mut c, &[b"SET", format!("user:{i}").as_bytes(), b"u"]);
cmd(&mut c, &[b"SET", format!("sess:{i}").as_bytes(), b"s"]);
}
for sh in 0..NSHARDS {
let (_, off) = parse_tail(&cmd(&mut c, &[b"FEED.TAIL", sh.to_string().as_bytes()]));
if off == 0 {
continue;
}
let r = cmd(
&mut c,
&[
b"FEED.READ", sh.to_string().as_bytes(), b"1", b"0",
b"COUNT", b"100", b"PREFIX", b"user:",
],
);
let s = String::from_utf8_lossy(&r);
assert!(!s.contains("sess:"), "filtered out other prefixes: {s}");
}
let r = cmd(&mut c, &[b"FEED.READ", b"99", b"1", b"0"]);
assert!(r.starts_with(b"-ERR shard out of range"), "{:?}", String::from_utf8_lossy(&r));
let r = cmd(&mut c, &[b"FEED.READ", b"0", b"9", b"0"]);
assert!(
r.starts_with(b"-ERR feed cursor ahead"),
"{:?}",
String::from_utf8_lossy(&r)
);
}
#[test]
fn flushall_bumps_generation_and_old_cursor_resyncs() {
let srv = Feed::start();
let mut c = srv.connect();
for i in 0..16 {
cmd(&mut c, &[b"SET", format!("gb{i}").as_bytes(), b"v"]);
}
cmd(&mut c, &[b"FLUSHALL"]);
let (g, off) = parse_tail(&cmd(&mut c, &[b"FEED.TAIL", b"0"]));
assert_eq!(g, 2);
assert_eq!(off, 0);
let r = cmd(&mut c, &[b"FEED.READ", b"0", b"1", b"5"]);
assert!(r.starts_with(b"-FEEDRESYNC 2 0"), "{:?}", String::from_utf8_lossy(&r));
}
#[test]
fn clean_restart_keeps_cursor_unclean_bumps() {
let _gate = START_GATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = std::env::temp_dir().join(format!(
"kevy-feed-restart-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let srv = Feed::boot(dir.clone());
let mut c = srv.connect();
for i in 0..32 {
cmd(&mut c, &[b"SET", format!("rk{i}").as_bytes(), b"v"]);
}
let mut busy = (0usize, 0u64);
for sh in 0..NSHARDS {
let (_, off) = parse_tail(&cmd(&mut c, &[b"FEED.TAIL", sh.to_string().as_bytes()]));
if off > busy.1 {
busy = (sh, off);
}
}
drop(c);
let dir = srv.shutdown();
let srv2 = Feed::boot(dir.clone());
let mut c2 = srv2.connect();
let (g, off) = parse_tail(&cmd(&mut c2, &[b"FEED.TAIL", busy.0.to_string().as_bytes()]));
assert_eq!(g, 1, "clean restart keeps the generation");
assert_eq!(off, busy.1, "clean restart keeps the offset");
drop(c2);
let dir = srv2.shutdown();
for i in 0..NSHARDS {
std::fs::remove_file(dir.join(format!("feed-{i}.meta"))).unwrap();
}
let srv3 = Feed::boot(dir.clone());
let mut c3 = srv3.connect();
let (g3, off3) = parse_tail(&cmd(&mut c3, &[b"FEED.TAIL", busy.0.to_string().as_bytes()]));
assert_eq!(g3, 2, "unclean restart bumps the generation");
assert_eq!(off3, 0, "offsets restart after a bump");
drop(c3);
drop(srv3);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_snapshot_records_feed_cursor() {
let srv = Feed::start();
let mut c = srv.connect();
for i in 0..16 {
cmd(&mut c, &[b"SET", format!("sc{i}").as_bytes(), b"v"]);
}
let r = cmd(&mut c, &[b"SAVE"]);
assert!(r.starts_with(b"+OK"), "{:?}", String::from_utf8_lossy(&r));
std::thread::sleep(std::time::Duration::from_millis(400));
let mut seen_any = false;
for sh in 0..NSHARDS {
let dump = srv.dir.join(format!("dump-{sh}.rdb"));
if !dump.exists() {
continue;
}
let cur = kevy_persist::read_snapshot_cursor(&dump).unwrap();
let (g, off) = parse_tail(&cmd(&mut c, &[b"FEED.TAIL", sh.to_string().as_bytes()]));
assert_eq!(cur, Some((g, off)), "shard {sh} snapshot cursor = live tail");
if off > 0 {
seen_any = true;
}
}
assert!(seen_any, "at least one shard had frames + a cursor");
}
#[test]
fn prefix_stats_fanout() {
let srv = Feed::start();
let mut c = srv.connect();
for i in 0..20 {
cmd(&mut c, &[b"SET", format!("ps:{i}").as_bytes(), b"v"]);
}
cmd(&mut c, &[b"SET", b"other:1", b"v"]);
cmd(&mut c, &[b"EXPIRE", b"ps:1", b"1000"]);
let r = cmd(&mut c, &[b"PREFIX.STATS", b"ps:"]);
let s = String::from_utf8_lossy(&r);
assert!(s.contains("keys\r\n:20\r\n"), "20 keys under ps:, got {s}");
assert!(s.contains("expires\r\n:1\r\n"), "1 ttl'd, got {s}");
}