use std::io::{Read, Write};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
static START_GATE: Mutex<()> = Mutex::new(());
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(40));
let mut buf = [0u8; 65536];
let n = s.read(&mut buf).unwrap();
buf[..n].to_vec()
}
struct Server {
port: u16,
dir: std::path::PathBuf,
stop: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl Server {
fn start() -> Self {
let _gate = START_GATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let dir = std::env::temp_dir().join(format!(
"kevy-idxcov-{}",
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
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::builder(kevy::KevyCommands::sharded(4))
.bind([127, 0, 0, 1], port)
.shards(4)
.with_data_dir(dir_thread);
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 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
}
}
impl Drop for Server {
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 counter(reply: &[u8], field: &str) -> u64 {
let text = String::from_utf8_lossy(reply);
let mut items = text.split("\r\n").filter(|s| !s.is_empty() && !s.starts_with(['*', '$', ':']));
while let Some(k) = items.next() {
if k == field {
return items.next().and_then(|v| v.parse().ok()).unwrap_or(0);
}
}
let raw: Vec<&str> = text.split("\r\n").filter(|s| !s.is_empty()).collect();
for (i, k) in raw.iter().enumerate() {
if *k == field {
return raw.get(i + 1).map_or(0, |v| v.trim_start_matches(':').parse().unwrap_or(0));
}
}
0
}
fn assert_clean(c: &mut std::net::TcpStream, after: &str) {
for attempt in 0..40 {
let r = cmd(c, &[b"IDX.VERIFY", b"u_age"]);
if r.starts_with(b"-INDEXBUILDING") {
std::thread::sleep(std::time::Duration::from_millis(50));
continue;
}
let (drift, missing) = (counter(&r, "drift"), counter(&r, "missing"));
if drift == 0 && missing == 0 {
return;
}
if attempt == 39 {
panic!(
"index disagrees with the keyspace after {after}: \
drift {drift}, missing {missing}\n{}",
String::from_utf8_lossy(&r)
);
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
fn agg_count(c: &mut std::net::TcpStream) -> i64 {
let r = cmd(c, &[b"IDX.QUERY", b"u_grp", b"GROUP", b"red"]);
let text = String::from_utf8_lossy(&r);
for line in text.split("\r\n") {
if let Ok(n) = line.parse::<i64>() {
return n; }
}
-1
}
fn live_rows(c: &mut std::net::TcpStream) -> i64 {
let r = cmd(c, &[b"KEYS", b"u:*"]);
let text = String::from_utf8_lossy(&r).into_owned();
let mut n = 0;
for line in text.split("\r\n") {
if !line.starts_with("u:") {
continue;
}
let key = line.to_string();
let g = cmd(c, &[b"HGET", key.as_bytes(), b"team"]);
if !String::from_utf8_lossy(&g).contains("red") {
continue;
}
let v = cmd(c, &[b"HGET", key.as_bytes(), b"age"]);
let vt = String::from_utf8_lossy(&v);
if vt.starts_with("$-1") || vt.starts_with("_") {
continue; }
n += 1;
}
n
}
fn assert_agg_matches(c: &mut std::net::TcpStream, after: &str) {
for attempt in 0..40 {
let (agg, live) = (agg_count(c), live_rows(c));
if agg == live {
return;
}
if attempt == 39 {
panic!("aggregate count {agg} but {live} live row(s) carry the group, after {after}");
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
fn ok(c: &mut std::net::TcpStream, parts: &[&[u8]], what: &str) -> Vec<u8> {
let r = cmd(c, parts);
assert!(
!r.starts_with(b"-"),
"{what} was refused, so this case tests nothing: {}",
String::from_utf8_lossy(&r)
);
r
}
#[test]
fn every_write_path_that_touches_a_row_keeps_the_index_honest() {
let srv = Server::start();
let mut c = srv.connect();
ok(&mut c, &[b"IDX.CREATE", b"u_age", b"ON", b"PREFIX", b"u:", b"FIELD", b"age", b"TYPE", b"i64", b"KIND", b"range"], "IDX.CREATE");
ok(&mut c, &[b"IDX.CREATE", b"u_grp", b"ON", b"PREFIX", b"u:", b"FIELD", b"age", b"TYPE", b"i64", b"KIND", b"agg", b"GROUPBY", b"team"], "IDX.CREATE agg");
for i in 1..=12 {
let key = format!("u:{i}");
let age = format!("{}", 20 + i);
ok(&mut c, &[b"HSET", key.as_bytes(), b"age", age.as_bytes(), b"team", b"red"], "HSET seed");
}
assert_clean(&mut c, "the seed writes");
assert_agg_matches(&mut c, "the seed writes");
ok(&mut c, &[b"HSET", b"u:1", b"age", b"31"], "HSET overwrite");
assert_clean(&mut c, "HSET (overwrite the indexed field)");
ok(&mut c, &[b"HSETNX", b"u:13", b"age", b"40"], "HSETNX");
assert_clean(&mut c, "HSETNX (new row)");
ok(&mut c, &[b"HINCRBY", b"u:2", b"age", b"5"], "HINCRBY");
assert_clean(&mut c, "HINCRBY (indexed field moves)");
ok(&mut c, &[b"HDEL", b"u:3", b"age"], "HDEL");
assert_clean(&mut c, "HDEL (the indexed field goes; the row no longer derives)");
ok(&mut c, &[b"DEL", b"u:4"], "DEL");
assert_clean(&mut c, "DEL (single key)");
ok(&mut c, &[b"DEL", b"u:5", b"u:6"], "multi-key DEL");
assert_clean(&mut c, "DEL (multi-key)");
assert_agg_matches(&mut c, "DEL (multi-key)");
ok(&mut c, &[b"UNLINK", b"u:7", b"u:8"], "multi-key UNLINK");
assert_clean(&mut c, "UNLINK (multi-key)");
ok(&mut c, &[b"RENAME", b"u:9", b"u:900"], "RENAME");
assert_clean(&mut c, "RENAME (row moves inside the prefix)");
ok(&mut c, &[b"RENAME", b"u:10", b"other:10"], "RENAME out of prefix");
assert_clean(&mut c, "RENAME (row leaves the indexed prefix)");
ok(&mut c, &[b"SET", b"u:12", b"not-a-hash"], "SET over a hash");
assert_clean(&mut c, "SET over an indexed hash (type change)");
ok(&mut c, &[b"PEXPIRE", b"u:11", b"50"], "PEXPIRE");
std::thread::sleep(std::time::Duration::from_millis(400));
assert_clean(&mut c, "PEXPIRE (the row expires with no command to see it)");
assert_agg_matches(&mut c, "PEXPIRE");
ok(&mut c, &[b"MSET", b"u:1", b"gone", b"u:2", b"gone"], "MSET over hashes");
assert_clean(&mut c, "MSET (multi-key type change over indexed rows)");
}