Skip to content

Commit 8097250

Browse files
authored
Add HTTP layer (#11)
* Add HTTP layer * Move `MemStore` to tests directory The in-memory metadastore is intended for testing purposes only. * Add HTTP integration tests
1 parent 34ce5e7 commit 8097250

14 files changed

Lines changed: 2295 additions & 70 deletions

File tree

Cargo.lock

Lines changed: 628 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ keywords = ["storage", "distributed", "leveldb", "object-store"]
1414
categories = ["database", "network-programming"]
1515

1616
[workspace.dependencies]
17+
minikv-core = { path = "minikv-core" }
18+
1719
thiserror = "2"
1820
tracing = "0.1"
1921

@@ -26,8 +28,9 @@ dashmap = "6.1"
2628
tokio = { version = "1.49", default-features = false }
2729
reqwest = { version = "0.13", default-features = false }
2830
bytes = { version = "1.11" }
29-
30-
# Chosen over `leveldb` crate which requires C++ LevelDB via FFI.
31+
uuid = { version = "1", features = ["v4"] }
32+
rand = "0.8"
33+
futures = "0.3"
3134
rusty-leveldb = "1"
3235

3336
# Testing

minikv-core/src/storage/mod.rs

Lines changed: 0 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -42,56 +42,3 @@ pub trait MetadataStore: Send + Sync {
4242
/// Primarily used by `rebuild_all` to regenerate the database from scratch.
4343
fn delete_all(&self) -> Result<(), Error>;
4444
}
45-
46-
/// In-memory `MetadataStore` backed by a `BTreeMap`.
47-
///
48-
/// Useful for unit and integration tests that do not require persistent storage.
49-
#[cfg(debug_assertions)]
50-
pub mod mem {
51-
use super::*;
52-
use std::collections::BTreeMap;
53-
use std::sync::Mutex;
54-
55-
#[derive(Default)]
56-
pub struct MemStore {
57-
inner: Mutex<BTreeMap<Vec<u8>, Vec<u8>>>,
58-
}
59-
60-
impl MetadataStore for MemStore {
61-
fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, Error> {
62-
Ok(self.inner.lock().unwrap().get(key).cloned())
63-
}
64-
65-
fn put(&self, key: &[u8], value: &[u8]) -> Result<(), Error> {
66-
self.inner
67-
.lock()
68-
.unwrap()
69-
.insert(key.to_vec(), value.to_vec());
70-
Ok(())
71-
}
72-
73-
fn delete(&self, key: &[u8]) -> Result<(), Error> {
74-
self.inner.lock().unwrap().remove(key);
75-
Ok(())
76-
}
77-
78-
fn scan_prefix(&self, prefix: &[u8]) -> Result<Vec<KeyValuePair>, Error> {
79-
let store = self.inner.lock().unwrap();
80-
Ok(store
81-
.iter()
82-
.filter(|(k, _)| k.starts_with(prefix))
83-
.map(|(k, v)| (k.clone(), v.clone()))
84-
.collect())
85-
}
86-
87-
fn scan_all(&self) -> Result<Vec<KeyValuePair>, Error> {
88-
let store = self.inner.lock().unwrap();
89-
Ok(store.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
90-
}
91-
92-
fn delete_all(&self) -> Result<(), Error> {
93-
self.inner.lock().unwrap().clear();
94-
Ok(())
95-
}
96-
}
97-
}

minikv-core/tests/rebalance.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,66 @@
88
//! An in-memory `MemStore` is used as the metadata backend.
99
1010
use dashmap::DashMap;
11+
use minikv_core::Error;
12+
use minikv_core::KeyValuePair;
13+
use minikv_core::MetadataStore;
1114
use minikv_core::locking::KeyLock;
1215
use minikv_core::rebalance::rebalance_key;
1316
use minikv_core::replication::build_volume_client;
1417
use minikv_core::state::AppState;
15-
use minikv_core::storage::mem::MemStore;
1618
use minikv_core::volumes::needs_rebalance;
19+
use std::collections::BTreeMap;
1720
use std::collections::HashMap;
1821
use std::sync::Arc;
22+
use std::sync::Mutex;
1923
use std::time::Duration;
2024
use wiremock::matchers::method;
2125
use wiremock::{Mock, MockServer, ResponseTemplate};
2226

27+
// In-memory `MetadataStore` backed by a `BTreeMap`.
28+
#[derive(Default)]
29+
pub struct MemStore {
30+
inner: Mutex<BTreeMap<Vec<u8>, Vec<u8>>>,
31+
}
32+
33+
impl MetadataStore for MemStore {
34+
fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, Error> {
35+
Ok(self.inner.lock().unwrap().get(key).cloned())
36+
}
37+
38+
fn put(&self, key: &[u8], value: &[u8]) -> Result<(), Error> {
39+
self.inner
40+
.lock()
41+
.unwrap()
42+
.insert(key.to_vec(), value.to_vec());
43+
Ok(())
44+
}
45+
46+
fn delete(&self, key: &[u8]) -> Result<(), Error> {
47+
self.inner.lock().unwrap().remove(key);
48+
Ok(())
49+
}
50+
51+
fn scan_prefix(&self, prefix: &[u8]) -> Result<Vec<KeyValuePair>, Error> {
52+
let store = self.inner.lock().unwrap();
53+
Ok(store
54+
.iter()
55+
.filter(|(k, _)| k.starts_with(prefix))
56+
.map(|(k, v)| (k.clone(), v.clone()))
57+
.collect())
58+
}
59+
60+
fn scan_all(&self) -> Result<Vec<KeyValuePair>, Error> {
61+
let store = self.inner.lock().unwrap();
62+
Ok(store.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
63+
}
64+
65+
fn delete_all(&self) -> Result<(), Error> {
66+
self.inner.lock().unwrap().clear();
67+
Ok(())
68+
}
69+
}
70+
2371
/// Build a test `AppState` backed by `MemStore` and pointing at `volumes`.
2472
fn make_state(volumes: Vec<String>) -> Arc<AppState> {
2573
let replicas = volumes.len().min(2);

minikv/Cargo.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,28 @@ authors.workspace = true
1010
keywords.workspace = true
1111
categories.workspace = true
1212

13+
[lib]
14+
name = "minikv_server"
15+
path = "src/lib.rs"
16+
1317
[dependencies]
18+
minikv-core = { workspace = true }
19+
tracing = { workspace = true }
20+
dashmap = { workspace = true }
21+
thiserror = { workspace = true }
22+
bytes = { workspace = true }
23+
serde = { workspace = true, features = ["derive"] }
24+
serde_json = { workspace = true }
25+
futures = { workspace = true }
26+
rand = { workspace = true }
27+
uuid = { workspace = true }
28+
tokio = { workspace = true, features = ["full"] }
29+
clap = { version = "4", features = ["derive", "env"] }
30+
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
31+
axum = { version = "0.7", features = [] }
32+
quick-xml = { version = "0.31", features = ["serialize"] }
33+
34+
[dev-dependencies]
35+
tower-util = { version = "0.3" }
36+
minikv-core = { workspace = true }
37+
wiremock = "0.6"

minikv/src/cli.rs

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/// CLI definition for `minikv`.
2+
///
3+
/// # Subcommands
4+
/// - `server` run the HTTP metadata coordinator
5+
/// - `rebuild` reconstruct LevelDB from volume server autoindex
6+
/// - `rebalance` move all keys to their ideal volume set
7+
/// - `print-nginx-config` emit the nginx volume server config to stdout
8+
use std::path::PathBuf;
9+
use std::time::Duration;
10+
11+
use clap::{Args, Parser, Subcommand};
12+
13+
#[derive(Parser, Debug)]
14+
#[command(
15+
name = "minikv",
16+
version,
17+
about = "Distributed object storage coordinator",
18+
long_about = None,
19+
)]
20+
pub struct Cli {
21+
#[command(subcommand)]
22+
pub command: Command,
23+
}
24+
25+
#[derive(Subcommand, Debug)]
26+
pub enum Command {
27+
/// Run the HTTP metadata coordinator server.
28+
Server(ServerArgs),
29+
30+
/// Rebuild LevelDB metadata from volume server autoindex listings.
31+
///
32+
/// WARNING: This clears the entire DB before scanning.
33+
Rebuild(CommonArgs),
34+
35+
/// Rebalance all keys to their ideal volume set.
36+
Rebalance(CommonArgs),
37+
38+
/// Print the nginx volume server configuration to stdout.
39+
PrintNginxConfig,
40+
}
41+
42+
/// Arguments shared by server, rebuild, and rebalance.
43+
#[derive(Args, Debug, Clone)]
44+
pub struct CommonArgs {
45+
/// Path to the LevelDB database directory.
46+
#[arg(long, env = "MINIKV_DB", required = true)]
47+
pub db: PathBuf,
48+
49+
/// Comma-separated list of volume server addresses (host:port).
50+
#[arg(long, env = "MINIKV_VOLUMES", required = true, value_delimiter = ',')]
51+
pub volumes: Vec<String>,
52+
53+
/// Number of replicas to maintain per object.
54+
#[arg(long, env = "MINIKV_REPLICAS", default_value = "3")]
55+
pub replicas: usize,
56+
57+
/// Number of sub-volume shards per volume server.
58+
/// Use 1 to disable sub-volume path components.
59+
#[arg(long, env = "MINIKV_SUBVOLUMES", default_value = "10")]
60+
pub subvolumes: usize,
61+
62+
/// Timeout for HEAD probes to volume servers (e.g. "1s", "500ms").
63+
#[arg(long, env = "MINIKV_VOLTIMEOUT", default_value = "1s", value_parser = parse_duration)]
64+
pub voltimeout: Duration,
65+
}
66+
67+
/// Additional arguments for the `server` subcommand.
68+
#[derive(Args, Debug)]
69+
pub struct ServerArgs {
70+
#[command(flatten)]
71+
pub common: CommonArgs,
72+
73+
/// Port to listen on.
74+
#[arg(long, env = "MINIKV_PORT", default_value = "3000")]
75+
pub port: u16,
76+
77+
/// Optional fallback server for keys missing from all volumes.
78+
#[arg(long, env = "MINIKV_FALLBACK")]
79+
pub fallback: Option<String>,
80+
81+
/// Public-facing addresses for each volume server, used in Location
82+
/// redirect headers returned to clients.
83+
///
84+
/// Must be the same length as --volumes and in the same order.
85+
/// Each entry is the externally-reachable host:port for the corresponding
86+
/// --volumes entry.
87+
///
88+
/// Example:
89+
/// --volumes=volume1:8080,volume2:8080,volume3:8080
90+
/// --public-volumes=localhost:8001,localhost:8002,localhost:8003
91+
///
92+
/// When omitted, --volumes addresses are used as-is in Location headers
93+
/// (correct for bare-metal deployments where internal == external).
94+
#[arg(
95+
long,
96+
env = "MINIKV_PUBLIC_VOLUMES",
97+
value_delimiter = ',',
98+
requires = "volumes"
99+
)]
100+
pub public_volumes: Option<Vec<String>>,
101+
102+
/// Require UNLINK (soft-delete) before a hard DELETE is allowed.
103+
#[arg(long, env = "MINIKV_PROTECT", default_value = "false")]
104+
pub protect: bool,
105+
106+
/// Compute and store a BLAKE3 checksum for every uploaded object.
107+
#[arg(long, env = "MINIKV_CHECKSUM", default_value = "true")]
108+
pub checksum: bool,
109+
110+
/// Enable X-Accel-Redirect mode for GET/HEAD responses.
111+
///
112+
/// When enabled, the coordinator returns `X-Accel-Redirect: /accel/<host>/<path>`
113+
/// instead of `302 Location`. A frontend nginx must be configured with:
114+
///
115+
/// proxy_pass http://coordinator:3000;
116+
/// location ~ ^/accel/([^/]+)/(.+)$ {
117+
/// internal;
118+
/// proxy_pass http://$1/$2;
119+
/// }
120+
///
121+
/// This allows nginx to stream the object body while the coordinator controls
122+
/// all response headers, including Content-Type from stored object metadata.
123+
///
124+
/// When disabled (default), GET/HEAD returns a standard 302 redirect.
125+
#[arg(long, env = "MINIKV_ACCEL_REDIRECT", default_value = "false")]
126+
pub accel_redirect: bool,
127+
128+
/// Enable verbose structured logging.
129+
#[arg(short, long, env = "MINIKV_VERBOSE", default_value = "false")]
130+
pub verbose: bool,
131+
}
132+
133+
/// Parse a human-friendly duration string such as "1s" or "500ms".
134+
fn parse_duration(s: &str) -> Result<Duration, String> {
135+
if let Some(ms) = s.strip_suffix("ms") {
136+
ms.parse::<u64>()
137+
.map(Duration::from_millis)
138+
.map_err(|e| e.to_string())
139+
} else if let Some(secs) = s.strip_suffix('s') {
140+
secs.parse::<u64>()
141+
.map(Duration::from_secs)
142+
.map_err(|e| e.to_string())
143+
} else {
144+
// Fall back: treat as integer milliseconds.
145+
s.parse::<u64>()
146+
.map(Duration::from_millis)
147+
.map_err(|_| format!("invalid duration '{s}'. Use '1s' or '500ms'"))
148+
}
149+
}
150+
151+
/// Validate common arguments and panic with a clear message on bad input.
152+
pub fn validate_common(args: &CommonArgs) {
153+
if args.volumes.is_empty() {
154+
eprintln!("error: --volumes must contain at least one volume server");
155+
std::process::exit(1);
156+
}
157+
if args.replicas == 0 {
158+
eprintln!("error: --replicas must be ≥ 1");
159+
std::process::exit(1);
160+
}
161+
if args.volumes.len() < args.replicas {
162+
eprintln!(
163+
"error: need at least as many volumes ({}) as replicas ({})",
164+
args.volumes.len(),
165+
args.replicas
166+
);
167+
std::process::exit(1);
168+
}
169+
if args.subvolumes == 0 {
170+
eprintln!("error: --subvolumes must be ≥ 1");
171+
std::process::exit(1);
172+
}
173+
}
174+
175+
/// Validate server-only arguments.
176+
#[allow(unused)]
177+
pub fn validate_server(args: &ServerArgs) {
178+
validate_common(&args.common);
179+
if let Some(ref pv) = args.public_volumes
180+
&& pv.len() != args.common.volumes.len()
181+
{
182+
eprintln!(
183+
"error: --public-volumes has {} entries but --volumes has {}; they must match 1:1",
184+
pv.len(),
185+
args.common.volumes.len()
186+
);
187+
std::process::exit(1);
188+
}
189+
}
190+
191+
#[cfg(test)]
192+
mod tests {
193+
use super::*;
194+
195+
#[test]
196+
fn parse_duration_seconds() {
197+
assert_eq!(parse_duration("1s").unwrap(), Duration::from_secs(1));
198+
assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
199+
}
200+
201+
#[test]
202+
fn parse_duration_millis() {
203+
assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
204+
}
205+
206+
#[test]
207+
fn parse_duration_invalid() {
208+
assert!(parse_duration("1h").is_err());
209+
}
210+
}

0 commit comments

Comments
 (0)