This commit is contained in:
Silas Brack 2026-03-07 17:27:54 +01:00
parent dc1f4bd19d
commit 2c66fa50d8
9 changed files with 1125 additions and 960 deletions

View file

@ -1,4 +1,4 @@
use rusqlite::{params, Connection, OpenFlags}; use rusqlite::{Connection, OpenFlags, params};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crate::error::AppError; use crate::error::AppError;
@ -56,7 +56,9 @@ impl Db {
);", );",
) )
.expect("failed to create tables"); .expect("failed to create tables");
Self { conn: Arc::new(Mutex::new(conn)) } Self {
conn: Arc::new(Mutex::new(conn)),
}
} }
pub async fn get(&self, key: &str) -> Result<Record, AppError> { pub async fn get(&self, key: &str) -> Result<Record, AppError> {
@ -64,10 +66,15 @@ impl Db {
let key = key.to_string(); let key = key.to_string();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let conn = conn.lock().unwrap(); let conn = conn.lock().unwrap();
let mut stmt = conn.prepare_cached("SELECT key, volumes, size FROM kv WHERE key = ?1")?; let mut stmt =
conn.prepare_cached("SELECT key, volumes, size FROM kv WHERE key = ?1")?;
Ok(stmt.query_row(params![key], |row| { Ok(stmt.query_row(params![key], |row| {
let vj: String = row.get(1)?; let vj: String = row.get(1)?;
Ok(Record { key: row.get(0)?, volumes: parse_volumes(&vj), size: row.get(2)? }) Ok(Record {
key: row.get(0)?,
volumes: parse_volumes(&vj),
size: row.get(2)?,
})
})?) })?)
}) })
.await .await
@ -108,9 +115,8 @@ impl Db {
.collect::<Result<Vec<String>, _>>()? .collect::<Result<Vec<String>, _>>()?
} }
None => { None => {
let mut stmt = conn.prepare_cached( let mut stmt =
"SELECT key FROM kv WHERE key >= ?1 ORDER BY key", conn.prepare_cached("SELECT key FROM kv WHERE key >= ?1 ORDER BY key")?;
)?;
stmt.query_map(params![prefix], |row| row.get(0))? stmt.query_map(params![prefix], |row| row.get(0))?
.collect::<Result<Vec<String>, _>>()? .collect::<Result<Vec<String>, _>>()?
} }
@ -121,7 +127,12 @@ impl Db {
.unwrap() .unwrap()
} }
pub async fn put(&self, key: String, volumes: Vec<String>, size: Option<i64>) -> Result<(), AppError> { 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();
@ -148,7 +159,10 @@ impl Db {
.unwrap() .unwrap()
} }
pub async fn bulk_put(&self, records: Vec<(String, Vec<String>, Option<i64>)>) -> Result<(), AppError> { pub async fn bulk_put(
&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();
@ -174,7 +188,11 @@ impl Db {
let records = stmt let records = stmt
.query_map([], |row| { .query_map([], |row| {
let vj: String = row.get(1)?; let vj: String = row.get(1)?;
Ok(Record { key: row.get(0)?, volumes: parse_volumes(&vj), size: row.get(2)? }) Ok(Record {
key: row.get(0)?,
volumes: parse_volumes(&vj),
size: row.get(2)?,
})
})? })?
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Ok(records) Ok(records)

View file

@ -4,8 +4,14 @@ use axum::response::{IntoResponse, Response};
/// Errors from individual volume HTTP requests — used for logging, not HTTP responses. /// Errors from individual volume HTTP requests — used for logging, not HTTP responses.
#[derive(Debug)] #[derive(Debug)]
pub enum VolumeError { pub enum VolumeError {
Request { url: String, source: reqwest::Error }, Request {
BadStatus { url: String, status: reqwest::StatusCode }, url: String,
source: reqwest::Error,
},
BadStatus {
url: String,
status: reqwest::StatusCode,
},
} }
impl std::fmt::Display for VolumeError { impl std::fmt::Display for VolumeError {

View file

@ -12,7 +12,11 @@ pub fn volumes_for_key(key: &str, volumes: &[String], count: usize) -> Vec<Strin
}) })
.collect(); .collect();
scored.sort_by_key(|(score, _)| *score); scored.sort_by_key(|(score, _)| *score);
scored.into_iter().take(count).map(|(_, v)| v.clone()).collect() scored
.into_iter()
.take(count)
.map(|(_, v)| v.clone())
.collect()
} }
#[cfg(test)] #[cfg(test)]

View file

@ -1,9 +1,9 @@
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod hasher; pub mod hasher;
pub mod server;
pub mod rebalance; pub mod rebalance;
pub mod rebuild; pub mod rebuild;
pub mod server;
use std::sync::Arc; use std::sync::Arc;

View file

@ -6,7 +6,13 @@ struct Cli {
#[arg(short, long, env = "MKV_DB", default_value = "/tmp/mkv/index.db")] #[arg(short, long, env = "MKV_DB", default_value = "/tmp/mkv/index.db")]
db: String, db: String,
#[arg(short, long, env = "MKV_VOLUMES", required = true, value_delimiter = ',')] #[arg(
short,
long,
env = "MKV_VOLUMES",
required = true,
value_delimiter = ','
)]
volumes: Vec<String>, volumes: Vec<String>,
#[arg(short, long, env = "MKV_REPLICAS", default_value_t = 2)] #[arg(short, long, env = "MKV_REPLICAS", default_value_t = 2)]
@ -36,8 +42,7 @@ async fn shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c(); let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)] #[cfg(unix)]
{ {
let mut sigterm = let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler"); .expect("failed to install SIGTERM handler");
tokio::select! { tokio::select! {
_ = ctrl_c => tracing::info!("Received SIGINT, shutting down..."), _ = ctrl_c => tracing::info!("Received SIGINT, shutting down..."),

View file

@ -1,5 +1,5 @@
use crate::db;
use crate::Args; use crate::Args;
use crate::db;
pub struct KeyMove { pub struct KeyMove {
pub key: String, pub key: String,
@ -10,12 +10,25 @@ pub struct KeyMove {
pub to_remove: Vec<String>, pub to_remove: Vec<String>,
} }
pub fn plan_rebalance(records: &[db::Record], volumes: &[String], replication: usize) -> Vec<KeyMove> { pub fn plan_rebalance(
records: &[db::Record],
volumes: &[String],
replication: usize,
) -> Vec<KeyMove> {
let mut moves = Vec::new(); let mut moves = Vec::new();
for record in records { for record in records {
let desired = crate::hasher::volumes_for_key(&record.key, volumes, replication); let desired = crate::hasher::volumes_for_key(&record.key, volumes, replication);
let to_add: Vec<String> = desired.iter().filter(|v| !record.volumes.contains(v)).cloned().collect(); let to_add: Vec<String> = desired
let to_remove: Vec<String> = record.volumes.iter().filter(|v| !desired.contains(v)).cloned().collect(); .iter()
.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 {
@ -79,7 +92,12 @@ pub async fn run(args: &Args, dry_run: bool) {
let dst_url = format!("{dst}/{}", m.key); let dst_url = format!("{dst}/{}", m.key);
match client.put(&dst_url).body(data).send().await { match client.put(&dst_url).body(data).send().await {
Ok(resp) if !resp.status().is_success() => { Ok(resp) if !resp.status().is_success() => {
eprintln!(" ERROR copy {} to {}: status {}", m.key, dst, resp.status()); eprintln!(
" ERROR copy {} to {}: status {}",
m.key,
dst,
resp.status()
);
copy_ok = false; copy_ok = false;
errors += 1; errors += 1;
} }
@ -92,7 +110,12 @@ pub async fn run(args: &Args, dry_run: bool) {
} }
} }
Ok(resp) => { Ok(resp) => {
eprintln!(" ERROR read {} from {}: status {}", m.key, src, resp.status()); eprintln!(
" ERROR read {} from {}: status {}",
m.key,
src,
resp.status()
);
copy_ok = false; copy_ok = false;
errors += 1; errors += 1;
} }
@ -104,9 +127,13 @@ pub async fn run(args: &Args, dry_run: bool) {
} }
} }
if !copy_ok { continue; } if !copy_ok {
continue;
}
db.put(m.key.clone(), m.desired_volumes.clone(), m.size).await.expect("failed to update index"); db.put(m.key.clone(), m.desired_volumes.clone(), m.size)
.await
.expect("failed to update index");
for old in &m.to_remove { for old in &m.to_remove {
let url = format!("{old}/{}", m.key); let url = format!("{old}/{}", m.key);
@ -131,7 +158,11 @@ mod tests {
.map(|i| { .map(|i| {
let key = format!("key-{i}"); let key = format!("key-{i}");
let vols = crate::hasher::volumes_for_key(&key, &volumes, 2); let vols = crate::hasher::volumes_for_key(&key, &volumes, 2);
db::Record { key, volumes: vols, size: Some(100) } db::Record {
key,
volumes: vols,
size: Some(100),
}
}) })
.collect(); .collect();
@ -146,7 +177,11 @@ mod tests {
.map(|i| { .map(|i| {
let key = format!("key-{i}"); let key = format!("key-{i}");
let vols = crate::hasher::volumes_for_key(&key, &volumes3, 2); let vols = crate::hasher::volumes_for_key(&key, &volumes3, 2);
db::Record { key, volumes: vols, size: Some(100) } db::Record {
key,
volumes: vols,
size: Some(100),
}
}) })
.collect(); .collect();

View file

@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::db;
use crate::Args; use crate::Args;
use crate::db;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct NginxEntry { struct NginxEntry {
@ -19,13 +19,22 @@ async fn list_volume_keys(volume_url: &str) -> Result<Vec<(String, i64)>, String
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.get(&url).send().await.map_err(|e| format!("GET {url}: {e}"))?; let resp = http
.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() { entry.name.clone() } else { format!("{prefix}{}", entry.name) }; let full_path = if prefix.is_empty() {
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))),
@ -58,14 +67,19 @@ pub async fn run(args: &Args) {
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());
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}"),
} }
} }
let records: Vec<_> = index.into_iter().map(|(k, (v, s))| (k, v, Some(s))).collect(); let records: Vec<_> = index
.into_iter()
.map(|(k, (v, s))| (k, v, Some(s)))
.collect();
let count = records.len(); let count = records.len();
db.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");

View file

@ -25,7 +25,11 @@ pub async fn get_key(
.first() .first()
.ok_or_else(|| AppError::CorruptRecord { key: key.clone() })?; .ok_or_else(|| AppError::CorruptRecord { key: key.clone() })?;
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())
} }
pub async fn put_key( pub async fn put_key(
@ -45,15 +49,22 @@ pub async fn put_key(
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 url = format!("{vol}/{key}"); let url = format!("{vol}/{key}");
let handle = tokio::spawn({ let handle =
tokio::spawn({
let client = state.http.clone(); let client = state.http.clone();
let data = body.clone(); let data = body.clone();
async move { async move {
let resp = client.put(&url).body(data).send().await.map_err(|e| { let resp = client.put(&url).body(data).send().await.map_err(|e| {
VolumeError::Request { url: url.clone(), source: e } VolumeError::Request {
url: url.clone(),
source: e,
}
})?; })?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(VolumeError::BadStatus { url, status: resp.status() }); return Err(VolumeError::BadStatus {
url,
status: resp.status(),
});
} }
Ok(()) Ok(())
} }
@ -85,7 +96,11 @@ pub async fn put_key(
} }
let size = Some(body.len() as i64); let size = Some(body.len() as i64);
if let Err(e) = state.db.put(key.clone(), target_volumes.clone(), size).await { if let Err(e) = state
.db
.put(key.clone(), target_volumes.clone(), size)
.await
{
for vol in &target_volumes { for vol in &target_volumes {
let _ = state.http.delete(format!("{vol}/{key}")).send().await; let _ = state.http.delete(format!("{vol}/{key}")).send().await;
} }
@ -106,11 +121,19 @@ pub async fn delete_key(
let handle = tokio::spawn({ let handle = tokio::spawn({
let client = state.http.clone(); let client = state.http.clone();
async move { async move {
let resp = client.delete(&url).send().await.map_err(|e| { let resp = client
VolumeError::Request { url: url.clone(), source: e } .delete(&url)
.send()
.await
.map_err(|e| VolumeError::Request {
url: url.clone(),
source: e,
})?; })?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(VolumeError::BadStatus { url, status: resp.status() }); return Err(VolumeError::BadStatus {
url,
status: resp.status(),
});
} }
Ok(()) Ok(())
} }
@ -136,7 +159,10 @@ pub async fn head_key(
let record = state.db.get(&key).await?; let record = state.db.get(&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(axum::http::header::CONTENT_LENGTH, size.to_string().parse().unwrap()); headers.insert(
axum::http::header::CONTENT_LENGTH,
size.to_string().parse().unwrap(),
);
} }
Ok((StatusCode::OK, headers).into_response()) Ok((StatusCode::OK, headers).into_response())
} }

View file

@ -45,12 +45,24 @@ async fn test_put_and_head() {
let base = start_server().await; let base = start_server().await;
let c = client(); let c = client();
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);
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!(resp.headers().get("content-length").unwrap().to_str().unwrap(), "5"); assert_eq!(
resp.headers()
.get("content-length")
.unwrap()
.to_str()
.unwrap(),
"5"
);
} }
#[tokio::test] #[tokio::test]
@ -58,14 +70,22 @@ async fn test_put_and_get_redirect() {
let base = start_server().await; let base = start_server().await;
let c = client(); let c = client();
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);
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!(location.starts_with("http://localhost:310"), "got: {location}"); assert!(
location.starts_with("http://localhost:310"),
"got: {location}"
);
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);
@ -76,7 +96,11 @@ 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);
} }
@ -85,7 +109,12 @@ async fn test_put_get_delete_get() {
let base = start_server().await; let base = start_server().await;
let c = client(); let c = client();
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);
let resp = c.get(format!("{base}/delete-me")).send().await.unwrap(); let resp = c.get(format!("{base}/delete-me")).send().await.unwrap();
@ -102,7 +131,11 @@ 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);
} }
@ -112,7 +145,11 @@ async fn test_list_keys() {
let c = client(); let c = client();
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}")).body("data").send().await.unwrap(); c.put(format!("{base}/{name}"))
.body("data")
.send()
.await
.unwrap();
} }
let resp = c.get(format!("{base}/")).send().await.unwrap(); let resp = c.get(format!("{base}/")).send().await.unwrap();
@ -134,13 +171,29 @@ async fn test_put_overwrite() {
let base = start_server().await; let base = start_server().await;
let c = client(); let c = client();
c.put(format!("{base}/overwrite")).body("version1").send().await.unwrap(); c.put(format!("{base}/overwrite"))
.body("version1")
.send()
.await
.unwrap();
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);
let resp = c.head(format!("{base}/overwrite")).send().await.unwrap(); let resp = c.head(format!("{base}/overwrite")).send().await.unwrap();
assert_eq!(resp.headers().get("content-length").unwrap().to_str().unwrap(), "8"); assert_eq!(
resp.headers()
.get("content-length")
.unwrap()
.to_str()
.unwrap(),
"8"
);
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();
@ -153,7 +206,11 @@ async fn test_replication_writes_to_multiple_volumes() {
let base = start_server().await; let base = start_server().await;
let c = client(); let c = client();
c.put(format!("{base}/replicated")).body("replica-data").send().await.unwrap(); c.put(format!("{base}/replicated"))
.body("replica-data")
.send()
.await
.unwrap();
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);