Simplify moar
This commit is contained in:
parent
7f3ec69cf6
commit
07490efc28
14 changed files with 261 additions and 1061 deletions
98
Cargo.lock
generated
98
Cargo.lock
generated
|
|
@ -377,23 +377,6 @@ version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-io"
|
|
||||||
version = "0.3.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-macro"
|
|
||||||
version = "0.3.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
@ -413,11 +396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
|
||||||
"futures-macro",
|
|
||||||
"futures-sink",
|
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
@ -878,7 +857,6 @@ name = "mkv"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"bytes",
|
|
||||||
"clap",
|
"clap",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|
@ -886,7 +864,6 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
@ -1082,7 +1059,6 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
|
||||||
"h2",
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
|
@ -1104,14 +1080,12 @@ dependencies = [
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
"tokio-util",
|
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1299,15 +1273,6 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_spanned"
|
|
||||||
version = "0.6.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
|
|
@ -1541,47 +1506,6 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml"
|
|
||||||
version = "0.8.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_spanned",
|
|
||||||
"toml_datetime",
|
|
||||||
"toml_edit",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml_datetime"
|
|
||||||
version = "0.6.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml_edit"
|
|
||||||
version = "0.22.27"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
|
||||||
dependencies = [
|
|
||||||
"indexmap",
|
|
||||||
"serde",
|
|
||||||
"serde_spanned",
|
|
||||||
"toml_datetime",
|
|
||||||
"toml_write",
|
|
||||||
"winnow",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml_write"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|
@ -1872,19 +1796,6 @@ dependencies = [
|
||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-streams"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
|
||||||
dependencies = [
|
|
||||||
"futures-util",
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasmparser"
|
name = "wasmparser"
|
||||||
version = "0.244.0"
|
version = "0.244.0"
|
||||||
|
|
@ -2024,15 +1935,6 @@ version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winnow"
|
|
||||||
version = "0.7.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,13 @@ edition = "2024"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
rusqlite = { version = "0.35", features = ["bundled"] }
|
rusqlite = { version = "0.35", features = ["bundled"] }
|
||||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = "0.8"
|
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
bytes = "1"
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
use serde::Deserialize;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
pub struct Config {
|
|
||||||
pub server: ServerConfig,
|
|
||||||
pub database: DatabaseConfig,
|
|
||||||
pub volumes: Vec<VolumeConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
pub struct ServerConfig {
|
|
||||||
pub port: u16,
|
|
||||||
pub replication_factor: usize,
|
|
||||||
pub virtual_nodes: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
pub struct DatabaseConfig {
|
|
||||||
pub path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
pub struct VolumeConfig {
|
|
||||||
pub url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
let contents = std::fs::read_to_string(path)?;
|
|
||||||
let config: Config = toml::from_str(&contents)?;
|
|
||||||
if config.volumes.is_empty() {
|
|
||||||
return Err("at least one volume is required".into());
|
|
||||||
}
|
|
||||||
if config.server.replication_factor == 0 {
|
|
||||||
return Err("replication_factor must be >= 1".into());
|
|
||||||
}
|
|
||||||
if config.server.replication_factor > config.volumes.len() {
|
|
||||||
return Err("replication_factor exceeds number of volumes".into());
|
|
||||||
}
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn volume_urls(&self) -> Vec<String> {
|
|
||||||
self.volumes.iter().map(|v| v.url.clone()).collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
176
src/db.rs
176
src/db.rs
|
|
@ -1,11 +1,8 @@
|
||||||
use rusqlite::{params, Connection, OpenFlags};
|
use rusqlite::{params, Connection, OpenFlags};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
// --- Record type ---
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Record {
|
pub struct Record {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
|
|
@ -13,36 +10,35 @@ pub struct Record {
|
||||||
pub size: Option<i64>,
|
pub size: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SQLite setup ---
|
|
||||||
|
|
||||||
fn apply_pragmas(conn: &Connection) {
|
fn apply_pragmas(conn: &Connection) {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"
|
"PRAGMA journal_mode = WAL;
|
||||||
PRAGMA journal_mode = WAL;
|
|
||||||
PRAGMA synchronous = NORMAL;
|
PRAGMA synchronous = NORMAL;
|
||||||
PRAGMA busy_timeout = 5000;
|
PRAGMA busy_timeout = 5000;
|
||||||
PRAGMA temp_store = memory;
|
PRAGMA temp_store = memory;
|
||||||
PRAGMA cache_size = -64000;
|
PRAGMA cache_size = -64000;
|
||||||
PRAGMA mmap_size = 268435456;
|
PRAGMA mmap_size = 268435456;
|
||||||
PRAGMA page_size = 4096;
|
PRAGMA page_size = 4096;",
|
||||||
",
|
|
||||||
)
|
)
|
||||||
.expect("failed to set pragmas");
|
.expect("failed to set pragmas");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_readonly(path: &str) -> Connection {
|
fn parse_volumes(s: &str) -> Vec<String> {
|
||||||
let conn = Connection::open_with_flags(
|
serde_json::from_str(s).unwrap_or_default()
|
||||||
path,
|
|
||||||
OpenFlags::SQLITE_OPEN_READ_ONLY
|
|
||||||
| OpenFlags::SQLITE_OPEN_NO_MUTEX
|
|
||||||
| OpenFlags::SQLITE_OPEN_URI,
|
|
||||||
)
|
|
||||||
.expect("failed to open read connection");
|
|
||||||
apply_pragmas(&conn);
|
|
||||||
conn
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_readwrite(path: &str) -> Connection {
|
fn encode_volumes(v: &[String]) -> String {
|
||||||
|
serde_json::to_string(v).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single SQLite connection behind a mutex, used for both reads and writes.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Db {
|
||||||
|
conn: Arc<Mutex<Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
pub fn new(path: &str) -> Self {
|
||||||
let conn = Connection::open_with_flags(
|
let conn = Connection::open_with_flags(
|
||||||
path,
|
path,
|
||||||
OpenFlags::SQLITE_OPEN_READ_WRITE
|
OpenFlags::SQLITE_OPEN_READ_WRITE
|
||||||
|
|
@ -50,137 +46,59 @@ fn open_readwrite(path: &str) -> Connection {
|
||||||
| OpenFlags::SQLITE_OPEN_NO_MUTEX
|
| OpenFlags::SQLITE_OPEN_NO_MUTEX
|
||||||
| OpenFlags::SQLITE_OPEN_URI,
|
| OpenFlags::SQLITE_OPEN_URI,
|
||||||
)
|
)
|
||||||
.expect("failed to open write connection");
|
.expect("failed to open database");
|
||||||
apply_pragmas(&conn);
|
apply_pragmas(&conn);
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_tables(conn: &Connection) {
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"
|
"CREATE TABLE IF NOT EXISTS kv (
|
||||||
CREATE TABLE IF NOT EXISTS kv (
|
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
volumes TEXT NOT NULL,
|
volumes TEXT NOT NULL,
|
||||||
size INTEGER,
|
size INTEGER,
|
||||||
created_at INTEGER DEFAULT (unixepoch())
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
);
|
);",
|
||||||
",
|
|
||||||
)
|
)
|
||||||
.expect("failed to create tables");
|
.expect("failed to create tables");
|
||||||
|
Self { conn: Arc::new(Mutex::new(conn)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_volumes(volumes_json: &str) -> Vec<String> {
|
pub async fn get(&self, key: &str) -> Result<Record, AppError> {
|
||||||
serde_json::from_str(volumes_json).unwrap_or_default()
|
let conn = self.conn.clone();
|
||||||
}
|
let key = key.to_string();
|
||||||
|
|
||||||
fn encode_volumes(volumes: &[String]) -> String {
|
|
||||||
serde_json::to_string(volumes).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ReadPool ---
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ReadPool {
|
|
||||||
conns: Vec<Arc<Mutex<Connection>>>,
|
|
||||||
next: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReadPool {
|
|
||||||
pub fn new(path: &str, size: usize) -> Self {
|
|
||||||
let conns = (0..size)
|
|
||||||
.map(|_| Arc::new(Mutex::new(open_readonly(path))))
|
|
||||||
.collect();
|
|
||||||
Self {
|
|
||||||
conns,
|
|
||||||
next: Arc::new(AtomicUsize::new(0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn query<T, F>(&self, f: F) -> Result<T, AppError>
|
|
||||||
where
|
|
||||||
T: Send + 'static,
|
|
||||||
F: FnOnce(&Connection) -> Result<T, AppError> + Send + 'static,
|
|
||||||
{
|
|
||||||
let idx = self.next.fetch_add(1, Ordering::Relaxed) % self.conns.len();
|
|
||||||
let conn = self.conns[idx].clone();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let conn = conn.lock().unwrap();
|
let conn = conn.lock().unwrap();
|
||||||
f(&conn)
|
let mut stmt = conn.prepare_cached("SELECT key, volumes, size FROM kv WHERE key = ?1")?;
|
||||||
|
Ok(stmt.query_row(params![key], |row| {
|
||||||
|
let vj: String = row.get(1)?;
|
||||||
|
Ok(Record { key: row.get(0)?, volumes: parse_volumes(&vj), size: row.get(2)? })
|
||||||
|
})?)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// --- Read query functions ---
|
pub async fn list_keys(&self, prefix: &str) -> Result<Vec<String>, AppError> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
pub fn get(conn: &Connection, key: &str) -> Result<Record, AppError> {
|
|
||||||
let mut stmt = conn.prepare_cached("SELECT key, volumes, size FROM kv WHERE key = ?1")?;
|
|
||||||
Ok(stmt.query_row(params![key], |row| {
|
|
||||||
let volumes_json: String = row.get(1)?;
|
|
||||||
Ok(Record {
|
|
||||||
key: row.get(0)?,
|
|
||||||
volumes: parse_volumes(&volumes_json),
|
|
||||||
size: row.get(2)?,
|
|
||||||
})
|
|
||||||
})?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_keys(conn: &Connection, prefix: &str) -> Result<Vec<String>, AppError> {
|
|
||||||
let mut stmt = conn.prepare_cached("SELECT key FROM kv WHERE key LIKE ?1 ORDER BY key")?;
|
|
||||||
let pattern = format!("{prefix}%");
|
let pattern = format!("{prefix}%");
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let conn = conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare_cached("SELECT key FROM kv WHERE key LIKE ?1 ORDER BY key")?;
|
||||||
let keys = stmt
|
let keys = stmt
|
||||||
.query_map(params![pattern], |row| row.get(0))?
|
.query_map(params![pattern], |row| row.get(0))?
|
||||||
.collect::<Result<Vec<String>, _>>()?;
|
.collect::<Result<Vec<String>, _>>()?;
|
||||||
Ok(keys)
|
Ok(keys)
|
||||||
}
|
|
||||||
|
|
||||||
pub fn all_records(conn: &Connection) -> Result<Vec<Record>, AppError> {
|
|
||||||
let mut stmt = conn.prepare_cached("SELECT key, volumes, size FROM kv")?;
|
|
||||||
let records = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
let volumes_json: String = row.get(1)?;
|
|
||||||
Ok(Record {
|
|
||||||
key: row.get(0)?,
|
|
||||||
volumes: parse_volumes(&volumes_json),
|
|
||||||
size: row.get(2)?,
|
|
||||||
})
|
})
|
||||||
})?
|
.await
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.unwrap()
|
||||||
Ok(records)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WriterHandle ---
|
pub async fn put(&self, key: String, volumes: Vec<String>, size: Option<i64>) -> Result<(), AppError> {
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WriterHandle {
|
|
||||||
conn: Arc<Mutex<Connection>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WriterHandle {
|
|
||||||
pub fn new(path: &str) -> Self {
|
|
||||||
let conn = open_readwrite(path);
|
|
||||||
create_tables(&conn);
|
|
||||||
Self {
|
|
||||||
conn: Arc::new(Mutex::new(conn)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn put(
|
|
||||||
&self,
|
|
||||||
key: String,
|
|
||||||
volumes: Vec<String>,
|
|
||||||
size: Option<i64>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = self.conn.clone();
|
let conn = self.conn.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let conn = conn.lock().unwrap();
|
let conn = conn.lock().unwrap();
|
||||||
let volumes_json = encode_volumes(&volumes);
|
|
||||||
conn.prepare_cached(
|
conn.prepare_cached(
|
||||||
"INSERT INTO kv (key, volumes, size) VALUES (?1, ?2, ?3)
|
"INSERT INTO kv (key, volumes, size) VALUES (?1, ?2, ?3)
|
||||||
ON CONFLICT(key) DO UPDATE SET volumes = ?2, size = ?3",
|
ON CONFLICT(key) DO UPDATE SET volumes = ?2, size = ?3",
|
||||||
)?
|
)?
|
||||||
.execute(params![key, volumes_json, size])?;
|
.execute(params![key, encode_volumes(&volumes), size])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
@ -199,10 +117,7 @@ impl WriterHandle {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn bulk_put(
|
pub async fn bulk_put(&self, records: Vec<(String, Vec<String>, Option<i64>)>) -> Result<(), AppError> {
|
||||||
&self,
|
|
||||||
records: Vec<(String, Vec<String>, Option<i64>)>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = self.conn.clone();
|
let conn = self.conn.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let conn = conn.lock().unwrap();
|
let conn = conn.lock().unwrap();
|
||||||
|
|
@ -211,12 +126,23 @@ impl WriterHandle {
|
||||||
ON CONFLICT(key) DO UPDATE SET volumes = ?2, size = ?3",
|
ON CONFLICT(key) DO UPDATE SET volumes = ?2, size = ?3",
|
||||||
)?;
|
)?;
|
||||||
for (key, volumes, size) in &records {
|
for (key, volumes, size) in &records {
|
||||||
let volumes_json = encode_volumes(volumes);
|
stmt.execute(params![key, encode_volumes(volumes), size])?;
|
||||||
stmt.execute(params![key, volumes_json, size])?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn all_records_sync(&self) -> Result<Vec<Record>, AppError> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare_cached("SELECT key, volumes, size FROM kv")?;
|
||||||
|
let records = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
let vj: String = row.get(1)?;
|
||||||
|
Ok(Record { key: row.get(0)?, volumes: parse_volumes(&vj), size: row.get(2)? })
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ pub enum AppError {
|
||||||
NotFound,
|
NotFound,
|
||||||
Db(rusqlite::Error),
|
Db(rusqlite::Error),
|
||||||
VolumeError(String),
|
VolumeError(String),
|
||||||
NoHealthyVolume,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<rusqlite::Error> for AppError {
|
impl From<rusqlite::Error> for AppError {
|
||||||
|
|
@ -24,7 +23,6 @@ impl std::fmt::Display for AppError {
|
||||||
AppError::NotFound => write!(f, "not found"),
|
AppError::NotFound => write!(f, "not found"),
|
||||||
AppError::Db(e) => write!(f, "database error: {e}"),
|
AppError::Db(e) => write!(f, "database error: {e}"),
|
||||||
AppError::VolumeError(msg) => write!(f, "volume error: {msg}"),
|
AppError::VolumeError(msg) => write!(f, "volume error: {msg}"),
|
||||||
AppError::NoHealthyVolume => write!(f, "no healthy volume available"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +31,6 @@ impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, msg) = match &self {
|
let (status, msg) = match &self {
|
||||||
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
AppError::NoHealthyVolume => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
|
|
||||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||||
};
|
};
|
||||||
(status, msg).into_response()
|
(status, msg).into_response()
|
||||||
|
|
|
||||||
155
src/hasher.rs
155
src/hasher.rs
|
|
@ -1,61 +1,18 @@
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
pub struct Ring {
|
/// Pick `count` volumes for a key by hashing key+volume, sorting by score.
|
||||||
nodes: BTreeMap<u64, String>,
|
/// Same idea as minikeyvalue's key2volume — stable in volume name, not position.
|
||||||
virtual_nodes: usize,
|
pub fn volumes_for_key(key: &str, volumes: &[String], count: usize) -> Vec<String> {
|
||||||
}
|
let mut scored: Vec<(u64, &String)> = volumes
|
||||||
|
.iter()
|
||||||
impl Ring {
|
.map(|v| {
|
||||||
pub fn new(volumes: &[String], virtual_nodes: usize) -> Self {
|
let hash = Sha256::digest(format!("{key}:{v}").as_bytes());
|
||||||
let mut ring = Self {
|
let score = u64::from_be_bytes(hash[..8].try_into().unwrap());
|
||||||
nodes: BTreeMap::new(),
|
(score, v)
|
||||||
virtual_nodes,
|
})
|
||||||
};
|
.collect();
|
||||||
for url in volumes {
|
scored.sort_by_key(|(score, _)| *score);
|
||||||
ring.add_volume(url);
|
scored.into_iter().take(count).map(|(_, v)| v.clone()).collect()
|
||||||
}
|
|
||||||
ring
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_volume(&mut self, url: &str) {
|
|
||||||
for i in 0..self.virtual_nodes {
|
|
||||||
let hash = hash_key(&format!("{url}:{i}"));
|
|
||||||
self.nodes.insert(hash, url.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_volume(&mut self, url: &str) {
|
|
||||||
self.nodes.retain(|_, v| v != url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk the ring clockwise from the key's hash position and return
|
|
||||||
/// `count` distinct physical volumes.
|
|
||||||
pub fn get_volumes(&self, key: &str, count: usize) -> Vec<String> {
|
|
||||||
if self.nodes.is_empty() {
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
|
|
||||||
let hash = hash_key(key);
|
|
||||||
let mut result = Vec::with_capacity(count);
|
|
||||||
|
|
||||||
// Walk clockwise from hash position
|
|
||||||
for (_, url) in self.nodes.range(hash..).chain(self.nodes.iter()) {
|
|
||||||
if !result.contains(url) {
|
|
||||||
result.push(url.clone());
|
|
||||||
}
|
|
||||||
if result.len() == count {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_key(input: &str) -> u64 {
|
|
||||||
let hash = Sha256::digest(input.as_bytes());
|
|
||||||
u64::from_be_bytes(hash[..8].try_into().unwrap())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -63,48 +20,29 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ring_distribution() {
|
fn test_deterministic() {
|
||||||
let volumes: Vec<String> = (1..=3).map(|i| format!("http://vol{i}:300{i}")).collect();
|
let volumes: Vec<String> = (1..=3).map(|i| format!("http://vol{i}")).collect();
|
||||||
let ring = Ring::new(&volumes, 100);
|
let a = volumes_for_key("my-key", &volumes, 2);
|
||||||
|
let b = volumes_for_key("my-key", &volumes, 2);
|
||||||
let selected = ring.get_volumes("test-key", 3);
|
|
||||||
assert_eq!(selected.len(), 3);
|
|
||||||
for v in &volumes {
|
|
||||||
assert!(selected.contains(v), "missing volume {v}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ring_deterministic() {
|
|
||||||
let volumes: Vec<String> = (1..=3).map(|i| format!("http://vol{i}:300{i}")).collect();
|
|
||||||
let ring = Ring::new(&volumes, 100);
|
|
||||||
|
|
||||||
let a = ring.get_volumes("my-key", 2);
|
|
||||||
let b = ring.get_volumes("my-key", 2);
|
|
||||||
assert_eq!(a, b);
|
assert_eq!(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ring_count_capped() {
|
fn test_count_capped() {
|
||||||
let volumes: Vec<String> = (1..=2).map(|i| format!("http://vol{i}:300{i}")).collect();
|
let volumes: Vec<String> = (1..=2).map(|i| format!("http://vol{i}")).collect();
|
||||||
let ring = Ring::new(&volumes, 100);
|
let selected = volumes_for_key("key", &volumes, 5);
|
||||||
|
|
||||||
let selected = ring.get_volumes("key", 5);
|
|
||||||
assert_eq!(selected.len(), 2);
|
assert_eq!(selected.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ring_even_distribution() {
|
fn test_even_distribution() {
|
||||||
let volumes: Vec<String> = (1..=3).map(|i| format!("http://vol{i}:300{i}")).collect();
|
let volumes: Vec<String> = (1..=3).map(|i| format!("http://vol{i}")).collect();
|
||||||
let ring = Ring::new(&volumes, 100);
|
|
||||||
|
|
||||||
let mut counts = std::collections::HashMap::new();
|
let mut counts = std::collections::HashMap::new();
|
||||||
for i in 0..3000 {
|
for i in 0..3000 {
|
||||||
let key = format!("key-{i}");
|
let key = format!("key-{i}");
|
||||||
let primary = &ring.get_volumes(&key, 1)[0];
|
let primary = &volumes_for_key(&key, &volumes, 1)[0];
|
||||||
*counts.entry(primary.clone()).or_insert(0u32) += 1;
|
*counts.entry(primary.clone()).or_insert(0u32) += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (vol, count) in &counts {
|
for (vol, count) in &counts {
|
||||||
assert!(
|
assert!(
|
||||||
*count > 700 && *count < 1300,
|
*count > 700 && *count < 1300,
|
||||||
|
|
@ -114,65 +52,30 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ring_stability_on_add() {
|
fn test_stability_on_add() {
|
||||||
// Adding a 4th volume to a 3-volume ring should only move ~25% of keys
|
|
||||||
let volumes: Vec<String> = (1..=3).map(|i| format!("http://vol{i}")).collect();
|
let volumes: Vec<String> = (1..=3).map(|i| format!("http://vol{i}")).collect();
|
||||||
let ring_before = Ring::new(&volumes, 100);
|
|
||||||
|
|
||||||
let mut volumes4 = volumes.clone();
|
let mut volumes4 = volumes.clone();
|
||||||
volumes4.push("http://vol4".into());
|
volumes4.push("http://vol4".into());
|
||||||
let ring_after = Ring::new(&volumes4, 100);
|
|
||||||
|
|
||||||
let total = 10000;
|
let total = 10000;
|
||||||
let mut moved = 0;
|
let mut moved = 0;
|
||||||
for i in 0..total {
|
for i in 0..total {
|
||||||
let key = format!("key-{i}");
|
let key = format!("key-{i}");
|
||||||
let before = &ring_before.get_volumes(&key, 1)[0];
|
let before = &volumes_for_key(&key, &volumes, 1)[0];
|
||||||
let after = &ring_after.get_volumes(&key, 1)[0];
|
let after = &volumes_for_key(&key, &volumes4, 1)[0];
|
||||||
if before != after {
|
if before != after {
|
||||||
moved += 1;
|
moved += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ideal: 1/4 of keys move (25%). Allow 15%-40%.
|
|
||||||
let pct = moved as f64 / total as f64 * 100.0;
|
let pct = moved as f64 / total as f64 * 100.0;
|
||||||
assert!(
|
assert!(
|
||||||
pct > 15.0 && pct < 40.0,
|
pct > 15.0 && pct < 40.0,
|
||||||
"expected ~25% of keys to move, got {pct:.1}% ({moved}/{total})"
|
"expected ~25% of keys to move, got {pct:.1}%"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ring_stability_on_remove() {
|
fn test_empty() {
|
||||||
// Removing 1 volume from a 4-volume ring should only move ~25% of keys
|
assert_eq!(volumes_for_key("key", &[], 1), Vec::<String>::new());
|
||||||
let volumes: Vec<String> = (1..=4).map(|i| format!("http://vol{i}")).collect();
|
|
||||||
let ring_before = Ring::new(&volumes, 100);
|
|
||||||
|
|
||||||
let volumes3: Vec<String> = (1..=3).map(|i| format!("http://vol{i}")).collect();
|
|
||||||
let ring_after = Ring::new(&volumes3, 100);
|
|
||||||
|
|
||||||
let total = 10000;
|
|
||||||
let mut moved = 0;
|
|
||||||
for i in 0..total {
|
|
||||||
let key = format!("key-{i}");
|
|
||||||
let before = &ring_before.get_volumes(&key, 1)[0];
|
|
||||||
let after = &ring_after.get_volumes(&key, 1)[0];
|
|
||||||
if before != after {
|
|
||||||
moved += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ideal: 1/4 of keys move (25%). Allow 15%-40%.
|
|
||||||
let pct = moved as f64 / total as f64 * 100.0;
|
|
||||||
assert!(
|
|
||||||
pct > 15.0 && pct < 40.0,
|
|
||||||
"expected ~25% of keys to move, got {pct:.1}% ({moved}/{total})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ring_empty() {
|
|
||||||
let ring = Ring::new(&[], 100);
|
|
||||||
assert_eq!(ring.get_volumes("key", 1), Vec::<String>::new());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
use crate::volume::VolumeClient;
|
|
||||||
|
|
||||||
pub type HealthyVolumes = Arc<RwLock<HashSet<String>>>;
|
|
||||||
|
|
||||||
pub fn spawn_health_checker(
|
|
||||||
volume_client: VolumeClient,
|
|
||||||
all_volumes: Vec<String>,
|
|
||||||
healthy: HealthyVolumes,
|
|
||||||
) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
for url in &all_volumes {
|
|
||||||
let ok = volume_client.check(url).await;
|
|
||||||
let mut set = healthy.write().await;
|
|
||||||
if ok {
|
|
||||||
set.insert(url.clone());
|
|
||||||
} else {
|
|
||||||
if set.remove(url) {
|
|
||||||
tracing::warn!("Volume {url} is unhealthy");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
51
src/lib.rs
51
src/lib.rs
|
|
@ -1,60 +1,31 @@
|
||||||
pub mod config;
|
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod hasher;
|
pub mod hasher;
|
||||||
pub mod health;
|
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod rebalance;
|
pub mod rebalance;
|
||||||
pub mod rebuild;
|
pub mod rebuild;
|
||||||
pub mod volume;
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
/// Build the axum Router with all state wired up. Returns the router and
|
pub struct Args {
|
||||||
/// a handle to the writer (caller must keep it alive).
|
pub db_path: String,
|
||||||
pub async fn build_app(config: config::Config) -> axum::Router {
|
pub volumes: Vec<String>,
|
||||||
let db_path = &config.database.path;
|
pub replicas: usize,
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(parent) = std::path::Path::new(db_path).parent() {
|
pub fn build_app(args: &Args) -> axum::Router {
|
||||||
|
if let Some(parent) = std::path::Path::new(&args.db_path).parent() {
|
||||||
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
|
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
|
||||||
eprintln!("Failed to create database directory: {e}");
|
eprintln!("Failed to create database directory: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let writer = db::WriterHandle::new(db_path);
|
|
||||||
|
|
||||||
let num_readers = std::thread::available_parallelism()
|
|
||||||
.map(|n| n.get())
|
|
||||||
.unwrap_or(4);
|
|
||||||
let reads = db::ReadPool::new(db_path, num_readers);
|
|
||||||
|
|
||||||
let volume_urls = config.volume_urls();
|
|
||||||
let ring = Arc::new(RwLock::new(hasher::Ring::new(
|
|
||||||
&volume_urls,
|
|
||||||
config.server.virtual_nodes,
|
|
||||||
)));
|
|
||||||
|
|
||||||
let volume_client = volume::VolumeClient::new();
|
|
||||||
|
|
||||||
let healthy_volumes: health::HealthyVolumes =
|
|
||||||
Arc::new(RwLock::new(HashSet::from_iter(volume_urls.clone())));
|
|
||||||
|
|
||||||
health::spawn_health_checker(
|
|
||||||
volume_client.clone(),
|
|
||||||
volume_urls,
|
|
||||||
healthy_volumes.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let state = server::AppState {
|
let state = server::AppState {
|
||||||
writer,
|
db: db::Db::new(&args.db_path),
|
||||||
reads,
|
volumes: Arc::new(args.volumes.clone()),
|
||||||
ring,
|
replicas: args.replicas,
|
||||||
volume_client,
|
http: reqwest::Client::new(),
|
||||||
healthy_volumes,
|
|
||||||
config: Arc::new(config),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
axum::Router::new()
|
axum::Router::new()
|
||||||
|
|
|
||||||
37
src/main.rs
37
src/main.rs
|
|
@ -1,11 +1,16 @@
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "mkv", about = "Distributed key-value store")]
|
#[command(name = "mkv", about = "Distributed key-value store")]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[arg(short, long, default_value = "config.toml")]
|
#[arg(short, long, default_value = "/tmp/mkv/index.db")]
|
||||||
config: PathBuf,
|
db: String,
|
||||||
|
|
||||||
|
#[arg(short, long, required = true, value_delimiter = ',')]
|
||||||
|
volumes: Vec<String>,
|
||||||
|
|
||||||
|
#[arg(short, long, default_value_t = 2)]
|
||||||
|
replicas: usize,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Commands,
|
command: Commands,
|
||||||
|
|
@ -14,7 +19,10 @@ struct Cli {
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Start the index server
|
/// Start the index server
|
||||||
Serve,
|
Serve {
|
||||||
|
#[arg(short, long, default_value_t = 3000)]
|
||||||
|
port: u16,
|
||||||
|
},
|
||||||
/// Rebuild SQLite index from volume servers
|
/// Rebuild SQLite index from volume servers
|
||||||
Rebuild,
|
Rebuild,
|
||||||
/// Rebalance data after adding/removing volumes
|
/// Rebalance data after adding/removing volumes
|
||||||
|
|
@ -27,28 +35,27 @@ enum Commands {
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let config = mkv::config::Config::load(&cli.config).unwrap_or_else(|e| {
|
|
||||||
eprintln!("Failed to load config: {e}");
|
let args = mkv::Args {
|
||||||
std::process::exit(1);
|
db_path: cli.db,
|
||||||
});
|
volumes: cli.volumes,
|
||||||
|
replicas: cli.replicas,
|
||||||
|
};
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Serve => {
|
Commands::Serve { port } => {
|
||||||
let port = config.server.port;
|
let app = mkv::build_app(&args);
|
||||||
let app = mkv::build_app(config).await;
|
|
||||||
|
|
||||||
let addr = format!("0.0.0.0:{port}");
|
let addr = format!("0.0.0.0:{port}");
|
||||||
tracing::info!("Listening on {addr}");
|
tracing::info!("Listening on {addr}");
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
}
|
}
|
||||||
Commands::Rebuild => {
|
Commands::Rebuild => {
|
||||||
mkv::rebuild::run(&config).await;
|
mkv::rebuild::run(&args).await;
|
||||||
}
|
}
|
||||||
Commands::Rebalance { dry_run } => {
|
Commands::Rebalance { dry_run } => {
|
||||||
mkv::rebalance::run(&config, dry_run).await;
|
mkv::rebalance::run(&args, dry_run).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
133
src/rebalance.rs
133
src/rebalance.rs
|
|
@ -1,9 +1,6 @@
|
||||||
use crate::config::Config;
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::hasher::Ring;
|
use crate::Args;
|
||||||
use crate::volume::VolumeClient;
|
|
||||||
|
|
||||||
/// What needs to happen to a single key during rebalance.
|
|
||||||
pub struct KeyMove {
|
pub struct KeyMove {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub size: Option<i64>,
|
pub size: Option<i64>,
|
||||||
|
|
@ -13,26 +10,12 @@ pub struct KeyMove {
|
||||||
pub to_remove: Vec<String>,
|
pub to_remove: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure: compute the diff between current and desired placement for all keys.
|
pub fn plan_rebalance(records: &[db::Record], volumes: &[String], replication: usize) -> Vec<KeyMove> {
|
||||||
pub fn plan_rebalance(
|
|
||||||
records: &[db::Record],
|
|
||||||
ring: &Ring,
|
|
||||||
replication: usize,
|
|
||||||
) -> Vec<KeyMove> {
|
|
||||||
let mut moves = Vec::new();
|
let mut moves = Vec::new();
|
||||||
for record in records {
|
for record in records {
|
||||||
let desired = ring.get_volumes(&record.key, replication);
|
let desired = crate::hasher::volumes_for_key(&record.key, volumes, replication);
|
||||||
let to_add: Vec<String> = desired
|
let to_add: Vec<String> = desired.iter().filter(|v| !record.volumes.contains(v)).cloned().collect();
|
||||||
.iter()
|
let to_remove: Vec<String> = record.volumes.iter().filter(|v| !desired.contains(v)).cloned().collect();
|
||||||
.filter(|v| !record.volumes.contains(v))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
let to_remove: Vec<String> = record
|
|
||||||
.volumes
|
|
||||||
.iter()
|
|
||||||
.filter(|v| !desired.contains(v))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !to_add.is_empty() || !to_remove.is_empty() {
|
if !to_add.is_empty() || !to_remove.is_empty() {
|
||||||
moves.push(KeyMove {
|
moves.push(KeyMove {
|
||||||
|
|
@ -48,22 +31,10 @@ pub fn plan_rebalance(
|
||||||
moves
|
moves
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(config: &Config, dry_run: bool) {
|
pub async fn run(args: &Args, dry_run: bool) {
|
||||||
let db_path = &config.database.path;
|
let db = db::Db::new(&args.db_path);
|
||||||
let replication = config.server.replication_factor;
|
let records = db.all_records_sync().expect("failed to read records");
|
||||||
|
let moves = plan_rebalance(&records, &args.volumes, args.replicas);
|
||||||
// Open DB read-only to plan
|
|
||||||
let conn = rusqlite::Connection::open_with_flags(
|
|
||||||
db_path,
|
|
||||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
|
||||||
)
|
|
||||||
.expect("failed to open database");
|
|
||||||
|
|
||||||
let records = db::all_records(&conn).expect("failed to read records");
|
|
||||||
drop(conn);
|
|
||||||
|
|
||||||
let ring = Ring::new(&config.volume_urls(), config.server.virtual_nodes);
|
|
||||||
let moves = plan_rebalance(&records, &ring, replication);
|
|
||||||
|
|
||||||
if moves.is_empty() {
|
if moves.is_empty() {
|
||||||
eprintln!("Nothing to rebalance — all keys are already correctly placed.");
|
eprintln!("Nothing to rebalance — all keys are already correctly placed.");
|
||||||
|
|
@ -71,44 +42,40 @@ pub async fn run(config: &Config, dry_run: bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_bytes: i64 = moves.iter().filter_map(|m| m.size).sum();
|
let total_bytes: i64 = moves.iter().filter_map(|m| m.size).sum();
|
||||||
eprintln!(
|
eprintln!("{} keys to move ({} bytes)", moves.len(), total_bytes);
|
||||||
"{} keys to move ({} bytes)",
|
|
||||||
moves.len(),
|
|
||||||
total_bytes
|
|
||||||
);
|
|
||||||
|
|
||||||
if dry_run {
|
if dry_run {
|
||||||
for m in &moves {
|
for m in &moves {
|
||||||
eprintln!(
|
eprintln!(" {} : add {:?}, remove {:?}", m.key, m.to_add, m.to_remove);
|
||||||
" {} : add {:?}, remove {:?}",
|
|
||||||
m.key, m.to_add, m.to_remove
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open writer for updates
|
let client = reqwest::Client::new();
|
||||||
let writer = db::WriterHandle::new(db_path);
|
|
||||||
|
|
||||||
let client = VolumeClient::new();
|
|
||||||
let mut moved = 0;
|
let mut moved = 0;
|
||||||
let mut errors = 0;
|
let mut errors = 0;
|
||||||
|
|
||||||
for m in &moves {
|
for m in &moves {
|
||||||
// Pick a source volume to copy from (any current volume)
|
|
||||||
let src = &m.current_volumes[0];
|
let src = &m.current_volumes[0];
|
||||||
|
|
||||||
// Copy to new volumes
|
|
||||||
let mut copy_ok = true;
|
let mut copy_ok = true;
|
||||||
|
|
||||||
for dst in &m.to_add {
|
for dst in &m.to_add {
|
||||||
match client.get(src, &m.key).await {
|
let src_url = format!("{src}/{}", m.key);
|
||||||
Ok(data) => {
|
match client.get(&src_url).send().await {
|
||||||
if let Err(e) = client.put(dst, &m.key, data).await {
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
let data = resp.bytes().await.unwrap();
|
||||||
|
let dst_url = format!("{dst}/{}", m.key);
|
||||||
|
if let Err(e) = client.put(&dst_url).body(data).send().await {
|
||||||
eprintln!(" ERROR copy {} to {}: {}", m.key, dst, e);
|
eprintln!(" ERROR copy {} to {}: {}", m.key, dst, e);
|
||||||
copy_ok = false;
|
copy_ok = false;
|
||||||
errors += 1;
|
errors += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
eprintln!(" ERROR read {} from {}: status {}", m.key, src, resp.status());
|
||||||
|
copy_ok = false;
|
||||||
|
errors += 1;
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(" ERROR read {} from {}: {}", m.key, src, e);
|
eprintln!(" ERROR read {} from {}: {}", m.key, src, e);
|
||||||
copy_ok = false;
|
copy_ok = false;
|
||||||
|
|
@ -117,23 +84,16 @@ pub async fn run(config: &Config, dry_run: bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !copy_ok {
|
if !copy_ok { continue; }
|
||||||
continue; // don't update index or delete if copy failed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update index with new volume list
|
db.put(m.key.clone(), m.desired_volumes.clone(), m.size).await.expect("failed to update index");
|
||||||
writer
|
|
||||||
.put(m.key.clone(), m.desired_volumes.clone(), m.size)
|
|
||||||
.await
|
|
||||||
.expect("failed to update index");
|
|
||||||
|
|
||||||
// Delete from old volumes
|
|
||||||
for old in &m.to_remove {
|
for old in &m.to_remove {
|
||||||
if let Err(e) = client.delete(old, &m.key).await {
|
let url = format!("{old}/{}", m.key);
|
||||||
|
if let Err(e) = client.delete(&url).send().await {
|
||||||
eprintln!(" WARN delete {} from {}: {}", m.key, old, e);
|
eprintln!(" WARN delete {} from {}: {}", m.key, old, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
moved += 1;
|
moved += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,58 +107,33 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_plan_rebalance_no_change() {
|
fn test_plan_rebalance_no_change() {
|
||||||
let volumes: Vec<String> = (1..=3).map(|i| format!("http://vol{i}")).collect();
|
let volumes: Vec<String> = (1..=3).map(|i| format!("http://vol{i}")).collect();
|
||||||
let ring = Ring::new(&volumes, 100);
|
|
||||||
|
|
||||||
// Create records that are already correctly placed
|
|
||||||
let records: Vec<db::Record> = (0..100)
|
let records: Vec<db::Record> = (0..100)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let key = format!("key-{i}");
|
let key = format!("key-{i}");
|
||||||
let vols = ring.get_volumes(&key, 2);
|
let vols = crate::hasher::volumes_for_key(&key, &volumes, 2);
|
||||||
db::Record {
|
db::Record { key, volumes: vols, size: Some(100) }
|
||||||
key,
|
|
||||||
volumes: vols,
|
|
||||||
size: Some(100),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let moves = plan_rebalance(&records, &ring, 2);
|
let moves = plan_rebalance(&records, &volumes, 2);
|
||||||
assert!(moves.is_empty());
|
assert!(moves.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_plan_rebalance_new_volume() {
|
fn test_plan_rebalance_new_volume() {
|
||||||
let volumes3: Vec<String> = (1..=3).map(|i| format!("http://vol{i}")).collect();
|
let volumes3: Vec<String> = (1..=3).map(|i| format!("http://vol{i}")).collect();
|
||||||
let ring3 = Ring::new(&volumes3, 100);
|
|
||||||
|
|
||||||
// Place keys on 3-volume ring
|
|
||||||
let records: Vec<db::Record> = (0..1000)
|
let records: Vec<db::Record> = (0..1000)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let key = format!("key-{i}");
|
let key = format!("key-{i}");
|
||||||
let vols = ring3.get_volumes(&key, 2);
|
let vols = crate::hasher::volumes_for_key(&key, &volumes3, 2);
|
||||||
db::Record {
|
db::Record { key, volumes: vols, size: Some(100) }
|
||||||
key,
|
|
||||||
volumes: vols,
|
|
||||||
size: Some(100),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Build new ring with 4 volumes
|
|
||||||
let volumes4: Vec<String> = (1..=4).map(|i| format!("http://vol{i}")).collect();
|
let volumes4: Vec<String> = (1..=4).map(|i| format!("http://vol{i}")).collect();
|
||||||
let ring4 = Ring::new(&volumes4, 100);
|
let moves = plan_rebalance(&records, &volumes4, 2);
|
||||||
|
|
||||||
let moves = plan_rebalance(&records, &ring4, 2);
|
|
||||||
|
|
||||||
// Some keys should need to move, but not all
|
|
||||||
assert!(!moves.is_empty());
|
assert!(!moves.is_empty());
|
||||||
assert!(moves.len() < 800, "too many moves: {}", moves.len());
|
assert!(moves.len() < 800, "too many moves: {}", moves.len());
|
||||||
|
|
||||||
// Every move should involve vol4 (the new volume)
|
|
||||||
for m in &moves {
|
|
||||||
let involves_vol4 = m.to_add.iter().any(|v| v == "http://vol4")
|
|
||||||
|| m.to_remove.iter().any(|v| v == "http://vol4");
|
|
||||||
assert!(involves_vol4, "move for {} doesn't involve vol4", m.key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::config::Config;
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
use crate::Args;
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct NginxEntry {
|
struct NginxEntry {
|
||||||
|
|
@ -12,36 +12,20 @@ struct NginxEntry {
|
||||||
size: Option<i64>,
|
size: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all keys on a volume by recursively walking nginx autoindex.
|
|
||||||
async fn list_volume_keys(volume_url: &str) -> Result<Vec<(String, i64)>, String> {
|
async fn list_volume_keys(volume_url: &str) -> Result<Vec<(String, i64)>, String> {
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
let mut keys = Vec::new();
|
let mut keys = Vec::new();
|
||||||
let mut dirs = vec![String::new()]; // start at root
|
let mut dirs = vec![String::new()];
|
||||||
|
|
||||||
while let Some(prefix) = dirs.pop() {
|
while let Some(prefix) = dirs.pop() {
|
||||||
let url = format!("{volume_url}/{prefix}");
|
let url = format!("{volume_url}/{prefix}");
|
||||||
let resp = http
|
let resp = http.get(&url).send().await.map_err(|e| format!("GET {url}: {e}"))?;
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("GET {url}: {e}"))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("GET {url}: status {}", resp.status()));
|
return Err(format!("GET {url}: status {}", resp.status()));
|
||||||
}
|
}
|
||||||
|
let entries: Vec<NginxEntry> = resp.json().await.map_err(|e| format!("parse {url}: {e}"))?;
|
||||||
let entries: Vec<NginxEntry> = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("parse {url}: {e}"))?;
|
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let full_path = if prefix.is_empty() {
|
let full_path = if prefix.is_empty() { entry.name.clone() } else { format!("{prefix}{}", entry.name) };
|
||||||
entry.name.clone()
|
|
||||||
} else {
|
|
||||||
format!("{prefix}{}", entry.name)
|
|
||||||
};
|
|
||||||
|
|
||||||
match entry.entry_type.as_str() {
|
match entry.entry_type.as_str() {
|
||||||
"directory" => dirs.push(format!("{full_path}/")),
|
"directory" => dirs.push(format!("{full_path}/")),
|
||||||
"file" => keys.push((full_path, entry.size.unwrap_or(0))),
|
"file" => keys.push((full_path, entry.size.unwrap_or(0))),
|
||||||
|
|
@ -49,31 +33,24 @@ async fn list_volume_keys(volume_url: &str) -> Result<Vec<(String, i64)>, String
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(keys)
|
Ok(keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(config: &Config) {
|
pub async fn run(args: &Args) {
|
||||||
let db_path = &config.database.path;
|
let db_path = &args.db_path;
|
||||||
|
|
||||||
// Ensure parent directory exists
|
|
||||||
if let Some(parent) = std::path::Path::new(db_path).parent() {
|
if let Some(parent) = std::path::Path::new(db_path).parent() {
|
||||||
let _ = std::fs::create_dir_all(parent);
|
let _ = std::fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete old database
|
|
||||||
let _ = std::fs::remove_file(db_path);
|
let _ = std::fs::remove_file(db_path);
|
||||||
let _ = std::fs::remove_file(format!("{db_path}-wal"));
|
let _ = std::fs::remove_file(format!("{db_path}-wal"));
|
||||||
let _ = std::fs::remove_file(format!("{db_path}-shm"));
|
let _ = std::fs::remove_file(format!("{db_path}-shm"));
|
||||||
|
|
||||||
let writer = db::WriterHandle::new(db_path);
|
let db = db::Db::new(db_path);
|
||||||
|
|
||||||
let volume_urls = config.volume_urls();
|
|
||||||
|
|
||||||
// key -> (volumes, size)
|
|
||||||
let mut index: HashMap<String, (Vec<String>, i64)> = HashMap::new();
|
let mut index: HashMap<String, (Vec<String>, i64)> = HashMap::new();
|
||||||
|
|
||||||
for vol_url in &volume_urls {
|
for vol_url in &args.volumes {
|
||||||
eprintln!("Scanning {vol_url}...");
|
eprintln!("Scanning {vol_url}...");
|
||||||
match list_volume_keys(vol_url).await {
|
match list_volume_keys(vol_url).await {
|
||||||
Ok(keys) => {
|
Ok(keys) => {
|
||||||
|
|
@ -81,26 +58,15 @@ pub async fn run(config: &Config) {
|
||||||
for (key, size) in keys {
|
for (key, size) in keys {
|
||||||
let entry = index.entry(key).or_insert_with(|| (Vec::new(), size));
|
let entry = index.entry(key).or_insert_with(|| (Vec::new(), size));
|
||||||
entry.0.push(vol_url.clone());
|
entry.0.push(vol_url.clone());
|
||||||
// Use the largest size seen (they should all match)
|
if size > entry.1 { entry.1 = size; }
|
||||||
if size > entry.1 {
|
|
||||||
entry.1 = size;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Err(e) => eprintln!(" Error scanning {vol_url}: {e}"),
|
||||||
Err(e) => {
|
|
||||||
eprintln!(" Error scanning {vol_url}: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch insert into SQLite
|
let records: Vec<_> = index.into_iter().map(|(k, (v, s))| (k, v, Some(s))).collect();
|
||||||
let records: Vec<(String, Vec<String>, Option<i64>)> = index
|
|
||||||
.into_iter()
|
|
||||||
.map(|(key, (volumes, size))| (key, volumes, Some(size)))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let count = records.len();
|
let count = records.len();
|
||||||
writer.bulk_put(records).await.expect("bulk_put failed");
|
db.bulk_put(records).await.expect("bulk_put failed");
|
||||||
|
|
||||||
eprintln!("Rebuilt index with {count} keys");
|
eprintln!("Rebuilt index with {count} keys");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
260
src/server.rs
260
src/server.rs
|
|
@ -2,201 +2,113 @@ use axum::body::Bytes;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
use crate::config::Config;
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::hasher::Ring;
|
|
||||||
use crate::volume::VolumeClient;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub writer: db::WriterHandle,
|
pub db: db::Db,
|
||||||
pub reads: db::ReadPool,
|
pub volumes: Arc<Vec<String>>,
|
||||||
pub ring: Arc<RwLock<Ring>>,
|
pub replicas: usize,
|
||||||
pub volume_client: VolumeClient,
|
pub http: reqwest::Client,
|
||||||
pub healthy_volumes: Arc<RwLock<HashSet<String>>>,
|
|
||||||
pub config: Arc<Config>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pure decision functions ---
|
|
||||||
|
|
||||||
/// Pick the first volume from the record that appears in the healthy set.
|
|
||||||
fn pick_healthy_volume<'a>(
|
|
||||||
record_volumes: &'a [String],
|
|
||||||
healthy: &HashSet<String>,
|
|
||||||
) -> Option<&'a str> {
|
|
||||||
record_volumes
|
|
||||||
.iter()
|
|
||||||
.find(|v| healthy.contains(v.as_str()))
|
|
||||||
.map(|v| v.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Select target volumes for a key, ensuring we have enough for replication.
|
|
||||||
fn select_volumes(ring: &Ring, key: &str, replication: usize) -> Result<Vec<String>, AppError> {
|
|
||||||
let target_volumes = ring.get_volumes(key, replication);
|
|
||||||
if target_volumes.len() < replication {
|
|
||||||
return Err(AppError::VolumeError(format!(
|
|
||||||
"need {replication} volumes but only {} available",
|
|
||||||
target_volumes.len()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(target_volumes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evaluate fan-out results. Returns the list of volumes that succeeded,
|
|
||||||
/// or the error messages if any failed.
|
|
||||||
fn evaluate_fanout(
|
|
||||||
results: Vec<Result<(), String>>,
|
|
||||||
volumes: &[String],
|
|
||||||
) -> Result<Vec<String>, Vec<String>> {
|
|
||||||
let mut succeeded = Vec::new();
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
for (i, result) in results.into_iter().enumerate() {
|
|
||||||
match result {
|
|
||||||
Ok(()) => succeeded.push(volumes[i].clone()),
|
|
||||||
Err(e) => errors.push(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if errors.is_empty() {
|
|
||||||
Ok(succeeded)
|
|
||||||
} else {
|
|
||||||
Err(errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Handlers ---
|
|
||||||
|
|
||||||
/// GET /:key — look up key, redirect to a healthy volume
|
|
||||||
pub async fn get_key(
|
pub async fn get_key(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(key): Path<String>,
|
Path(key): Path<String>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let record = state
|
let record = state.db.get(&key).await?;
|
||||||
.reads
|
let vol = &record.volumes[0];
|
||||||
.query({
|
|
||||||
let key = key.clone();
|
|
||||||
move |conn| db::get(conn, &key)
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let healthy = state.healthy_volumes.read().await;
|
|
||||||
let vol = pick_healthy_volume(&record.volumes, &healthy).ok_or(AppError::NoHealthyVolume)?;
|
|
||||||
let location = format!("{vol}/{key}");
|
let location = format!("{vol}/{key}");
|
||||||
|
Ok((StatusCode::FOUND, [(axum::http::header::LOCATION, location)]).into_response())
|
||||||
Ok((
|
|
||||||
StatusCode::FOUND,
|
|
||||||
[(axum::http::header::LOCATION, location)],
|
|
||||||
)
|
|
||||||
.into_response())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /:key — store blob on volumes, record in index
|
|
||||||
pub async fn put_key(
|
pub async fn put_key(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(key): Path<String>,
|
Path(key): Path<String>,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let target_volumes = {
|
let target_volumes = crate::hasher::volumes_for_key(&key, &state.volumes, state.replicas);
|
||||||
let ring = state.ring.read().await;
|
if target_volumes.len() < state.replicas {
|
||||||
select_volumes(&ring, &key, state.config.server.replication_factor)?
|
return Err(AppError::VolumeError(format!(
|
||||||
};
|
"need {} volumes but only {} available",
|
||||||
|
state.replicas,
|
||||||
|
target_volumes.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
// Fan out PUTs to all target volumes concurrently
|
// Fan out PUTs to all target volumes concurrently
|
||||||
let mut handles = Vec::with_capacity(target_volumes.len());
|
let mut handles = Vec::with_capacity(target_volumes.len());
|
||||||
for vol in &target_volumes {
|
for vol in &target_volumes {
|
||||||
let client = state.volume_client.clone();
|
let url = format!("{vol}/{key}");
|
||||||
let vol = vol.clone();
|
let handle = tokio::spawn({
|
||||||
let key = key.clone();
|
let client = state.http.clone();
|
||||||
let data = body.clone();
|
let data = body.clone();
|
||||||
handles.push(tokio::spawn(async move {
|
async move {
|
||||||
client.put(&vol, &key, data).await
|
let resp = client.put(&url).body(data).send().await.map_err(|e| format!("PUT {url}: {e}"))?;
|
||||||
}));
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("PUT {url}: status {}", resp.status()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
handles.push(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut results = Vec::with_capacity(handles.len());
|
let mut all_ok = true;
|
||||||
for handle in handles {
|
for handle in handles {
|
||||||
results.push(handle.await.unwrap());
|
if let Err(e) = handle.await.unwrap() {
|
||||||
|
tracing::error!("PUT to volume failed: {e}");
|
||||||
|
all_ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !all_ok {
|
||||||
|
// Rollback: best-effort delete from volumes
|
||||||
|
for vol in &target_volumes {
|
||||||
|
let _ = state.http.delete(format!("{vol}/{key}")).send().await;
|
||||||
|
}
|
||||||
|
return Err(AppError::VolumeError("not all volume writes succeeded".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
match evaluate_fanout(results, &target_volumes) {
|
|
||||||
Ok(succeeded_volumes) => {
|
|
||||||
let size = Some(body.len() as i64);
|
let size = Some(body.len() as i64);
|
||||||
state.writer.put(key, succeeded_volumes, size).await?;
|
state.db.put(key, target_volumes, size).await?;
|
||||||
Ok(StatusCode::CREATED.into_response())
|
Ok(StatusCode::CREATED.into_response())
|
||||||
}
|
}
|
||||||
Err(errors) => {
|
|
||||||
for e in &errors {
|
|
||||||
tracing::error!("PUT to volume failed: {e}");
|
|
||||||
}
|
|
||||||
// Rollback: best-effort delete from any volumes that may have succeeded
|
|
||||||
for vol in &target_volumes {
|
|
||||||
let _ = state.volume_client.delete(vol, &key).await;
|
|
||||||
}
|
|
||||||
Err(AppError::VolumeError(
|
|
||||||
"not all volume writes succeeded".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DELETE /:key — remove from volumes and index
|
|
||||||
pub async fn delete_key(
|
pub async fn delete_key(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(key): Path<String>,
|
Path(key): Path<String>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let record = state
|
let record = state.db.get(&key).await?;
|
||||||
.reads
|
|
||||||
.query({
|
|
||||||
let key = key.clone();
|
|
||||||
move |conn| db::get(conn, &key)
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Fan out DELETEs concurrently
|
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
for vol in &record.volumes {
|
for vol in &record.volumes {
|
||||||
let client = state.volume_client.clone();
|
let url = format!("{vol}/{key}");
|
||||||
let vol = vol.clone();
|
let client = state.http.clone();
|
||||||
let key = key.clone();
|
handles.push(tokio::spawn(async move { client.delete(&url).send().await }));
|
||||||
handles.push(tokio::spawn(
|
|
||||||
async move { client.delete(&vol, &key).await },
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for handle in handles {
|
for handle in handles {
|
||||||
if let Err(e) = handle.await.unwrap() {
|
if let Err(e) = handle.await.unwrap() {
|
||||||
tracing::error!("DELETE from volume failed: {e}");
|
tracing::error!("DELETE from volume failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from index regardless of volume DELETE results
|
state.db.delete(key).await?;
|
||||||
state.writer.delete(key).await?;
|
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT.into_response())
|
Ok(StatusCode::NO_CONTENT.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HEAD /:key — check if key exists, return size
|
|
||||||
pub async fn head_key(
|
pub async fn head_key(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(key): Path<String>,
|
Path(key): Path<String>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let record = state
|
let record = state.db.get(&key).await?;
|
||||||
.reads
|
|
||||||
.query(move |conn| db::get(conn, &key))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
if let Some(size) = record.size {
|
if let Some(size) = record.size {
|
||||||
headers.insert(
|
headers.insert(axum::http::header::CONTENT_LENGTH, size.to_string().parse().unwrap());
|
||||||
axum::http::header::CONTENT_LENGTH,
|
|
||||||
size.to_string().parse().unwrap(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((StatusCode::OK, headers).into_response())
|
Ok((StatusCode::OK, headers).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,89 +118,27 @@ pub struct ListQuery {
|
||||||
pub prefix: String,
|
pub prefix: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET / — list keys with optional prefix filter
|
|
||||||
pub async fn list_keys(
|
pub async fn list_keys(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<ListQuery>,
|
Query(query): Query<ListQuery>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let keys = state
|
let keys = state.db.list_keys(&query.prefix).await?;
|
||||||
.reads
|
Ok((StatusCode::OK, keys.join("\n")).into_response())
|
||||||
.query(move |conn| db::list_keys(conn, &query.prefix))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let body = keys.join("\n");
|
|
||||||
Ok((StatusCode::OK, body).into_response())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tests for pure functions ---
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pick_healthy_volume_first_match() {
|
fn test_volumes_for_key_sufficient() {
|
||||||
let volumes = vec!["http://vol1".into(), "http://vol2".into(), "http://vol3".into()];
|
|
||||||
let healthy: HashSet<String> = ["http://vol2".into(), "http://vol3".into()].into();
|
|
||||||
|
|
||||||
assert_eq!(pick_healthy_volume(&volumes, &healthy), Some("http://vol2"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_pick_healthy_volume_none_healthy() {
|
|
||||||
let volumes = vec!["http://vol1".into(), "http://vol2".into()];
|
|
||||||
let healthy: HashSet<String> = HashSet::new();
|
|
||||||
|
|
||||||
assert_eq!(pick_healthy_volume(&volumes, &healthy), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_pick_healthy_volume_all_healthy() {
|
|
||||||
let volumes = vec!["http://vol1".into(), "http://vol2".into()];
|
|
||||||
let healthy: HashSet<String> = ["http://vol1".into(), "http://vol2".into()].into();
|
|
||||||
|
|
||||||
assert_eq!(pick_healthy_volume(&volumes, &healthy), Some("http://vol1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_select_volumes_sufficient() {
|
|
||||||
let volumes: Vec<String> = (1..=3).map(|i| format!("http://vol{i}")).collect();
|
let volumes: Vec<String> = (1..=3).map(|i| format!("http://vol{i}")).collect();
|
||||||
let ring = Ring::new(&volumes, 50);
|
let selected = crate::hasher::volumes_for_key("test-key", &volumes, 2);
|
||||||
|
|
||||||
let selected = select_volumes(&ring, "test-key", 2).unwrap();
|
|
||||||
assert_eq!(selected.len(), 2);
|
assert_eq!(selected.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_select_volumes_insufficient() {
|
fn test_volumes_for_key_insufficient() {
|
||||||
let volumes: Vec<String> = vec!["http://vol1".into()];
|
let volumes: Vec<String> = vec!["http://vol1".into()];
|
||||||
let ring = Ring::new(&volumes, 50);
|
let selected = crate::hasher::volumes_for_key("test-key", &volumes, 2);
|
||||||
|
assert_eq!(selected.len(), 1);
|
||||||
assert!(select_volumes(&ring, "test-key", 2).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_evaluate_fanout_all_ok() {
|
|
||||||
let volumes = vec!["http://vol1".into(), "http://vol2".into()];
|
|
||||||
let results = vec![Ok(()), Ok(())];
|
|
||||||
|
|
||||||
assert_eq!(evaluate_fanout(results, &volumes).unwrap(), volumes);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_evaluate_fanout_partial_failure() {
|
|
||||||
let volumes = vec!["http://vol1".into(), "http://vol2".into()];
|
|
||||||
let results = vec![Ok(()), Err("connection refused".into())];
|
|
||||||
|
|
||||||
let errors = evaluate_fanout(results, &volumes).unwrap_err();
|
|
||||||
assert_eq!(errors, vec!["connection refused"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_evaluate_fanout_all_fail() {
|
|
||||||
let volumes = vec!["http://vol1".into(), "http://vol2".into()];
|
|
||||||
let results = vec![Err("err1".into()), Err("err2".into())];
|
|
||||||
|
|
||||||
assert_eq!(evaluate_fanout(results, &volumes).unwrap_err().len(), 2);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
use bytes::Bytes;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct VolumeClient {
|
|
||||||
client: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VolumeClient {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.connect_timeout(Duration::from_secs(2))
|
|
||||||
.timeout(Duration::from_secs(30))
|
|
||||||
.pool_max_idle_per_host(10)
|
|
||||||
.build()
|
|
||||||
.expect("failed to build HTTP client");
|
|
||||||
Self { client }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT a blob to a volume server at /{key}.
|
|
||||||
pub async fn put(&self, volume_url: &str, key: &str, data: Bytes) -> Result<(), String> {
|
|
||||||
let url = format!("{volume_url}/{key}");
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.put(&url)
|
|
||||||
.body(data)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("PUT {url}: {e}"))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("PUT {url}: status {}", resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET a blob from a volume server.
|
|
||||||
pub async fn get(&self, volume_url: &str, key: &str) -> Result<Bytes, String> {
|
|
||||||
let url = format!("{volume_url}/{key}");
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("GET {url}: {e}"))?;
|
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
|
||||||
return Err(format!("GET {url}: not found"));
|
|
||||||
}
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("GET {url}: status {}", resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
resp.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("GET {url} body: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DELETE a blob from a volume server.
|
|
||||||
pub async fn delete(&self, volume_url: &str, key: &str) -> Result<(), String> {
|
|
||||||
let url = format!("{volume_url}/{key}");
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.delete(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("DELETE {url}: {e}"))?;
|
|
||||||
|
|
||||||
// 404 is fine — already gone
|
|
||||||
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND {
|
|
||||||
return Err(format!("DELETE {url}: status {}", resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Health check a volume server.
|
|
||||||
pub async fn check(&self, volume_url: &str) -> bool {
|
|
||||||
let url = format!("{volume_url}/");
|
|
||||||
self.client
|
|
||||||
.head(&url)
|
|
||||||
.timeout(Duration::from_secs(2))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.is_ok_and(|r| r.status().is_success())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +1,35 @@
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
|
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
/// Start the mkv server in-process on a random port with its own DB.
|
|
||||||
/// Returns the base URL (e.g. "http://localhost:12345").
|
|
||||||
async fn start_server() -> String {
|
async fn start_server() -> String {
|
||||||
let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
|
let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
let db_path = format!("/tmp/mkv-test/index-{id}.db");
|
let db_path = format!("/tmp/mkv-test/index-{id}.db");
|
||||||
|
|
||||||
// Clean up any previous test database
|
|
||||||
let _ = std::fs::remove_file(&db_path);
|
let _ = std::fs::remove_file(&db_path);
|
||||||
let _ = std::fs::remove_file(format!("{db_path}-wal"));
|
let _ = std::fs::remove_file(format!("{db_path}-wal"));
|
||||||
let _ = std::fs::remove_file(format!("{db_path}-shm"));
|
let _ = std::fs::remove_file(format!("{db_path}-shm"));
|
||||||
|
|
||||||
let mut config =
|
let args = mkv::Args {
|
||||||
mkv::config::Config::load(Path::new("tests/test_config.toml")).expect("load test config");
|
db_path,
|
||||||
config.database.path = db_path;
|
volumes: vec![
|
||||||
|
"http://localhost:3101".into(),
|
||||||
|
"http://localhost:3102".into(),
|
||||||
|
"http://localhost:3103".into(),
|
||||||
|
],
|
||||||
|
replicas: 2,
|
||||||
|
};
|
||||||
|
|
||||||
// Bind to port 0 to get a random available port
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let port = listener.local_addr().unwrap().port();
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let app = mkv::build_app(&args);
|
||||||
let app = mkv::build_app(config).await;
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Give the server a moment to start
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
format!("http://127.0.0.1:{port}")
|
format!("http://127.0.0.1:{port}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,26 +45,12 @@ async fn test_put_and_head() {
|
||||||
let base = start_server().await;
|
let base = start_server().await;
|
||||||
let c = client();
|
let c = client();
|
||||||
|
|
||||||
// PUT a key
|
let resp = c.put(format!("{base}/hello")).body("world").send().await.unwrap();
|
||||||
let resp = c
|
|
||||||
.put(format!("{base}/hello"))
|
|
||||||
.body("world")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
// HEAD should return 200 with content-length
|
|
||||||
let resp = c.head(format!("{base}/hello")).send().await.unwrap();
|
let resp = c.head(format!("{base}/hello")).send().await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
assert_eq!(
|
assert_eq!(resp.headers().get("content-length").unwrap().to_str().unwrap(), "5");
|
||||||
resp.headers()
|
|
||||||
.get("content-length")
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap(),
|
|
||||||
"5"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -74,26 +58,15 @@ async fn test_put_and_get_redirect() {
|
||||||
let base = start_server().await;
|
let base = start_server().await;
|
||||||
let c = client();
|
let c = client();
|
||||||
|
|
||||||
// PUT
|
let resp = c.put(format!("{base}/redirect-test")).body("some data").send().await.unwrap();
|
||||||
let resp = c
|
|
||||||
.put(format!("{base}/redirect-test"))
|
|
||||||
.body("some data")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
// GET should return 302 with Location header pointing to a volume
|
|
||||||
let resp = c.get(format!("{base}/redirect-test")).send().await.unwrap();
|
let resp = c.get(format!("{base}/redirect-test")).send().await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||||
|
|
||||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||||
assert!(
|
assert!(location.starts_with("http://localhost:310"), "got: {location}");
|
||||||
location.starts_with("http://localhost:310"),
|
|
||||||
"location should point to a volume, got: {location}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Follow the redirect manually and verify the blob content
|
|
||||||
let blob_resp = reqwest::get(location).await.unwrap();
|
let blob_resp = reqwest::get(location).await.unwrap();
|
||||||
assert_eq!(blob_resp.status(), StatusCode::OK);
|
assert_eq!(blob_resp.status(), StatusCode::OK);
|
||||||
assert_eq!(blob_resp.text().await.unwrap(), "some data");
|
assert_eq!(blob_resp.text().await.unwrap(), "some data");
|
||||||
|
|
@ -103,7 +76,6 @@ async fn test_put_and_get_redirect() {
|
||||||
async fn test_get_nonexistent_returns_404() {
|
async fn test_get_nonexistent_returns_404() {
|
||||||
let base = start_server().await;
|
let base = start_server().await;
|
||||||
let c = client();
|
let c = client();
|
||||||
|
|
||||||
let resp = c.get(format!("{base}/does-not-exist")).send().await.unwrap();
|
let resp = c.get(format!("{base}/does-not-exist")).send().await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
@ -113,24 +85,15 @@ async fn test_put_get_delete_get() {
|
||||||
let base = start_server().await;
|
let base = start_server().await;
|
||||||
let c = client();
|
let c = client();
|
||||||
|
|
||||||
// PUT
|
let resp = c.put(format!("{base}/delete-me")).body("temporary").send().await.unwrap();
|
||||||
let resp = c
|
|
||||||
.put(format!("{base}/delete-me"))
|
|
||||||
.body("temporary")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
// GET → 302
|
|
||||||
let resp = c.get(format!("{base}/delete-me")).send().await.unwrap();
|
let resp = c.get(format!("{base}/delete-me")).send().await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||||
|
|
||||||
// DELETE → 204
|
|
||||||
let resp = c.delete(format!("{base}/delete-me")).send().await.unwrap();
|
let resp = c.delete(format!("{base}/delete-me")).send().await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
// GET after delete → 404
|
|
||||||
let resp = c.get(format!("{base}/delete-me")).send().await.unwrap();
|
let resp = c.get(format!("{base}/delete-me")).send().await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
@ -139,12 +102,7 @@ async fn test_put_get_delete_get() {
|
||||||
async fn test_delete_nonexistent_returns_404() {
|
async fn test_delete_nonexistent_returns_404() {
|
||||||
let base = start_server().await;
|
let base = start_server().await;
|
||||||
let c = client();
|
let c = client();
|
||||||
|
let resp = c.delete(format!("{base}/never-existed")).send().await.unwrap();
|
||||||
let resp = c
|
|
||||||
.delete(format!("{base}/never-existed"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,28 +111,17 @@ async fn test_list_keys() {
|
||||||
let base = start_server().await;
|
let base = start_server().await;
|
||||||
let c = client();
|
let c = client();
|
||||||
|
|
||||||
// PUT a few keys with a common prefix
|
|
||||||
for name in ["docs/a", "docs/b", "docs/c", "other/x"] {
|
for name in ["docs/a", "docs/b", "docs/c", "other/x"] {
|
||||||
c.put(format!("{base}/{name}"))
|
c.put(format!("{base}/{name}")).body("data").send().await.unwrap();
|
||||||
.body("data")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all
|
|
||||||
let resp = c.get(format!("{base}/")).send().await.unwrap();
|
let resp = c.get(format!("{base}/")).send().await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
let body = resp.text().await.unwrap();
|
let body = resp.text().await.unwrap();
|
||||||
assert!(body.contains("docs/a"));
|
assert!(body.contains("docs/a"));
|
||||||
assert!(body.contains("other/x"));
|
assert!(body.contains("other/x"));
|
||||||
|
|
||||||
// List with prefix filter
|
let resp = c.get(format!("{base}/?prefix=docs/")).send().await.unwrap();
|
||||||
let resp = c
|
|
||||||
.get(format!("{base}/?prefix=docs/"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
let body = resp.text().await.unwrap();
|
let body = resp.text().await.unwrap();
|
||||||
let lines: Vec<&str> = body.lines().collect();
|
let lines: Vec<&str> = body.lines().collect();
|
||||||
|
|
@ -187,34 +134,14 @@ async fn test_put_overwrite() {
|
||||||
let base = start_server().await;
|
let base = start_server().await;
|
||||||
let c = client();
|
let c = client();
|
||||||
|
|
||||||
// PUT v1
|
c.put(format!("{base}/overwrite")).body("version1").send().await.unwrap();
|
||||||
c.put(format!("{base}/overwrite"))
|
|
||||||
.body("version1")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// PUT v2 (same key)
|
let resp = c.put(format!("{base}/overwrite")).body("version2").send().await.unwrap();
|
||||||
let resp = c
|
|
||||||
.put(format!("{base}/overwrite"))
|
|
||||||
.body("version2")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
// HEAD should reflect new size
|
|
||||||
let resp = c.head(format!("{base}/overwrite")).send().await.unwrap();
|
let resp = c.head(format!("{base}/overwrite")).send().await.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(resp.headers().get("content-length").unwrap().to_str().unwrap(), "8");
|
||||||
resp.headers()
|
|
||||||
.get("content-length")
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap(),
|
|
||||||
"8"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Follow redirect, verify content is v2
|
|
||||||
let resp = c.get(format!("{base}/overwrite")).send().await.unwrap();
|
let resp = c.get(format!("{base}/overwrite")).send().await.unwrap();
|
||||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||||
let body = reqwest::get(location).await.unwrap().text().await.unwrap();
|
let body = reqwest::get(location).await.unwrap().text().await.unwrap();
|
||||||
|
|
@ -226,27 +153,12 @@ async fn test_replication_writes_to_multiple_volumes() {
|
||||||
let base = start_server().await;
|
let base = start_server().await;
|
||||||
let c = client();
|
let c = client();
|
||||||
|
|
||||||
// PUT a key (replication_factor=2 in test config)
|
c.put(format!("{base}/replicated")).body("replica-data").send().await.unwrap();
|
||||||
c.put(format!("{base}/replicated"))
|
|
||||||
.body("replica-data")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// HEAD to confirm it exists
|
let resp = c.head(format!("{base}/replicated")).send().await.unwrap();
|
||||||
let resp = c
|
|
||||||
.head(format!("{base}/replicated"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
// GET and verify the blob is accessible
|
let resp = c.get(format!("{base}/replicated")).send().await.unwrap();
|
||||||
let resp = c
|
|
||||||
.get(format!("{base}/replicated"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||||
let body = reqwest::get(location).await.unwrap().text().await.unwrap();
|
let body = reqwest::get(location).await.unwrap().text().await.unwrap();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue