README.md (11529B)
1 # mkv 2 3 Distributed key-value store for blobs. Thin index server (Rust + SQLite) in front of nginx volume servers. Inspired by [minikeyvalue](https://github.com/geohot/minikeyvalue). 4 5 ## Usage 6 7 ```bash 8 # Start the index server (replicates to 2 of 3 volumes) 9 mkv -d /tmp/index.db -v http://vol1:8080,http://vol2:8080,http://vol3:8080 -r 2 serve -p 3000 10 11 # Store a file 12 curl -X PUT -d "contents" http://localhost:3000/path/to/key 13 14 # Retrieve (returns 302 redirect to nginx) 15 curl -L http://localhost:3000/path/to/key 16 17 # Check existence and size 18 curl -I http://localhost:3000/path/to/key 19 20 # Delete 21 curl -X DELETE http://localhost:3000/path/to/key 22 23 # List keys (with optional prefix filter) 24 curl http://localhost:3000/?prefix=path/to/ 25 ``` 26 27 ### Operations 28 29 ```bash 30 # Rebuild index by scanning all volumes (stop the server first) 31 mkv -d /tmp/index.db -v http://vol1:8080,http://vol2:8080,http://vol3:8080 -r 2 rebuild 32 33 # Rebalance after adding/removing volumes (preview with --dry-run) 34 mkv -d /tmp/index.db -v http://vol1:8080,http://vol2:8080,http://vol3:8080,http://vol4:8080 -r 2 rebalance --dry-run 35 mkv -d /tmp/index.db -v http://vol1:8080,http://vol2:8080,http://vol3:8080,http://vol4:8080 -r 2 rebalance 36 ``` 37 38 ### Volume servers 39 40 Any nginx with WebDAV enabled works: 41 42 ```nginx 43 server { 44 listen 80; 45 root /data; 46 location / { 47 dav_methods PUT DELETE; 48 create_full_put_path on; 49 autoindex on; 50 autoindex_format json; 51 } 52 } 53 ``` 54 55 ## What it does 56 57 - **HTTP API** — PUT, GET (302 redirect), DELETE, HEAD, LIST with prefix filtering 58 - **Replication** — fan-out writes to N volumes concurrently, all-or-nothing with rollback 59 - **Consistent hashing** — stable volume assignment; adding/removing a volume only moves ~1/N of keys 60 - **Rebuild** — reconstructs the SQLite index by scanning nginx autoindex on all volumes 61 - **Rebalance** — migrates data to correct volumes after topology changes, with `--dry-run` preview 62 - **Key-as-path** — blobs stored at `/{key}` on nginx, no content-addressing or sidecar files 63 - **Single binary** — no config files, everything via CLI flags 64 65 ## What it doesn't do 66 67 - **Checksums** — no integrity verification; bit rot goes undetected 68 - **Auth** — no access control; anyone who can reach the server can read/write/delete 69 - **Encryption** — blobs stored as plain files on nginx 70 - **Streaming / range requests** — entire blob must fit in memory 71 - **Metadata** — no EXIF, tags, or content types; key path is all you get 72 - **Versioning** — PUT overwrites; no history 73 - **Compression** — blobs stored as-is 74 75 ## Comparison to minikeyvalue 76 77 mkv is a ground-up rewrite of [minikeyvalue](https://github.com/geohot/minikeyvalue) in Rust. 78 79 | | mkv | minikeyvalue | 80 |--|-----|--------------| 81 | Language | Rust | Go | 82 | Index | SQLite (WAL mode) | LevelDB | 83 | Storage paths | key-as-path (`/{key}`) | content-addressed (md5 + base64) | 84 | GET behavior | Index lookup, 302 redirect | HEAD to volume first, then 302 redirect | 85 | PUT overwrite | Allowed | Forbidden (returns 403) | 86 | Hash function | SHA-256 per volume, sort by score | MD5 per volume, sort by score | 87 | MD5 of values | No | Yes (stored in index) | 88 | Health checker | No | No (checks per-request via HEAD) | 89 | Subvolumes | No | Yes (configurable fan-out directories) | 90 | Soft delete | No (hard delete) | Yes (UNLINK + DELETE two-phase) | 91 | S3 API | No | Partial (list, multipart upload) | 92 | App code | ~600 lines | ~1,000 lines | 93 | Tests | 17 (unit + integration) | 1 | 94 95 ### Performance (10k keys, 1KB values, 100 concurrency) 96 97 Tested on the same machine with shared nginx volumes: 98 99 | Operation | mkv | minikeyvalue | 100 |-----------|-----|--------------| 101 | PUT | 10,000 req/s | 10,500 req/s | 102 | GET (full round-trip) | 7,000 req/s | 6,500 req/s | 103 | GET (index only) | 15,800 req/s | 13,800 req/s | 104 | DELETE | 13,300 req/s | 13,600 req/s | 105 106 Both are bottlenecked by nginx volume I/O. The index layer (SQLite) can sustain 378,000 writes/sec in isolation. 107 108 ## Error responses 109 110 Every error returns a plain-text body with a human-readable message. 111 112 | Status | Error | When | 113 |--------|-------|------| 114 | `404 Not Found` | `not found` | GET, HEAD, DELETE for a key that doesn't exist | 115 | `500 Internal Server Error` | `corrupt record for key {key}: no volumes` | Key exists in index but has no volume locations (data integrity issue) | 116 | `500 Internal Server Error` | `database error: {detail}` | SQLite failure (disk full, corruption, locked) | 117 | `502 Bad Gateway` | `not all volume writes succeeded` | PUT where one or more volume writes failed; all volumes are rolled back | 118 | `503 Service Unavailable` | `need {n} volumes but only {m} available` | PUT when fewer volumes are configured than the replication factor requires | 119 120 ### Failure modes 121 122 **PUT** writes to all target volumes concurrently, then updates the index. If any volume write fails, all volumes are rolled back (best-effort) and the client gets 502. If volume writes succeed but the index update fails, volumes are rolled back and the client gets 500. 123 124 **DELETE** removes the key from the index and issues best-effort deletes to all volumes. Volume delete failures are logged but do not fail the request — the client always gets 204 if the key existed. This can leave orphaned blobs on volumes; use `rebuild` to reconcile. 125 126 **GET** looks up the key in the index and returns a 302 redirect to the first volume. If the volume is unreachable, the client sees the failure directly from nginx (the index server does not proxy the blob). 127 128 ## Security 129 130 mkv assumes a **trusted network**. There is no built-in authentication, authorization, or encryption. This is the same security model as minikeyvalue — neither system is designed for direct exposure to the public internet. 131 132 ### Trust model 133 134 The index server and volume servers (nginx) are expected to live on the same private network. GET requests return a 302 redirect to a volume URL, so clients must be able to reach the volumes directly. Anyone who can reach the index server can read, write, and delete any key. Anyone who can reach a volume can read any blob. 135 136 ### Deploying with auth 137 138 Put a reverse proxy in front of the index server and handle authentication there: 139 140 - **Basic auth or API keys** at the reverse proxy for simple setups 141 - **mTLS** for machine-to-machine access 142 - **OAuth / JWT** validation at the proxy for multi-user setups 143 144 Volume servers should be on a private network that clients cannot reach directly, or use nginx's `secure_link` module to validate signed redirect URLs. 145 146 ### What neither mkv nor minikeyvalue protect against 147 148 - Unauthorized reads/writes (no auth) 149 - Data in transit (no TLS unless the proxy adds it) 150 - Data at rest (blobs are plain files on disk) 151 - Malicious keys (no input sanitization beyond what nginx enforces on paths) 152 - Index tampering (SQLite file has no integrity protection) 153 154 155 # Development 156 157 ## Principles 158 159 1. **Explicit over clever** — no magic helpers, no macros that hide control 160 flow, no trait gymnastics. Code reads top-to-bottom. A new reader should 161 understand what a function does without chasing through layers of 162 indirection. 163 164 2. **Pure functions** — isolate decision logic from IO. A function that takes 165 data and returns data is testable, composable, and easy to reason about. 166 Keep it that way. Don't sneak in network calls or logging. 167 168 3. **Linear flow** — avoid callbacks, deep nesting, and async gymnastics where 169 possible. A handler should read like a sequence of steps: look up the 170 record, pick a volume, build the response. 171 172 4. **Minimize shared state** — pass values explicitly. Don't hold locks across 173 IO. Don't reach into globals. 174 175 5. **Minimize indirection** — don't hide logic behind abstractions that exist 176 "in case we need to swap the implementation later." We won't. A three-line 177 function inline is better than a trait with one implementor. 178 179 ## Applying the principles: separate decisions from execution 180 181 Every request handler does two things: **decides** what should happen, then 182 **executes** IO to make it happen. These should be separate functions. 183 184 A decision is a pure function. It takes data in, returns a description of what 185 to do. It doesn't call the network, doesn't touch the database, doesn't log. 186 It can be tested with `assert_eq!` and nothing else. 187 188 Execution is the messy part — HTTP calls, SQLite writes, error recovery. It 189 reads the decision and carries it out. It's tested with integration tests. 190 191 ## Where this applies today 192 193 ### Already pure 194 195 **`hasher.rs`** — the entire module is pure. `volumes_for_key` is a 196 deterministic function of its inputs. No IO, no state mutation. This is the 197 gold standard for the project. 198 199 **`rebalance.rs::plan_rebalance`** — takes a slice of records and returns a 200 list of moves. Pure decision logic, tested with unit tests. 201 202 **`db.rs` encode/parse** — `parse_volumes` and `encode_volumes` are pure 203 transformations between JSON strings and `Vec<String>`. 204 205 ### Mixed (decision + execution interleaved) 206 207 **`server.rs::put_key`** — this handler does three things in one function: 208 209 1. *Decide* which volumes to write to (pure — `volumes_for_key`) 210 2. *Execute* fan-out PUTs to nginx (IO) 211 3. *Decide* whether to rollback based on results (pure — check which succeeded) 212 4. *Execute* rollback DELETEs and/or index write (IO) 213 214 Steps 1 and 3 could be extracted as pure functions if they grow more complex. 215 216 ### Intentionally impure 217 218 **`rebuild.rs`** — walks nginx autoindex and bulk-inserts into SQLite. The IO 219 is the whole point; there's no decision logic worth extracting. 220 221 **`db.rs`** — wraps SQLite behind `Arc<Mutex<Connection>>` with 222 `spawn_blocking` to avoid blocking the tokio runtime. The mutex serializes all 223 access; `SQLITE_OPEN_NO_MUTEX` disables SQLite's internal locking since the 224 application mutex handles it. 225 226 ## Guidelines 227 228 1. **If a function takes only data and returns only data, it's pure.** Keep it 229 that way. Don't sneak in logging, metrics, or "just one network call." 230 231 2. **If a handler has an `if` or `match` that decides between outcomes, that 232 decision can probably be a pure function.** Extract it. Name it. Test it. 233 234 3. **IO boundaries should be thin.** Format URL, make request, check status, 235 return bytes. No business logic. 236 237 4. **Don't over-abstract.** A three-line pure function inline in a handler is 238 fine. Extract it when it gets complex enough to need its own tests, or when 239 the same decision appears in multiple places (e.g., rebuild and rebalance 240 both use `volumes_for_key`). 241 242 5. **Errors are data.** `AppError` is a value, not an exception. Functions 243 return `Result`, handlers pattern-match on it. The `IntoResponse` impl is 244 the only place where errors become HTTP responses — one place, one mapping. 245 246 ## Anti-patterns to avoid 247 248 - **God handler** — a 100-line async fn that reads the DB, calls volumes, makes 249 decisions, handles errors, and formats the response. Break it up. 250 251 - **Hidden state reads** — if a function needs data, pass it in. Don't reach 252 into a global or lock a mutex inside a "pure" function. 253 254 - **Testing IO to test logic** — if you need a Docker container running to test 255 whether volume selection works correctly, the logic isn't separated from the 256 IO. 257