initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
target
|
||||
2191
Cargo.lock
generated
Normal file
2191
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
Cargo.toml
Normal file
31
Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "blob-store-app"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
default-run = "blob-store-app"
|
||||
|
||||
[[bin]]
|
||||
name = "blob-store-app"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "load-test"
|
||||
path = "src/load_test.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7.5", features = ["macros"] }
|
||||
axum_typed_multipart = "0.11.1"
|
||||
chrono = "0.4.38"
|
||||
clap = { version = "4.5.4", features = ["derive"] }
|
||||
futures = "0.3.30"
|
||||
kdam = "0.5.1"
|
||||
rand = "0.8.5"
|
||||
reqwest = { version = "0.12.4", features = ["json", "multipart", "blocking"] }
|
||||
rusqlite = "0.31.0"
|
||||
serde = { version = "1.0.198", features = ["serde_derive"] }
|
||||
serde_json = "1.0.116"
|
||||
sha2 = "0.10.8"
|
||||
tokio = { version = "1.37.0", features = ["full", "rt-multi-thread"] }
|
||||
tokio-rusqlite = "0.5.1"
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = "0.3.18"
|
||||
59
src/load_test.rs
Normal file
59
src/load_test.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use std::borrow::Borrow;
|
||||
use std::borrow::BorrowMut;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use kdam::tqdm;
|
||||
use kdam::Bar;
|
||||
use kdam::BarExt;
|
||||
use rand::Rng;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pb = Arc::new(Mutex::new(tqdm!()));
|
||||
|
||||
let mut handles = vec![];
|
||||
let num_shards = 8;
|
||||
for _ in (0..num_shards).into_iter() {
|
||||
let pb = pb.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
run_loop(pb).unwrap();
|
||||
}));
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_loop(mut pb: Arc<Mutex<Bar>>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rand_data = vec![0u8; 1024 * 1024];
|
||||
rng.fill(&mut rand_data[..]);
|
||||
|
||||
loop {
|
||||
// tweak a byte in the data
|
||||
let idx = rng.gen_range(0..rand_data.len());
|
||||
rand_data[idx] = rng.gen();
|
||||
|
||||
let form = reqwest::blocking::multipart::Form::new()
|
||||
.text("content_type", "text/plain")
|
||||
.part(
|
||||
"data",
|
||||
reqwest::blocking::multipart::Part::bytes(rand_data.clone()),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post("http://localhost:7692/store")
|
||||
.multipart(form)
|
||||
.send()?;
|
||||
|
||||
// update progress bar
|
||||
let mut pb = pb.lock().unwrap();
|
||||
pb.update(1)?;
|
||||
if resp.status() != 200 && resp.status() != 201 {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
280
src/main.rs
Normal file
280
src/main.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
use axum::Json;
|
||||
use axum::{body::Bytes, http::StatusCode, routing::post, Extension, Router};
|
||||
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
|
||||
use clap::Parser;
|
||||
use rusqlite::ffi;
|
||||
use rusqlite::params;
|
||||
use rusqlite::Error::SqliteFailure;
|
||||
use sha2::Digest;
|
||||
use tokio::signal;
|
||||
use std::collections::HashMap;
|
||||
use std::{borrow::Borrow, error::Error, path::PathBuf, sync::Arc};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_rusqlite::Connection;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Directory that holds the backing database files
|
||||
#[arg(short, long)]
|
||||
db_path: String,
|
||||
|
||||
/// Number of shards
|
||||
#[arg(short, long)]
|
||||
shards: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
struct ManifestData {
|
||||
shards: usize,
|
||||
}
|
||||
|
||||
#[derive(TryFromMultipart)]
|
||||
struct StoreRequest {
|
||||
sha256: Option<String>,
|
||||
content_type: String,
|
||||
data: FieldData<Bytes>,
|
||||
}
|
||||
|
||||
struct StoreRequestParsed {
|
||||
sha256: String,
|
||||
content_type: String,
|
||||
data: Bytes,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Shards(Vec<Arc<Connection>>);
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
let db_path = PathBuf::from(&args.db_path);
|
||||
let num_shards = validate_manifest(args)?;
|
||||
|
||||
// max num_shards threads
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(num_shards as usize)
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
runtime.block_on(async {
|
||||
let server = TcpListener::bind("127.0.0.1:7692").await?;
|
||||
info!(
|
||||
"listening on {} with {} shards",
|
||||
server.local_addr()?,
|
||||
num_shards
|
||||
);
|
||||
let mut shards = vec![];
|
||||
for shard in 0..num_shards {
|
||||
let shard_path = db_path.join(format!("shard{}.sqlite", shard));
|
||||
let conn = Connection::open(shard_path).await?;
|
||||
migrate(&conn).await?;
|
||||
shards.push(Arc::new(conn));
|
||||
}
|
||||
|
||||
for (shard, conn) in shards.iter().enumerate() {
|
||||
let count = num_entries_in(&conn).await?;
|
||||
info!("shard {} has {} entries", shard, count);
|
||||
}
|
||||
|
||||
server_loop(server, Shards(shards.clone())).await?;
|
||||
info!("shutting down server...");
|
||||
for conn in shards.into_iter() {
|
||||
(*conn).clone().close().await?;
|
||||
}
|
||||
info!("server closed sqlite connections. bye!");
|
||||
Ok::<(), Box<dyn Error>>(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn server_loop(server: TcpListener, shards: Shards) -> Result<(), Box<dyn Error>> {
|
||||
let app = Router::new()
|
||||
.route("/store", post(store_request_handler))
|
||||
.layer(Extension(shards));
|
||||
axum::serve(server, app.into_make_service()).with_graceful_shutdown(shutdown_signal()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
|
||||
type ResultType = Result<
|
||||
(StatusCode, Json<HashMap<&'static str, String>>),
|
||||
(StatusCode, Json<HashMap<&'static str, String>>)
|
||||
>;
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn store_request_handler(
|
||||
Extension(shards): Extension<Shards>,
|
||||
TypedMultipart(request): TypedMultipart<StoreRequest>,
|
||||
) -> ResultType {
|
||||
// compute sha256 of data
|
||||
let data_bytes = &request.data.contents;
|
||||
let sha256 = sha2::Sha256::digest(&data_bytes);
|
||||
let sha256_str = format!("{:x}", sha256);
|
||||
let num_shards = shards.0.len();
|
||||
// select shard
|
||||
let shard_num = sha256[0] as usize % num_shards;
|
||||
let conn = &shards.0[shard_num];
|
||||
|
||||
if let Some(req_sha256) = request.sha256 {
|
||||
if req_sha256 != sha256_str {
|
||||
error!("sha256 mismatch: {} != {}", req_sha256, sha256_str);
|
||||
let mut response = HashMap::new();
|
||||
response.insert("status", "error".to_owned());
|
||||
response.insert("message", "sha256 mismatch".to_owned());
|
||||
return Err((StatusCode::BAD_REQUEST, Json(response)));
|
||||
}
|
||||
}
|
||||
|
||||
// info!("storing {} on shard {}", sha256_str, shard_num);
|
||||
|
||||
let request_parsed = StoreRequestParsed {
|
||||
sha256: sha256_str,
|
||||
content_type: request.content_type,
|
||||
data: request.data.contents,
|
||||
};
|
||||
|
||||
let conn = conn.borrow();
|
||||
perform_store(&conn, request_parsed).await
|
||||
}
|
||||
|
||||
async fn perform_store(
|
||||
conn: &Connection,
|
||||
store_request: StoreRequestParsed,
|
||||
) -> ResultType {
|
||||
conn.call(move |conn| {
|
||||
let created_at = chrono::Utc::now().to_rfc3339();
|
||||
let maybe_error = conn.execute(
|
||||
"INSERT INTO entries (sha256, content_type, size, data, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
params![
|
||||
store_request.sha256,
|
||||
store_request.content_type,
|
||||
store_request.data.len() as i64,
|
||||
store_request.data.to_vec(),
|
||||
created_at,
|
||||
],
|
||||
);
|
||||
|
||||
let mut response = HashMap::new();
|
||||
response.insert("sha256", store_request.sha256.clone());
|
||||
|
||||
if let Err(e) = &maybe_error {
|
||||
if is_duplicate_entry_err(e) {
|
||||
info!("entry {} already exists", store_request.sha256);
|
||||
response.insert("status","ok".to_owned());
|
||||
response.insert("message", "already exists".to_owned());
|
||||
return Ok((StatusCode::OK, Json(response)));
|
||||
}
|
||||
}
|
||||
maybe_error?;
|
||||
let mut response = HashMap::new();
|
||||
response.insert("status", "ok".to_owned());
|
||||
response.insert("message", "created".to_owned());
|
||||
return Ok((StatusCode::CREATED, Json(response)));
|
||||
})
|
||||
.await.map_err(|e| {
|
||||
error!("store failed: {}", e);
|
||||
let mut response = HashMap::new();
|
||||
response.insert("status", "error".to_owned());
|
||||
response.insert("message", e.to_string());
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(response))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_duplicate_entry_err(error: &rusqlite::Error) -> bool {
|
||||
if let SqliteFailure(
|
||||
ffi::Error {
|
||||
code: ffi::ErrorCode::ConstraintViolation,
|
||||
..
|
||||
},
|
||||
Some(err_str),
|
||||
) = error
|
||||
{
|
||||
if err_str.contains("UNIQUE constraint failed: entries.sha256") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn migrate(conn: &Connection) -> Result<(), Box<dyn Error>> {
|
||||
// create tables, indexes, etc
|
||||
conn.call(|conn| {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS entries (
|
||||
sha256 BLOB PRIMARY KEY,
|
||||
content_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn num_entries_in(conn: &Connection) -> Result<i64, Box<dyn Error>> {
|
||||
conn.call(|conn| {
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM entries", [], |row| row.get(0))?;
|
||||
Ok(count)
|
||||
}).await.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
fn validate_manifest(args: Args) -> Result<usize, Box<dyn Error>> {
|
||||
let manifest_path = PathBuf::from(&args.db_path).join("manifest.json");
|
||||
if manifest_path.exists() {
|
||||
let file_content = std::fs::read_to_string(manifest_path)?;
|
||||
let manifest: ManifestData = serde_json::from_str(&file_content)?;
|
||||
if let Some(shards) = args.shards {
|
||||
if shards != manifest.shards {
|
||||
return Err(format!(
|
||||
"manifest indicates {} shards, expected {}",
|
||||
manifest.shards, shards
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
Ok(manifest.shards)
|
||||
} else {
|
||||
if let Some(shards) = args.shards {
|
||||
std::fs::create_dir_all(&args.db_path)?;
|
||||
let manifest = ManifestData { shards };
|
||||
let manifest_json = serde_json::to_string(&manifest)?;
|
||||
std::fs::write(manifest_path, manifest_json)?;
|
||||
return Ok(shards);
|
||||
} else {
|
||||
return Err("new database needs --shards argument".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
test/cat1.jpg
Normal file
BIN
test/cat1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
29
test/test.rb
Normal file
29
test/test.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
require "rest_client"
|
||||
|
||||
FILES = {
|
||||
cat1: -> { File.new("cat1.jpg", mode: "rb") },
|
||||
}
|
||||
|
||||
puts "response without sha256: "
|
||||
puts RestClient.post("http://localhost:7692/store", {
|
||||
content_type: "image/jpeg",
|
||||
data: FILES[:cat1].call,
|
||||
})
|
||||
|
||||
puts "response with correct sha256:"
|
||||
puts RestClient.post("http://localhost:7692/store", {
|
||||
content_type: "image/jpeg",
|
||||
sha256: "e3705544cbf2fa93e16107d1821b312a7b825fc177fa28180a9c9a9d3ae8af37",
|
||||
data: FILES[:cat1].call,
|
||||
})
|
||||
|
||||
puts "response with incorrect sha256:"
|
||||
begin
|
||||
puts RestClient.post("http://localhost:7692/store", {
|
||||
content_type: "image/jpeg",
|
||||
sha256: "123",
|
||||
data: FILES[:cat1].call,
|
||||
})
|
||||
rescue => e
|
||||
puts e.response
|
||||
end
|
||||
Reference in New Issue
Block a user