mkv

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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