Skip to content

Commit baf5044

Browse files
authored
Add central application state container (#7)
1 parent 0253571 commit baf5044

5 files changed

Lines changed: 283 additions & 0 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ tracing = "0.1"
1919

2020
base64 = "0.22"
2121
blake3 = "1.8"
22+
hex = "0.4"
2223
dashmap = "6.1"
2324
tokio = { version = "1.49", default-features = false }
2425
reqwest = { version = "0.13", default-features = false }

minikv-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ thiserror.workspace = true
1515
tracing.workspace = true
1616
base64 = { workspace = true }
1717
blake3 = { workspace = true }
18+
hex = { workspace = true }
1819
dashmap = { workspace = true }
1920
rusty-leveldb = { workspace = true }
2021
reqwest = { workspace = true, features = ["rustls", "stream", "json"] }

minikv-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod hashing;
33
pub mod locking;
44
pub mod record;
55
pub mod replication;
6+
pub mod state;
67
pub mod storage;
78
pub mod volumes;
89

minikv-core/src/state.rs

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
//! Central application state shared across all request handlers.
2+
//!
3+
//! `AppState` holds all mutable and immutable state needed for request
4+
//! processing, including the LevelDB metadata store, per-key locks, multipart
5+
//! upload registry, volume configuration, and the shared HTTP client.
6+
//!
7+
//! All fields follow strict concurrency rules: immutable fields remain read-only,
8+
//! while mutable fields are protected by fine-grained locks (`KeyLock`, `DashMap`,
9+
//! or `tokio::sync::Mutex` for the DB). LevelDB operations that block are wrapped
10+
//! in `spawn_blocking` when used in async contexts to avoid holding async locks
11+
//! during blocking I/O.
12+
13+
use std::collections::HashMap;
14+
use std::sync::Arc;
15+
use std::time::Duration;
16+
17+
use dashmap::DashMap;
18+
use tracing::{debug, warn};
19+
20+
use crate::locking::KeyLock;
21+
use crate::record::{Deleted, Record};
22+
use crate::storage::MetadataStore;
23+
24+
/// Central application state, shared via `Arc<AppState>`.
25+
pub struct AppState {
26+
/// LevelDB metadata store, behind a trait for testability.
27+
pub db: Arc<dyn MetadataStore>,
28+
29+
/// Per-key write lock map. This is used to prevent concurrent PUT/DELETE on the same key.
30+
pub key_lock: KeyLock,
31+
32+
/// In-flight multipart upload IDs.
33+
/// `DashMap` is used instead of `Mutex<HashMap>` to avoid a global lock.
34+
pub upload_ids: DashMap<String, ()>,
35+
36+
// ---- configuration (read-only after startup) ----
37+
/// Ordered list of all volume server addresses.
38+
pub volumes: Vec<String>,
39+
40+
/// Optional fallback server for keys not found on any volume.
41+
pub fallback: Option<String>,
42+
43+
/// Number of replicas to write per PUT.
44+
pub replicas: usize,
45+
46+
/// Number of subvolume shards per volume server (1 = no subvolumes).
47+
pub subvolumes: usize,
48+
49+
/// If `true`, DELETE is only allowed after an UNLINK (soft-delete first).
50+
pub protect: bool,
51+
52+
/// If `true`, compute and store a BLAKE3 checksum for each object body.
53+
pub checksum: bool,
54+
55+
/// Timeout for HEAD requests to volume servers during GET redirect.
56+
pub vol_timeout: Duration,
57+
58+
/// Shared HTTP client for all volume server communication.
59+
pub http_client: reqwest::Client,
60+
61+
/// Maps internal volume address (e.g. `"volume1:8080"`) to its
62+
/// public-facing address (e.g. `"localhost:8001"`).
63+
///
64+
/// Built once at startup from `--volumes` + `--public-volumes`.
65+
/// Empty when `--public-volumes` is not set — Location headers will
66+
/// then use internal addresses unchanged (correct for bare-metal).
67+
pub vol_rewrite: HashMap<String, String>,
68+
69+
/// When `true`, GET/HEAD returns `X-Accel-Redirect` instead of `302`.
70+
///
71+
/// Requires a frontend nginx configured with `proxy_pass` to the
72+
/// coordinator and an `internal` proxy location for `/accel/`.
73+
/// The frontend nginx then fetches the object from the volume server
74+
/// directly, serving the body with the coordinator\'s response headers
75+
/// (including `Content-Type` from stored metadata).
76+
///
77+
/// When `false` (default), GET/HEAD returns a standard `302 Found`
78+
/// redirect to the volume server URL.
79+
pub accel_redirect: bool,
80+
}
81+
82+
impl AppState {
83+
/// Read a record from LevelDB.
84+
///
85+
/// Returns `Record::not_found()` (deleted == Hard) when the key is absent.
86+
pub async fn get_record(&self, key: &[u8]) -> Record {
87+
match self.db.get(key) {
88+
Ok(Some(bytes)) => match Record::decode(&bytes) {
89+
Ok(rec) => rec,
90+
Err(e) => {
91+
warn!(?e, key = ?String::from_utf8_lossy(key), "corrupt record in DB");
92+
Record::not_found()
93+
}
94+
},
95+
Ok(None) => Record::not_found(),
96+
Err(e) => {
97+
warn!(?e, "LevelDB get error");
98+
Record::not_found()
99+
}
100+
}
101+
}
102+
103+
/// Write a record to LevelDB.
104+
///
105+
/// Returns `false` on any error (matching Go's bool-return pattern),
106+
/// and logs the error. Callers translate `false` => HTTP 500.
107+
pub async fn put_record(&self, key: &[u8], rec: Record) -> bool {
108+
match rec.encode() {
109+
Err(e) => {
110+
warn!(?e, "attempted to encode invalid record");
111+
false
112+
}
113+
Ok(bytes) => match self.db.put(key, &bytes) {
114+
Ok(()) => {
115+
debug!(key = ?String::from_utf8_lossy(key), "record written");
116+
true
117+
}
118+
Err(e) => {
119+
warn!(?e, "LevelDB put error");
120+
false
121+
}
122+
},
123+
}
124+
}
125+
126+
/// Hard-delete a key from LevelDB entirely.
127+
pub async fn delete_record(&self, key: &[u8]) -> bool {
128+
match self.db.delete(key) {
129+
Ok(()) => true,
130+
Err(e) => {
131+
warn!(?e, "LevelDB delete error");
132+
false
133+
}
134+
}
135+
}
136+
137+
/// Write an object to all configured replicas and update LevelDB.
138+
///
139+
/// Steps:
140+
/// 1. Compute target volumes via `key_to_volume`.
141+
/// 2. Mark key as SOFT-deleted in DB (partially written sentinel).
142+
/// 3. Write body bytes to each replica.
143+
/// 4. Optionally compute BLAKE3 hash of the body.
144+
/// 5. Mark key as fully present (NO deletion) with optional hash.
145+
///
146+
/// Returns the HTTP status code to send to the client (201 or 500).
147+
pub async fn write_to_replicas(
148+
&self,
149+
key: &[u8],
150+
body: bytes::Bytes,
151+
content_type: Option<String>,
152+
) -> u16 {
153+
use crate::hashing::key_to_path;
154+
use crate::replication::remote_put;
155+
use crate::volumes::key_to_volume;
156+
157+
let kvolumes = key_to_volume(key, &self.volumes, self.replicas, self.subvolumes);
158+
159+
// Step 1: mark as in-progress (SOFT) so a crash doesn't leave
160+
// a record pointing at volumes that were never written.
161+
if !self
162+
.put_record(
163+
key,
164+
Record {
165+
volumes: kvolumes.clone(),
166+
deleted: Deleted::Soft,
167+
hash: None,
168+
content_type: None,
169+
},
170+
)
171+
.await
172+
{
173+
return 500;
174+
}
175+
176+
// Step 2: write to each replica.
177+
let kp = key_to_path(key);
178+
for volume in &kvolumes {
179+
let url = format!("http://{volume}{kp}");
180+
if let Err(e) = remote_put(&self.http_client, &url, body.clone()).await {
181+
warn!(?e, url, "replica write failed");
182+
return 500;
183+
}
184+
}
185+
186+
// Step 3: optionally compute content hash.
187+
let hash = if self.checksum {
188+
let digest = blake3::hash(&body);
189+
Some(hex::encode(digest.as_bytes()))
190+
} else {
191+
None
192+
};
193+
194+
// Step 4: mark as fully present.
195+
if !self
196+
.put_record(
197+
key,
198+
Record {
199+
volumes: kvolumes,
200+
deleted: Deleted::No,
201+
hash,
202+
content_type,
203+
},
204+
)
205+
.await
206+
{
207+
return 500;
208+
}
209+
210+
201
211+
}
212+
213+
/// Delete an object (soft or hard depending on `unlink`).
214+
///
215+
/// Returns the HTTP status code: 204, 403, 404, or 500.
216+
pub async fn delete(&self, key: &[u8], unlink: bool) -> u16 {
217+
use crate::hashing::key_to_path;
218+
use crate::replication::remote_delete;
219+
220+
let rec = self.get_record(key).await;
221+
222+
if rec.deleted == Deleted::Hard {
223+
return 404;
224+
}
225+
if unlink && rec.deleted == Deleted::Soft {
226+
return 404;
227+
}
228+
if !unlink && self.protect && rec.deleted == Deleted::No {
229+
return 403;
230+
}
231+
232+
// Mark as soft-deleted first.
233+
if !self
234+
.put_record(
235+
key,
236+
Record {
237+
volumes: rec.volumes.clone(),
238+
deleted: Deleted::Soft,
239+
hash: rec.hash.clone(),
240+
content_type: rec.content_type.clone(),
241+
},
242+
)
243+
.await
244+
{
245+
return 500;
246+
}
247+
248+
if unlink {
249+
return 204;
250+
}
251+
252+
// Hard delete: remove from volume servers, then from DB.
253+
let kp = key_to_path(key);
254+
let mut delete_error = false;
255+
for volume in &rec.volumes {
256+
let url = format!("http://{volume}{kp}");
257+
if let Err(e) = remote_delete(&self.http_client, &url).await {
258+
warn!(?e, url, "remote delete failed: possible orphan file");
259+
delete_error = true;
260+
}
261+
}
262+
263+
if delete_error {
264+
return 500;
265+
}
266+
267+
if !self.delete_record(key).await {
268+
return 500;
269+
}
270+
271+
204
272+
}
273+
}

0 commit comments

Comments
 (0)