From 582827bb0d497d9dcfd2373d77f7f8291c1b4aaa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 02:52:22 +0000 Subject: [PATCH] V1 B: localhost TCP service, file store, and optional RustFS cold contract Serve the V1 A batch API over a loopback TCP envelope with a bounded buffer pool, checksums, and tenant fingerprints. FileStore persists committed blocks under a caller-supplied directory and evicts oldest unpinned blocks for quotas. Prefix reuse returns the whole shorter block. Cold Put/Head/RangeGet/Delete stays off the hot path and is exercised with a mock when no RustFS endpoint is configured. Co-authored-by: RustFS --- .github/workflows/ci.yml | 10 +- AGENTS.md | 6 +- COMPATIBILITY.md | 7 +- Cargo.lock | 19 +- Cargo.toml | 4 + README.md | 37 +- THIRD_PARTY_NOTICES.md | 2 +- crates/cold/Cargo.toml | 16 + crates/cold/src/http.rs | 423 +++++++++++++++ crates/cold/src/lib.rs | 19 + crates/cold/src/mock.rs | 58 +++ crates/cold/src/pack.rs | 219 ++++++++ crates/cold/src/sigv4.rs | 181 +++++++ crates/core-types/src/codec.rs | 8 + crates/core-types/src/error.rs | 5 + crates/protocol/src/api.rs | 38 ++ crates/protocol/src/codec.rs | 20 +- crates/protocol/src/lib.rs | 16 +- crates/protocol/src/reply.rs | 629 +++++++++++++++++++++++ crates/storage/Cargo.toml | 2 +- crates/storage/src/file.rs | 611 ++++++++++++++++++++++ crates/storage/src/lib.rs | 9 +- crates/storage/src/memory.rs | 273 ++++++++++ crates/storage/src/tests.rs | 86 ++++ crates/storage/src/transfer.rs | 1 + crates/storage/tests/file_backend.rs | 426 +++++++++++++++ crates/transport/Cargo.toml | 25 + crates/transport/src/bin/gpucache_tcp.rs | 96 ++++ crates/transport/src/bin/share_demo.rs | 23 + crates/transport/src/client.rs | 155 ++++++ crates/transport/src/clock.rs | 37 ++ crates/transport/src/demo.rs | 298 +++++++++++ crates/transport/src/frame.rs | 70 +++ crates/transport/src/lib.rs | 20 + crates/transport/src/pool.rs | 96 ++++ crates/transport/src/server.rs | 250 +++++++++ crates/transport/tests/tcp_path.rs | 269 ++++++++++ docs/ARCHITECTURE.md | 27 +- docs/BENCHMARKS.md | 4 +- docs/OPERATIONS.md | 46 +- docs/PROTOCOL.md | 22 +- docs/RISKS.md | 8 +- docs/SECURITY.md | 10 +- docs/V1B_TEST_MATRIX.md | 33 ++ docs/adr/0001-integration-route.md | 2 +- docs/adr/0002-no-erasure-coding.md | 4 +- docs/adr/0003-partial-prefix.md | 25 + 47 files changed, 4585 insertions(+), 60 deletions(-) create mode 100644 crates/cold/Cargo.toml create mode 100644 crates/cold/src/http.rs create mode 100644 crates/cold/src/lib.rs create mode 100644 crates/cold/src/mock.rs create mode 100644 crates/cold/src/pack.rs create mode 100644 crates/cold/src/sigv4.rs create mode 100644 crates/protocol/src/reply.rs create mode 100644 crates/storage/src/file.rs create mode 100644 crates/storage/tests/file_backend.rs create mode 100644 crates/transport/Cargo.toml create mode 100644 crates/transport/src/bin/gpucache_tcp.rs create mode 100644 crates/transport/src/bin/share_demo.rs create mode 100644 crates/transport/src/client.rs create mode 100644 crates/transport/src/clock.rs create mode 100644 crates/transport/src/demo.rs create mode 100644 crates/transport/src/frame.rs create mode 100644 crates/transport/src/lib.rs create mode 100644 crates/transport/src/pool.rs create mode 100644 crates/transport/src/server.rs create mode 100644 crates/transport/tests/tcp_path.rs create mode 100644 docs/V1B_TEST_MATRIX.md create mode 100644 docs/adr/0003-partial-prefix.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c7995d..74ab64a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,16 @@ jobs: run: cargo clippy --workspace --all-targets -- -D warnings - name: Test run: cargo test --workspace --all-targets - - name: Reject accelerator crates in V1 A + - name: Reject accelerator crates in V1 run: | if grep -RInE 'cuda|doca|nixl|rdma|spdk' crates/*/Cargo.toml; then - echo "V1 A crates must not depend on GPU or DPU stacks" + echo "V1 crates must not depend on GPU or DPU stacks" + exit 1 + fi + - name: Keep the cold tier off the hot path + run: | + if grep -n 'gpucache-cold' crates/storage/Cargo.toml crates/transport/Cargo.toml crates/protocol/Cargo.toml crates/core-types/Cargo.toml; then + echo "RustFS cold tier must not be a hot-path dependency" exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index a60b27e..a36a7fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ GPUCache is the G3.5 shared KV tier. RustFS is the colder G4 backend. Read [READ ## Phase -The active phase is **V1 milestone A** unless a human explicitly starts a later milestone. Do not implement V1 B, V2 RDMA, or V3 DPU work in a V1 A change. Do not publish crates, cut releases, deploy, or edit a website unless asked. +The active phase is **V1 milestone B**. Do not implement V1 C, V2 RDMA, or V3 DPU work in a V1 B change. Do not publish crates, cut releases, deploy, or edit a website unless asked. ## Honesty rules @@ -21,8 +21,10 @@ The active phase is **V1 milestone A** unless a human explicitly starts a later | `gpucache-core-types` | `sha2` only | | `gpucache-protocol` | `gpucache-core-types` | | `gpucache-storage` | the two crates above | +| `gpucache-transport` | the three crates above. TCP only | +| `gpucache-cold` | `gpucache-core-types` and `sha2`. Not a dependency of storage or transport | -No crate may add CUDA, DOCA, NIXL, RDMA, SPDK, erasure coding, or replica placement. `unsafe` is forbidden. Registered buffers whose completion is unknown stay quarantined until drain or a newer epoch; do not free them only because a lease timer fired. +No crate may add CUDA, DOCA, NIXL, RDMA, SPDK, erasure coding, or replica placement. `unsafe` is forbidden. Registered buffers whose completion is unknown stay quarantined until drain or a newer epoch; do not free them only because a lease timer fired. The cold crate stays off the hot path. ## Checks diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index a40b87a..81a46d8 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,6 +1,6 @@ # Compatibility manifest -Recorded for V1 milestone A. This file is not a performance acceptance report and not a hardware inventory of a BlueField node. +Recorded for V1 milestones A and B. This file is not a performance acceptance report and not a hardware inventory of a BlueField node. | Component | Value | Result | | --- | --- | --- | @@ -15,8 +15,9 @@ Recorded for V1 milestone A. This file is not a performance acceptance report an | LMCache | Not a dependency | `NOT_RUN_HARDWARE` (route undecided) | | NIXL / UCX / RDMA | Not a dependency | Not in V1 | | DOCA / BlueField BFB / firmware | Not present | `NOT_RUN_HARDWARE` | -| RustFS server / client | No endpoint authorized | `NOT_RUN_HARDWARE` | -| Direct Rust deps | `sha2` 0.10.8; dev `proptest` 1.4.0 | Apache-2.0 OR MIT; see `Cargo.lock` | +| RustFS server / client | No endpoint authorized in this environment. Contract mock covers Put, Head, RangeGet, Delete, pack, and manifest | Live probe `NOT_RUN_HARDWARE`. Mock `PASS` | +| TCP / file store | Loopback TCP and a temp directory on the build host | `PASS` for `cargo test --workspace --all-targets` on x86_64. Not an NVMe format and not a DPU run | +| Direct Rust deps | `sha2` 0.10.8; dev `proptest` 1.4.0 | Apache-2.0 OR MIT; see `Cargo.lock`. No new direct crate in V1 B | | License of this repo | Apache-2.0 | See `LICENSE` | CI (see `.github/workflows/ci.yml`) builds and runs the same tests on x86_64 and on aarch64 (GitHub ARM runner, plus qemu-user cross execution). An aarch64 result is a CPU result. It does not certify BlueField-3, DOCA, or NVMe-on-DPU. diff --git a/Cargo.lock b/Cargo.lock index 3c87f09..354fec9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "autocfg" @@ -127,6 +127,14 @@ dependencies = [ "r-efi", ] +[[package]] +name = "gpucache-cold" +version = "0.1.0" +dependencies = [ + "gpucache-core-types", + "sha2", +] + [[package]] name = "gpucache-core-types" version = "0.1.0" @@ -152,6 +160,15 @@ dependencies = [ "proptest", ] +[[package]] +name = "gpucache-transport" +version = "0.1.0" +dependencies = [ + "gpucache-core-types", + "gpucache-protocol", + "gpucache-storage", +] + [[package]] name = "lazy_static" version = "1.5.1" diff --git a/Cargo.toml b/Cargo.toml index d1af7bd..dbaa504 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ members = [ "crates/core-types", "crates/protocol", "crates/storage", + "crates/cold", + "crates/transport", ] [workspace.package] @@ -19,6 +21,8 @@ sha2 = "=0.10.8" gpucache-core-types = { path = "crates/core-types" } gpucache-protocol = { path = "crates/protocol" } gpucache-storage = { path = "crates/storage" } +gpucache-cold = { path = "crates/cold" } +gpucache-transport = { path = "crates/transport" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index b38ed3b..94d8884 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,16 @@ GPUCache does not do erasure coding and does not keep durability replicas. A los ## Status -**V1 milestone A is implemented. DPU acceleration is not.** +**V1 milestones A and B are implemented. DPU acceleration is not.** V1 runs the cache contract on CPU. The long-term deployment target is a storage-side BlueField-3, but this repository does not link DOCA, CUDA, RDMA, or SPDK, and it does not claim that any of those paths exist. An aarch64 build is a CPU build. It is not a BlueField functional test. | Milestone | Status | | --- | --- | -| V1 A: identity, protocol, `MemoryStore`, error/timeout/cancel tests | Implemented in this tree | +| V1 A: identity, protocol, `MemoryStore`, error/timeout/cancel tests | Implemented | | V1 A: real vLLM KV block export/import | `NOT_RUN_HARDWARE` (no GPU in this environment) | -| V1 B: TCP service, file/NVMe, admission, RustFS smoke | Not started | +| V1 B: TCP service, file backend, LRU admission, tenant quotas, two-client reuse | Implemented in this tree | +| V1 B: RustFS live Put/Head/RangeGet/Delete | `NOT_RUN_HARDWARE` (no authorized endpoint). Contract mock passes | | V1 C: fuzz, fault injection, engine output comparison, paired baselines | Not started | | V2: NIC RDMA (NIXL or Mooncake) plus a real RustFS cold path | Not started; blocked on V1 | | V3: BlueField-3 data-path offload and production hardening | Not started; not purchased and not measured. Not an erasure-coding milestone | @@ -34,13 +35,23 @@ Three library crates, none of which depend on a GPU or DPU: The batch API is Lookup, Reserve/Put, Commit, Get, Release/Cancel, and Invalidate. Every item carries `request_id`, epoch, deadline, and, where it applies, generation, length, and checksum. Only a `COMMITTED` block whose identity is compatible is a hit. `ABSENT`, `RESERVED`, `WRITING`, `EVICTING`, and `QUARANTINED` blocks are not readable. +## What V1 B adds + +`gpucache-transport` serves that same API over TCP. The default bind is `127.0.0.1`. The process refuses to listen on a non-loopback address unless `--allow-remote` is set, and it still requires a tenant credential fingerprint. There is no unauthenticated data path. + +`FileStore` keeps committed blocks as ordinary files under a directory you pass in. It does not format a disk and it does not open a device node. When a tenant or global byte quota would fail, it evicts the oldest unpinned committed block. A pinned or quarantined block stays. `MemoryStore` still fails admission instead of evicting, so the V1 A tests keep that behavior. + +Lookup can return `PrefixHit` when a committed block's hash chain is a strict prefix of the query and the layout, dtype, shard, and model identity match. The hit is the whole stored block. `Get` must use `identity_for_prefix_hit`. A shorter `valid_length` on the same chain is an incompatible miss. The service does not slice inside a block. + +`gpucache-cold` packs blocks into an object plus a manifest and can Put, Head, RangeGet, and Delete through a trait. The TCP hot path does not call it. Without `GPUCACHE_RUSTFS_*` variables the live probe is `NOT_RUN_HARDWARE`. The mock still runs. + Lifecycle: ```text ABSENT -> RESERVED -> WRITING -> COMMITTED -> EVICTING -> ABSENT ``` -A failed or timed-out registered region goes to `QUARANTINED`. Lease expiry does not prove that a copy has stopped, so that buffer stays allocated until the owner attests drain or a newer store epoch fences it. Same tokens do not imply the same bytes: the logical prefix key and the physical byte key are different encodings. V1 reuses only within one tenant, one model, and one layout. A reshard, quant, or layout mismatch is a miss. There is no cross-tenant dedup and no partial-prefix hit in this milestone. +A failed or timed-out registered region goes to `QUARANTINED`. Lease expiry does not prove that a copy has stopped, so that buffer stays allocated until the owner attests drain or a newer store epoch fences it. Same tokens do not imply the same bytes: the logical prefix key and the physical byte key are different encodings. V1 reuses only within one tenant, one model, and one layout. A reshard, quant, or layout mismatch is a miss. There is no cross-tenant dedup. A longer hash chain can reuse a whole shorter committed block (`PrefixHit`); the service does not slice inside a block. See [docs/adr/0003-partial-prefix.md](docs/adr/0003-partial-prefix.md). Resource limits cover object size, in-flight requests, memory registrations, per-tenant in-flight bytes, and retained bytes. The idempotency table is bounded. @@ -52,7 +63,15 @@ From a clean checkout, with Rust stable (MSRV 1.85): cargo test --workspace --all-targets ``` -That command is the CPU contract repro. It includes unit tests, property tests, timeout and cancel tests, a single-process import of a synthetic block, and a cross-process import through `kv-block-xfer`. The bytes in those tests are synthetic. They are not a vLLM KV tensor. +That command is the CPU contract repro for V1 A and V1 B. It includes unit tests, property tests, timeout and cancel tests, a single-process import of a synthetic block, a cross-process import through `kv-block-xfer`, file-backend quota and restart tests, and the TCP reuse tests. The bytes are synthetic. They are not a vLLM KV tensor. + +Two localhost clients sharing one file-backed service: + +```bash +cargo run -p gpucache-transport --bin share-demo +``` + +A successful run prints `status=REUSE_OK` and `partial_prefix=WHOLE_BLOCK_PREFIX`. It does not print a sliced-block hit. `kv-block-xfer import ` reads one export frame on stdin and imports it into a fresh `MemoryStore`. It does not talk to an engine. @@ -60,9 +79,11 @@ That command is the CPU contract repro. It includes unit tests, property tests, ```text crates/core-types identity, keys, lifecycle, errors -crates/protocol batch messages and version negotiation -crates/storage MemoryStore and kv-block-xfer -docs/adr decision records (integration route, no erasure coding) +crates/protocol batch messages, replies, version negotiation +crates/storage MemoryStore, FileStore, kv-block-xfer +crates/transport TCP server, client, share-demo +crates/cold RustFS contract, pack/manifest, live probe +docs/adr decision records COMPATIBILITY.md environment manifest ``` diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 01386d0..ff9aec8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -GPUCache V1 A is Apache-2.0. See [LICENSE](LICENSE). +GPUCache V1 A and V1 B are Apache-2.0. See [LICENSE](LICENSE). Direct dependencies: diff --git a/crates/cold/Cargo.toml b/crates/cold/Cargo.toml new file mode 100644 index 0000000..816774d --- /dev/null +++ b/crates/cold/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "gpucache-cold" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +authors.workspace = true +description = "Optional RustFS G4 cold-tier contract. Not on the GPUCache hot path." +publish = false + +[dependencies] +gpucache-core-types.workspace = true +sha2.workspace = true + +[lints] +workspace = true diff --git a/crates/cold/src/http.rs b/crates/cold/src/http.rs new file mode 100644 index 0000000..63e03d7 --- /dev/null +++ b/crates/cold/src/http.rs @@ -0,0 +1,423 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use gpucache_core_types::{content_checksum, CacheError}; + +use crate::pack::{contract_smoke, ObjectMeta, ObjectStore}; +use crate::sigv4::{authorization, format_amz_time, hex, sha256, SignInput}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProbeStatus { + NotRunHardware { reason: &'static str }, + Passed, + Failed { op: &'static str }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProbeReport { + pub status: ProbeStatus, +} + +/// Live Put/Head/RangeGet/Delete when every required variable is set. +/// +/// Missing configuration is `NOT_RUN_HARDWARE`. This function does not invent +/// credentials and does not fall back to a mock success. +pub fn live_probe() -> ProbeReport { + let Some(cfg) = LiveConfig::from_env() else { + return ProbeReport { + status: ProbeStatus::NotRunHardware { + reason: "no authorized RustFS endpoint", + }, + }; + }; + if cfg.endpoint.starts_with("https://") { + return ProbeReport { + status: ProbeStatus::NotRunHardware { + reason: "https client is not linked", + }, + }; + } + if !cfg.endpoint.starts_with("http://") { + return ProbeReport { + status: ProbeStatus::Failed { op: "endpoint" }, + }; + } + let mut store = match HttpStore::new(&cfg) { + Ok(store) => store, + Err(_) => { + return ProbeReport { + status: ProbeStatus::Failed { op: "connect" }, + } + } + }; + match contract_smoke(&mut store) { + Ok(()) => ProbeReport { + status: ProbeStatus::Passed, + }, + Err(_) => ProbeReport { + status: ProbeStatus::Failed { op: "smoke" }, + }, + } +} + +struct LiveConfig { + endpoint: String, + bucket: String, + prefix: String, + region: String, + access_key: String, + secret: String, +} + +impl LiveConfig { + fn from_env() -> Option { + let endpoint = env_nonempty("GPUCACHE_RUSTFS_ENDPOINT")?; + let bucket = env_nonempty("GPUCACHE_RUSTFS_BUCKET")?; + let prefix = env_nonempty("GPUCACHE_RUSTFS_PREFIX")?; + let access_key = env_nonempty("GPUCACHE_RUSTFS_ACCESS_KEY")?; + let secret = env_nonempty("GPUCACHE_RUSTFS_SECRET_KEY")?; + let region = + env_nonempty("GPUCACHE_RUSTFS_REGION").unwrap_or_else(|| "us-east-1".to_string()); + if !key_ok(&bucket) || !key_ok(&prefix) || !key_ok(®ion) { + return None; + } + Some(Self { + endpoint, + bucket, + prefix, + region, + access_key, + secret, + }) + } +} + +fn env_nonempty(name: &str) -> Option { + let value = std::env::var(name).ok()?; + if value.is_empty() { + None + } else { + Some(value) + } +} + +struct HttpStore { + host: String, + port: u16, + bucket: String, + region: String, + access_key: String, + secret: String, + prefix: String, +} + +impl HttpStore { + fn new(cfg: &LiveConfig) -> Result { + let (host, port) = parse_http(&cfg.endpoint)?; + let prefix = format!( + "{}/probe-{}-{}", + cfg.prefix.trim_matches('/'), + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ); + if !key_ok(&prefix) { + return Err(CacheError::Malformed { reason: "cold key" }); + } + Ok(Self { + host, + port, + bucket: cfg.bucket.clone(), + region: cfg.region.clone(), + access_key: cfg.access_key.clone(), + secret: cfg.secret.clone(), + prefix, + }) + } + + fn full_key(&self, key: &str) -> Result { + let full = format!("{}/{}", self.prefix, key); + if !key_ok(&full) { + return Err(CacheError::Malformed { reason: "cold key" }); + } + Ok(full) + } +} + +impl ObjectStore for HttpStore { + fn put(&mut self, key: &str, body: &[u8]) -> Result<(), CacheError> { + let key = self.full_key(key)?; + let status = request(self, "PUT", &key, body, None, false)?; + if !(200..300).contains(&status.code) { + return Err(CacheError::Storage { + reason: "object store", + }); + } + Ok(()) + } + + fn head(&self, key: &str) -> Result { + let key = self.full_key(key)?; + let status = request(self, "HEAD", &key, b"", None, false)?; + if status.code == 404 { + return Err(CacheError::Storage { + reason: "object missing", + }); + } + if !(200..300).contains(&status.code) { + return Err(CacheError::Storage { + reason: "object store", + }); + } + let fetched = request(self, "GET", &key, b"", None, true)?; + if !(200..300).contains(&fetched.code) { + return Err(CacheError::Storage { + reason: "object store", + }); + } + if let Some(len) = status.content_length { + if len != fetched.body.len() as u64 { + return Err(CacheError::LengthMismatch { + expected: len, + actual: fetched.body.len() as u64, + }); + } + } + Ok(ObjectMeta { + len: fetched.body.len() as u64, + checksum: content_checksum(&fetched.body), + }) + } + + fn range_get(&self, key: &str, offset: u64, length: u64) -> Result, CacheError> { + let key = self.full_key(key)?; + let end = offset.saturating_add(length).saturating_sub(1); + let header = format!("bytes={offset}-{end}"); + let status = request(self, "GET", &key, b"", Some(&header), true)?; + if !(200..300).contains(&status.code) { + return Err(CacheError::Storage { + reason: "object store", + }); + } + if status.body.len() as u64 != length { + return Err(CacheError::LengthMismatch { + expected: length, + actual: status.body.len() as u64, + }); + } + Ok(status.body) + } + + fn delete(&mut self, key: &str) -> Result<(), CacheError> { + let key = self.full_key(key)?; + let status = request(self, "DELETE", &key, b"", None, false)?; + if status.code == 404 || (200..300).contains(&status.code) { + return Ok(()); + } + Err(CacheError::Storage { + reason: "object store", + }) + } +} + +struct HttpStatus { + code: u16, + content_length: Option, + body: Vec, +} + +fn request( + store: &HttpStore, + method: &str, + key: &str, + body: &[u8], + range: Option<&str>, + read_body: bool, +) -> Result { + let uri = format!("/{}/{}", store.bucket, key); + let payload_hash = hex(&sha256(body)); + let unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| CacheError::Storage { + reason: "object store", + })? + .as_secs(); + let (date_stamp, amz_date) = format_amz_time(unix); + let host_header = if store.port == 80 { + store.host.clone() + } else { + format!("{}:{}", store.host, store.port) + }; + let headers = [ + ("host", host_header.as_str()), + ("x-amz-content-sha256", payload_hash.as_str()), + ("x-amz-date", amz_date.as_str()), + ]; + let auth = authorization(&SignInput { + method, + canonical_uri: &uri, + canonical_query: "", + headers: &headers, + payload_hash_hex: &payload_hash, + amz_date: &amz_date, + date_stamp: &date_stamp, + region: &store.region, + service: "s3", + access_key: &store.access_key, + secret: &store.secret, + }); + let mut text = format!( + "{method} {uri} HTTP/1.1\r\nHost: {host_header}\r\nx-amz-content-sha256: {payload_hash}\r\nx-amz-date: {amz_date}\r\nAuthorization: {auth}\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ); + if let Some(range) = range { + text.push_str("Range: "); + text.push_str(range); + text.push_str("\r\n"); + } + text.push_str("\r\n"); + let mut stream = + TcpStream::connect((store.host.as_str(), store.port)).map_err(|_| CacheError::Storage { + reason: "object store", + })?; + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .map_err(|_| CacheError::Storage { + reason: "object store", + })?; + stream + .set_write_timeout(Some(Duration::from_secs(3))) + .map_err(|_| CacheError::Storage { + reason: "object store", + })?; + stream + .write_all(text.as_bytes()) + .map_err(|_| CacheError::Storage { + reason: "object store", + })?; + if !body.is_empty() { + stream.write_all(body).map_err(|_| CacheError::Storage { + reason: "object store", + })?; + } + let mut raw = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + if raw.len().saturating_add(n) > 8 * 1024 * 1024 { + return Err(CacheError::ObjectTooLarge { + limit: 8 * 1024 * 1024, + actual: raw.len() as u64, + }); + } + raw.extend_from_slice(&chunk[..n]); + } + Err(err) + if err.kind() == std::io::ErrorKind::WouldBlock + || err.kind() == std::io::ErrorKind::TimedOut => + { + break; + } + Err(_) => { + return Err(CacheError::Storage { + reason: "object store", + }) + } + } + } + parse_response(&raw, read_body) +} + +fn parse_response(raw: &[u8], read_body: bool) -> Result { + let text = std::str::from_utf8(raw).map_err(|_| CacheError::Storage { + reason: "object store", + })?; + let (head, body) = text.split_once("\r\n\r\n").unwrap_or((text, "")); + let mut lines = head.lines(); + let status = lines.next().ok_or(CacheError::Storage { + reason: "object store", + })?; + let code = status + .split_whitespace() + .nth(1) + .and_then(|code| code.parse::().ok()) + .ok_or(CacheError::Storage { + reason: "object store", + })?; + let mut content_length = None; + for line in lines { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + if name.eq_ignore_ascii_case("content-length") { + content_length = value.trim().parse::().ok(); + } + } + let body = if read_body { + body.as_bytes().to_vec() + } else { + Vec::new() + }; + Ok(HttpStatus { + code, + content_length, + body, + }) +} + +fn parse_http(endpoint: &str) -> Result<(String, u16), CacheError> { + let rest = endpoint + .strip_prefix("http://") + .ok_or(CacheError::Malformed { reason: "cold key" })?; + if rest.contains('/') || rest.is_empty() { + return Err(CacheError::Malformed { reason: "cold key" }); + } + if let Some((host, port)) = rest.rsplit_once(':') { + let port = port + .parse::() + .map_err(|_| CacheError::Malformed { reason: "cold key" })?; + if host.is_empty() { + return Err(CacheError::Malformed { reason: "cold key" }); + } + Ok((host.to_string(), port)) + } else { + Ok((rest.to_string(), 80)) + } +} + +fn key_ok(key: &str) -> bool { + !key.is_empty() + && key.len() <= 512 + && key + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-' || b == b'/') + && !key.contains("//") + && !key.starts_with('/') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_endpoint_is_not_run_hardware() { + if std::env::var_os("GPUCACHE_RUSTFS_ENDPOINT").is_some() { + return; + } + let report = live_probe(); + assert_eq!( + report.status, + ProbeStatus::NotRunHardware { + reason: "no authorized RustFS endpoint", + } + ); + } +} diff --git a/crates/cold/src/lib.rs b/crates/cold/src/lib.rs new file mode 100644 index 0000000..eae6265 --- /dev/null +++ b/crates/cold/src/lib.rs @@ -0,0 +1,19 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 +//! Optional G4 cold tier in front of RustFS. +//! +//! The hot TCP path does not call this crate. A miss still means the engine +//! recomputes. Without an authorized RustFS endpoint the live probe is +//! `NOT_RUN_HARDWARE`; the in-process mock still checks Put, Head, RangeGet, +//! and Delete plus object packing. + +#![forbid(unsafe_code)] + +mod http; +mod mock; +mod pack; +mod sigv4; + +pub use http::{live_probe, ProbeReport, ProbeStatus}; +pub use mock::MockObjectStore; +pub use pack::{contract_smoke, pack_blocks, ObjectMeta, ObjectStore, PackedBlock, PayloadRange}; diff --git a/crates/cold/src/mock.rs b/crates/cold/src/mock.rs new file mode 100644 index 0000000..fff9001 --- /dev/null +++ b/crates/cold/src/mock.rs @@ -0,0 +1,58 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeMap; + +use gpucache_core_types::{content_checksum, CacheError}; + +use crate::pack::{ObjectMeta, ObjectStore}; + +/// In-memory stand-in for RustFS. A passing mock is not a hardware run. +#[derive(Clone, Debug, Default)] +pub struct MockObjectStore { + objects: BTreeMap>, +} + +impl ObjectStore for MockObjectStore { + fn put(&mut self, key: &str, body: &[u8]) -> Result<(), CacheError> { + self.objects.insert(key.to_string(), body.to_vec()); + Ok(()) + } + + fn head(&self, key: &str) -> Result { + let body = self.objects.get(key).ok_or(CacheError::Storage { + reason: "object missing", + })?; + Ok(ObjectMeta { + len: body.len() as u64, + checksum: content_checksum(body), + }) + } + + fn range_get(&self, key: &str, offset: u64, length: u64) -> Result, CacheError> { + let body = self.objects.get(key).ok_or(CacheError::Storage { + reason: "object missing", + })?; + let start = usize::try_from(offset).map_err(|_| CacheError::Malformed { + reason: "cold range", + })?; + let len = usize::try_from(length).map_err(|_| CacheError::Malformed { + reason: "cold range", + })?; + let end = start.checked_add(len).ok_or(CacheError::Malformed { + reason: "cold range", + })?; + if end > body.len() { + return Err(CacheError::LengthMismatch { + expected: body.len() as u64, + actual: end as u64, + }); + } + Ok(body[start..end].to_vec()) + } + + fn delete(&mut self, key: &str) -> Result<(), CacheError> { + self.objects.remove(key); + Ok(()) + } +} diff --git a/crates/cold/src/pack.rs b/crates/cold/src/pack.rs new file mode 100644 index 0000000..e06198b --- /dev/null +++ b/crates/cold/src/pack.rs @@ -0,0 +1,219 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use gpucache_core_types::{ + content_checksum, verify_checksum, ByteReader, ByteWriter, CacheError, CacheIdentity, Checksum, + HARD_BYTES_CAP, +}; + +const OBJECT_MAGIC: &[u8; 4] = b"GPKG"; +const MANIFEST_MAGIC: &[u8; 4] = b"GPMF"; +const VERSION: u16 = 1; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ObjectMeta { + pub len: u64, + pub checksum: Checksum, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackedBlock { + pub generation: u64, + pub identity: CacheIdentity, + pub checksum: Checksum, + pub payload: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PayloadRange { + pub generation: u64, + pub offset: u64, + pub length: u64, + pub checksum: Checksum, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pack { + pub object_key: String, + pub manifest_key: String, + pub object_bytes: Vec, + pub manifest_bytes: Vec, + pub ranges: Vec, +} + +pub trait ObjectStore { + fn put(&mut self, key: &str, body: &[u8]) -> Result<(), CacheError>; + fn head(&self, key: &str) -> Result; + fn range_get(&self, key: &str, offset: u64, length: u64) -> Result, CacheError>; + fn delete(&mut self, key: &str) -> Result<(), CacheError>; +} + +pub fn pack_blocks(namespace: &str, blocks: &[PackedBlock]) -> Result { + if blocks.is_empty() || blocks.len() > 1024 { + return Err(CacheError::Malformed { + reason: "cold pack", + }); + } + if !key_ok(namespace) { + return Err(CacheError::Malformed { reason: "cold key" }); + } + let mut records = Vec::with_capacity(blocks.len()); + for block in blocks { + verify_checksum(&block.payload, block.checksum)?; + if block.payload.len() > HARD_BYTES_CAP { + return Err(CacheError::ObjectTooLarge { + limit: HARD_BYTES_CAP as u64, + actual: block.payload.len() as u64, + }); + } + records.push((block, block.identity.to_bytes()?)); + } + let mut object = ByteWriter::with_capacity(64); + object.raw(OBJECT_MAGIC); + object.u16(VERSION); + object.u32(u32::try_from(records.len()).unwrap_or(u32::MAX)); + let mut ranges = Vec::with_capacity(records.len()); + for (block, identity) in &records { + object.u64(block.generation); + object.raw(&block.checksum.0); + object.bytes(identity)?; + let offset = object.len() + 4; + object.bytes(&block.payload)?; + ranges.push(PayloadRange { + generation: block.generation, + offset: offset as u64, + length: block.payload.len() as u64, + checksum: block.checksum, + }); + } + let object_bytes = object.finish(); + let object_checksum = content_checksum(&object_bytes); + let object_key = format!("{namespace}/object"); + let manifest_key = format!("{namespace}/manifest"); + let mut manifest = ByteWriter::with_capacity(64); + manifest.raw(MANIFEST_MAGIC); + manifest.u16(VERSION); + manifest.raw(&object_checksum.0); + manifest.bytes(object_key.as_bytes())?; + manifest.u32(u32::try_from(ranges.len()).unwrap_or(u32::MAX)); + for range in &ranges { + manifest.u64(range.generation); + manifest.u64(range.offset); + manifest.u64(range.length); + manifest.raw(&range.checksum.0); + } + Ok(Pack { + object_key, + manifest_key, + object_bytes, + manifest_bytes: manifest.finish(), + ranges, + }) +} + +/// Put, Head, RangeGet, and Delete against a packed object. This is the contract +/// smoke. It does not talk to a network unless `store` does. +pub fn contract_smoke(store: &mut dyn ObjectStore) -> Result<(), CacheError> { + let blocks = sample_blocks()?; + let pack = pack_blocks("smoke", &blocks)?; + store.put(&pack.object_key, &pack.object_bytes)?; + store.put(&pack.manifest_key, &pack.manifest_bytes)?; + let object_meta = store.head(&pack.object_key)?; + if object_meta.len != pack.object_bytes.len() as u64 + || object_meta.checksum != content_checksum(&pack.object_bytes) + { + return Err(CacheError::ChecksumMismatch); + } + let manifest_meta = store.head(&pack.manifest_key)?; + if manifest_meta.checksum != content_checksum(&pack.manifest_bytes) { + return Err(CacheError::ChecksumMismatch); + } + let decoded = decode_manifest(&pack.manifest_bytes)?; + if decoded.len() != pack.ranges.len() { + return Err(CacheError::Malformed { + reason: "cold manifest", + }); + } + let range = &decoded[1]; + let got = store.range_get(&pack.object_key, range.offset, range.length)?; + verify_checksum(&got, range.checksum)?; + if got != blocks[1].payload { + return Err(CacheError::ChecksumMismatch); + } + store.delete(&pack.object_key)?; + store.delete(&pack.manifest_key)?; + if store.head(&pack.object_key).is_ok() { + return Err(CacheError::Storage { + reason: "object remains", + }); + } + Ok(()) +} + +fn sample_blocks() -> Result, CacheError> { + let mut out = Vec::new(); + for (generation, byte) in [(1u64, 1u8), (2, 2u8)] { + let payload = vec![byte; 8]; + let mut identity = gpucache_core_types::synthetic_identity(byte, 1); + identity.prefix_chain[0].0[0] = byte; + out.push(PackedBlock { + generation, + checksum: content_checksum(&payload), + payload, + identity, + }); + } + Ok(out) +} + +fn decode_manifest(bytes: &[u8]) -> Result, CacheError> { + let mut r = ByteReader::new(bytes); + let magic = r.raw(4)?; + if magic != MANIFEST_MAGIC || r.u16()? != VERSION { + return Err(CacheError::Storage { + reason: "store corrupt", + }); + } + let _object_checksum = r.array32()?; + let _object_key = r.bytes(1024)?; + let n = r.u32()? as usize; + if n == 0 || n > 1024 { + return Err(CacheError::Malformed { + reason: "cold manifest", + }); + } + let mut ranges = Vec::with_capacity(n); + for _ in 0..n { + ranges.push(PayloadRange { + generation: r.u64()?, + offset: r.u64()?, + length: r.u64()?, + checksum: Checksum(r.array32()?), + }); + } + r.finish()?; + Ok(ranges) +} + +fn key_ok(key: &str) -> bool { + !key.is_empty() + && key.len() <= 256 + && key + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-' || b == b'/') + && !key.contains("//") + && !key.starts_with('/') + && !key.ends_with('/') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MockObjectStore; + + #[test] + fn mock_put_head_range_get_delete() { + let mut store = MockObjectStore::default(); + contract_smoke(&mut store).unwrap(); + } +} diff --git a/crates/cold/src/sigv4.rs b/crates/cold/src/sigv4.rs new file mode 100644 index 0000000..0d0afa2 --- /dev/null +++ b/crates/cold/src/sigv4.rs @@ -0,0 +1,181 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use sha2::{Digest, Sha256}; + +pub struct SignInput<'a> { + pub method: &'a str, + pub canonical_uri: &'a str, + pub canonical_query: &'a str, + pub headers: &'a [(&'a str, &'a str)], + pub payload_hash_hex: &'a str, + pub amz_date: &'a str, + pub date_stamp: &'a str, + pub region: &'a str, + pub service: &'a str, + pub access_key: &'a str, + pub secret: &'a str, +} + +pub fn authorization(input: &SignInput<'_>) -> String { + let canonical = canonical_request(input); + let scope = format!( + "{}/{}/{}/aws4_request", + input.date_stamp, input.region, input.service + ); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + input.amz_date, + scope, + hex(&sha256(canonical.as_bytes())) + ); + let key = signing_key(input.secret, input.date_stamp, input.region, input.service); + let signature = hex(&hmac_sha256(&key, string_to_sign.as_bytes())); + let signed = signed_headers(input.headers); + format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + input.access_key, scope, signed, signature + ) +} + +fn canonical_request(input: &SignInput<'_>) -> String { + let mut headers = String::new(); + for (name, value) in input.headers { + headers.push_str(name); + headers.push(':'); + headers.push_str(value.trim()); + headers.push('\n'); + } + format!( + "{}\n{}\n{}\n{}\n{}\n{}", + input.method, + input.canonical_uri, + input.canonical_query, + headers, + signed_headers(input.headers), + input.payload_hash_hex + ) +} + +fn signed_headers(headers: &[(&str, &str)]) -> String { + headers + .iter() + .map(|(name, _)| *name) + .collect::>() + .join(";") +} + +fn signing_key(secret: &str, date: &str, region: &str, service: &str) -> [u8; 32] { + let secret = format!("AWS4{secret}"); + let k_date = hmac_sha256(secret.as_bytes(), date.as_bytes()); + let k_region = hmac_sha256(&k_date, region.as_bytes()); + let k_service = hmac_sha256(&k_region, service.as_bytes()); + hmac_sha256(&k_service, b"aws4_request") +} + +pub fn sha256(data: &[u8]) -> [u8; 32] { + let dig = Sha256::digest(data); + let mut out = [0u8; 32]; + out.copy_from_slice(&dig); + out +} + +pub fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(DIGITS[(byte >> 4) as usize] as char); + out.push(DIGITS[(byte & 0x0f) as usize] as char); + } + out +} + +fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] { + let mut k = [0u8; 64]; + if key.len() > 64 { + let hashed = sha256(key); + k[..32].copy_from_slice(&hashed); + } else { + k[..key.len()].copy_from_slice(key); + } + let mut ipad = [0u8; 64]; + let mut opad = [0u8; 64]; + for i in 0..64 { + ipad[i] = k[i] ^ 0x36; + opad[i] = k[i] ^ 0x5c; + } + let mut inner = Vec::with_capacity(64 + msg.len()); + inner.extend_from_slice(&ipad); + inner.extend_from_slice(msg); + let inner_hash = sha256(&inner); + let mut outer = Vec::with_capacity(96); + outer.extend_from_slice(&opad); + outer.extend_from_slice(&inner_hash); + sha256(&outer) +} + +pub fn format_amz_time(unix_secs: u64) -> (String, String) { + let days = (unix_secs / 86_400) as i64; + let tod = unix_secs % 86_400; + let hour = tod / 3600; + let minute = (tod % 3600) / 60; + let second = tod % 60; + let (year, month, day) = civil_from_days(days); + let date = format!("{year:04}{month:02}{day:02}"); + let stamp = format!("{date}T{hour:02}{minute:02}{second:02}Z"); + (date, stamp) +} + +fn civil_from_days(days_since_epoch: i64) -> (i32, u32, u32) { + let z = days_since_epoch + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = (mp + if mp < 10 { 3 } else { 0 }) as u32 - if mp < 10 { 0 } else { 9 }; + let year = (if month <= 2 { y + 1 } else { y }) as i32; + (year, month, day) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aws_sigv4_example_signature() { + let headers = [ + ( + "content-type", + "application/x-www-form-urlencoded; charset=utf-8", + ), + ("host", "iam.amazonaws.com"), + ("x-amz-date", "20150830T123600Z"), + ]; + let header = authorization(&SignInput { + method: "GET", + canonical_uri: "/", + canonical_query: "Action=ListUsers&Version=2010-05-08", + headers: &headers, + payload_hash_hex: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + amz_date: "20150830T123600Z", + date_stamp: "20150830", + region: "us-east-1", + service: "iam", + access_key: "AKIDEXAMPLE", + secret: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + }); + assert!(header.ends_with( + "Signature=5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7" + )); + } + + #[test] + fn unix_time_formats_the_sigv4_example_instant() { + let (date, stamp) = format_amz_time(1_440_938_160); + assert_eq!(date, "20150830"); + assert_eq!(stamp, "20150830T123600Z"); + } +} diff --git a/crates/core-types/src/codec.rs b/crates/core-types/src/codec.rs index ddc7397..e546f83 100644 --- a/crates/core-types/src/codec.rs +++ b/crates/core-types/src/codec.rs @@ -63,6 +63,14 @@ impl ByteWriter { pub fn finish(self) -> Vec { self.buf } + + pub fn len(&self) -> usize { + self.buf.len() + } + + pub fn is_empty(&self) -> bool { + self.buf.is_empty() + } } impl Default for ByteWriter { diff --git a/crates/core-types/src/error.rs b/crates/core-types/src/error.rs index aaa360b..6ac185d 100644 --- a/crates/core-types/src/error.rs +++ b/crates/core-types/src/error.rs @@ -73,6 +73,10 @@ pub enum CacheError { IdentityInvalid { reason: &'static str, }, + /// Local storage or transport failure. The reason is a static label, never a path or payload. + Storage { + reason: &'static str, + }, } impl fmt::Display for CacheError { @@ -110,6 +114,7 @@ impl fmt::Display for CacheError { write!(f, "epoch mismatch store {store} request {request}") } Self::IdentityInvalid { reason } => write!(f, "identity invalid: {reason}"), + Self::Storage { reason } => write!(f, "storage: {reason}"), } } } diff --git a/crates/protocol/src/api.rs b/crates/protocol/src/api.rs index 94a0812..855019a 100644 --- a/crates/protocol/src/api.rs +++ b/crates/protocol/src/api.rs @@ -102,17 +102,55 @@ pub enum MissReason { #[derive(Clone, Debug, PartialEq, Eq)] pub enum LookupOutcome { + /// Exact physical-key match. The bytes cover the whole requested block. Hit { generation: u64, length: u64, checksum: Checksum, valid_length: u32, }, + /// Whole stored block whose prefix chain is a strict prefix of the query. + /// + /// This is not a sliced block. `matched_prefix_blocks` is the stored chain + /// length. `Get` must use [`identity_for_prefix_hit`], not the longer query. + PrefixHit { + generation: u64, + length: u64, + checksum: Checksum, + valid_length: u32, + matched_prefix_blocks: u32, + }, Miss { reason: MissReason, }, } +/// Rebuild the stored identity for a [`LookupOutcome::PrefixHit`]. +/// +/// The result keeps the query layout and truncates the hash chain. It does not +/// invent bytes for the suffix, and it rejects a chain that is not strictly shorter. +pub fn identity_for_prefix_hit( + query: &CacheIdentity, + valid_length: u32, + matched_prefix_blocks: u32, +) -> Result { + let n = matched_prefix_blocks as usize; + if matched_prefix_blocks == 0 || n >= query.prefix_chain.len() { + return Err(CacheError::Malformed { + reason: "prefix hit alignment", + }); + } + if valid_length == 0 || valid_length > query.block_token_count { + return Err(CacheError::IdentityInvalid { + reason: "valid length", + }); + } + let mut matched = query.clone(); + matched.prefix_chain.truncate(n); + matched.valid_length = valid_length; + Ok(matched) +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ReserveOutcome { pub generation: u64, diff --git a/crates/protocol/src/codec.rs b/crates/protocol/src/codec.rs index a876378..f485338 100644 --- a/crates/protocol/src/codec.rs +++ b/crates/protocol/src/codec.rs @@ -17,14 +17,14 @@ pub const WIRE_MAGIC: &[u8; 4] = b"GCK1"; pub const EXPORT_MAGIC: &[u8; 4] = b"GEXP"; pub const EXPORT_VERSION: u16 = 1; -const OP_LOOKUP: u16 = 1; -const OP_RESERVE: u16 = 2; -const OP_COMMIT: u16 = 3; -const OP_GET: u16 = 4; -const OP_RELEASE: u16 = 5; -const OP_INVALIDATE: u16 = 6; -const OP_HELLO: u16 = 7; -const OP_HELLO_ACK: u16 = 8; +pub const OP_LOOKUP: u16 = 1; +pub const OP_RESERVE: u16 = 2; +pub const OP_COMMIT: u16 = 3; +pub const OP_GET: u16 = 4; +pub const OP_RELEASE: u16 = 5; +pub const OP_INVALIDATE: u16 = 6; +pub const OP_HELLO: u16 = 7; +pub const OP_HELLO_ACK: u16 = 8; const MAX_ITEMS: usize = 1024; @@ -53,7 +53,7 @@ pub fn encode_request(auth: &AuthContext, body: &RequestBody) -> Result, let mut w = ByteWriter::with_capacity(128); w.raw(WIRE_MAGIC); w.u16(WIRE_VERSION); - w.u16(op_of(body)); + w.u16(request_op(body)); write_auth(&mut w, auth)?; w.u32( u32::try_from(body.len()).map_err(|_| CacheError::Malformed { @@ -331,7 +331,7 @@ pub fn decode_export(bytes: &[u8]) -> Result { }) } -fn op_of(body: &RequestBody) -> u16 { +pub fn request_op(body: &RequestBody) -> u16 { match body { RequestBody::Lookup(_) => OP_LOOKUP, RequestBody::ReservePut(_) => OP_RESERVE, diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 78b39ce..2f42635 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 //! Batch messages, deadlines, and wire-version negotiation for GPUCache. //! -//! V1 speaks this contract over an in-process API. A TCP listener is a later -//! milestone. Nothing here depends on a GPU, RDMA stack, or DPU. +//! V1 A defined the request frames. V1 B adds reply frames and a TCP listener +//! in `gpucache-transport`. Nothing here depends on a GPU, RDMA stack, or DPU. #![forbid(unsafe_code)] @@ -11,17 +11,21 @@ mod api; mod auth; mod codec; mod negotiate; +mod reply; pub use api::{ - BatchReply, CommitItem, CommitOutcome, GetHit, GetItem, InvalidateItem, InvalidateOutcome, - ItemHeader, ItemResult, LookupItem, LookupOutcome, MissReason, ReleaseCancelItem, - ReleaseOutcome, RequestBody, ReserveOutcome, ReservePutItem, + identity_for_prefix_hit, BatchReply, CommitItem, CommitOutcome, GetHit, GetItem, + InvalidateItem, InvalidateOutcome, ItemHeader, ItemResult, LookupItem, LookupOutcome, + MissReason, ReleaseCancelItem, ReleaseOutcome, RequestBody, ReserveOutcome, ReservePutItem, }; pub use auth::{fingerprints_equal, AuthContext, TenantPolicy}; pub use codec::{ decode_export, decode_hello, decode_hello_ack, decode_request, encode_export, encode_hello, - encode_hello_ack, encode_request, ensure_deadline, CommittedExport, EXPORT_VERSION, WIRE_MAGIC, + encode_hello_ack, encode_request, ensure_deadline, request_op, CommittedExport, EXPORT_VERSION, + OP_COMMIT, OP_GET, OP_HELLO, OP_HELLO_ACK, OP_INVALIDATE, OP_LOOKUP, OP_RELEASE, OP_RESERVE, + WIRE_MAGIC, }; pub use negotiate::{negotiate, HELLO_OP, WIRE_VERSION}; +pub use reply::{decode_reply, encode_reply, ReplyBody, REPLY_MAGIC}; pub use gpucache_core_types::Attestation; diff --git a/crates/protocol/src/reply.rs b/crates/protocol/src/reply.rs new file mode 100644 index 0000000..4983321 --- /dev/null +++ b/crates/protocol/src/reply.rs @@ -0,0 +1,629 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use gpucache_core_types::{ + BlockState, ByteReader, ByteWriter, CacheError, Checksum, QuotaKind, RequestId, HARD_BYTES_CAP, +}; + +use crate::api::{ + BatchReply, CommitOutcome, GetHit, InvalidateOutcome, ItemResult, LookupOutcome, MissReason, + ReleaseOutcome, ReserveOutcome, +}; +use crate::codec::{OP_COMMIT, OP_GET, OP_INVALIDATE, OP_LOOKUP, OP_RELEASE, OP_RESERVE}; +use crate::negotiate::WIRE_VERSION; + +pub const REPLY_MAGIC: &[u8; 4] = b"GCKR"; +const OP_FAILED: u16 = 0; + +/// Encoded store reply. Request frames stay `GCK1`; this is the response side. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReplyBody { + Lookup(BatchReply), + Reserve(BatchReply), + Commit(BatchReply), + Get(BatchReply), + Release(BatchReply), + Invalidate(BatchReply), + Failed { + op: u16, + store_epoch: u64, + error: CacheError, + }, +} + +pub fn encode_reply(body: &ReplyBody) -> Result, CacheError> { + let mut w = ByteWriter::with_capacity(64); + w.raw(REPLY_MAGIC); + w.u16(WIRE_VERSION); + match body { + ReplyBody::Failed { + op, + store_epoch, + error, + } => { + w.u16(OP_FAILED); + w.u16(*op); + w.u64(*store_epoch); + write_error(&mut w, error); + } + ReplyBody::Lookup(batch) => { + w.u16(OP_LOOKUP); + write_batch(&mut w, batch, write_lookup)?; + } + ReplyBody::Reserve(batch) => { + w.u16(OP_RESERVE); + write_batch(&mut w, batch, write_reserve)?; + } + ReplyBody::Commit(batch) => { + w.u16(OP_COMMIT); + write_batch(&mut w, batch, write_commit)?; + } + ReplyBody::Get(batch) => { + w.u16(OP_GET); + write_batch(&mut w, batch, write_get)?; + } + ReplyBody::Release(batch) => { + w.u16(OP_RELEASE); + write_batch(&mut w, batch, write_release)?; + } + ReplyBody::Invalidate(batch) => { + w.u16(OP_INVALIDATE); + write_batch(&mut w, batch, write_invalidate)?; + } + } + Ok(w.finish()) +} + +pub fn decode_reply(bytes: &[u8]) -> Result { + let mut r = ByteReader::new(bytes); + let magic = r.raw(4)?; + if magic != REPLY_MAGIC { + return Err(CacheError::Malformed { reason: "magic" }); + } + let version = r.u16()?; + if version != WIRE_VERSION { + return Err(CacheError::WireVersionUnsupported { + local: WIRE_VERSION, + remote: version, + }); + } + let op = r.u16()?; + let body = match op { + OP_FAILED => { + let failed_op = r.u16()?; + let store_epoch = r.u64()?; + let error = read_error(&mut r)?; + ReplyBody::Failed { + op: failed_op, + store_epoch, + error, + } + } + OP_LOOKUP => ReplyBody::Lookup(read_batch(&mut r, read_lookup)?), + OP_RESERVE => ReplyBody::Reserve(read_batch(&mut r, read_reserve)?), + OP_COMMIT => ReplyBody::Commit(read_batch(&mut r, read_commit)?), + OP_GET => ReplyBody::Get(read_batch(&mut r, read_get)?), + OP_RELEASE => ReplyBody::Release(read_batch(&mut r, read_release)?), + OP_INVALIDATE => ReplyBody::Invalidate(read_batch(&mut r, read_invalidate)?), + _ => { + return Err(CacheError::Malformed { + reason: "unknown op", + }) + } + }; + r.finish()?; + Ok(body) +} + +fn write_batch( + w: &mut ByteWriter, + batch: &BatchReply, + write_ok: fn(&mut ByteWriter, &T) -> Result<(), CacheError>, +) -> Result<(), CacheError> { + w.u64(batch.store_epoch); + let n = u32::try_from(batch.items.len()).map_err(|_| CacheError::Malformed { + reason: "batch length", + })?; + w.u32(n); + for item in &batch.items { + w.raw(&item.request_id.0); + match &item.body { + Ok(value) => { + w.u8(1); + write_ok(w, value)?; + } + Err(err) => { + w.u8(0); + write_error(w, err); + } + } + } + Ok(()) +} + +fn read_batch( + r: &mut ByteReader<'_>, + read_ok: fn(&mut ByteReader<'_>) -> Result, +) -> Result, CacheError> { + let store_epoch = r.u64()?; + let n = r.u32()? as usize; + if n > 1024 { + return Err(CacheError::QuotaExceeded { + kind: QuotaKind::BatchItems, + }); + } + let mut items = Vec::with_capacity(n); + for _ in 0..n { + let mut id = [0u8; 16]; + id.copy_from_slice(r.raw(16)?); + let request_id = RequestId::new(id)?; + let body = match r.u8()? { + 1 => Ok(read_ok(r)?), + 0 => Err(read_error(r)?), + _ => { + return Err(CacheError::Malformed { + reason: "item status", + }) + } + }; + items.push(ItemResult { request_id, body }); + } + Ok(BatchReply { store_epoch, items }) +} + +fn write_lookup(w: &mut ByteWriter, value: &LookupOutcome) -> Result<(), CacheError> { + match value { + LookupOutcome::Hit { + generation, + length, + checksum, + valid_length, + } => { + w.u8(1); + w.u64(*generation); + w.u64(*length); + w.raw(&checksum.0); + w.u32(*valid_length); + } + LookupOutcome::PrefixHit { + generation, + length, + checksum, + valid_length, + matched_prefix_blocks, + } => { + w.u8(2); + w.u64(*generation); + w.u64(*length); + w.raw(&checksum.0); + w.u32(*valid_length); + w.u32(*matched_prefix_blocks); + } + LookupOutcome::Miss { reason } => { + w.u8(3); + w.u8(miss_byte(*reason)); + } + } + Ok(()) +} + +fn read_lookup(r: &mut ByteReader<'_>) -> Result { + match r.u8()? { + 1 => Ok(LookupOutcome::Hit { + generation: r.u64()?, + length: r.u64()?, + checksum: Checksum(r.array32()?), + valid_length: r.u32()?, + }), + 2 => Ok(LookupOutcome::PrefixHit { + generation: r.u64()?, + length: r.u64()?, + checksum: Checksum(r.array32()?), + valid_length: r.u32()?, + matched_prefix_blocks: r.u32()?, + }), + 3 => Ok(LookupOutcome::Miss { + reason: miss_reason(r.u8()?)?, + }), + _ => Err(CacheError::Malformed { + reason: "lookup tag", + }), + } +} + +fn write_reserve(w: &mut ByteWriter, value: &ReserveOutcome) -> Result<(), CacheError> { + w.u64(value.generation); + w.u8(state_byte(value.state)); + match value.region_id { + None => w.u8(0), + Some(id) => { + w.u8(1); + w.u64(id); + } + } + w.u64(value.lease_deadline_ms); + Ok(()) +} + +fn read_reserve(r: &mut ByteReader<'_>) -> Result { + let generation = r.u64()?; + let state = read_state(r.u8()?)?; + let region_id = match r.u8()? { + 0 => None, + 1 => Some(r.u64()?), + _ => { + return Err(CacheError::Malformed { + reason: "optional u64", + }) + } + }; + Ok(ReserveOutcome { + generation, + state, + region_id, + lease_deadline_ms: r.u64()?, + }) +} + +fn write_commit(w: &mut ByteWriter, value: &CommitOutcome) -> Result<(), CacheError> { + w.u64(value.generation); + w.u64(value.length); + w.raw(&value.checksum.0); + Ok(()) +} + +fn read_commit(r: &mut ByteReader<'_>) -> Result { + Ok(CommitOutcome { + generation: r.u64()?, + length: r.u64()?, + checksum: Checksum(r.array32()?), + }) +} + +fn write_get(w: &mut ByteWriter, value: &GetHit) -> Result<(), CacheError> { + w.u64(value.generation); + w.u64(value.length); + w.raw(&value.checksum.0); + w.u64(value.region_id); + w.u64(value.lease_deadline_ms); + w.bytes(&value.payload)?; + Ok(()) +} + +fn read_get(r: &mut ByteReader<'_>) -> Result { + Ok(GetHit { + generation: r.u64()?, + length: r.u64()?, + checksum: Checksum(r.array32()?), + region_id: r.u64()?, + lease_deadline_ms: r.u64()?, + payload: r.bytes(HARD_BYTES_CAP)?.to_vec(), + }) +} + +fn write_release(w: &mut ByteWriter, value: &ReleaseOutcome) -> Result<(), CacheError> { + w.u64(value.generation); + w.u8(state_byte(value.state)); + w.u8(u8::from(value.quarantined)); + Ok(()) +} + +fn read_release(r: &mut ByteReader<'_>) -> Result { + Ok(ReleaseOutcome { + generation: r.u64()?, + state: read_state(r.u8()?)?, + quarantined: match r.u8()? { + 0 => false, + 1 => true, + _ => { + return Err(CacheError::Malformed { + reason: "quarantine flag", + }) + } + }, + }) +} + +fn write_invalidate(w: &mut ByteWriter, value: &InvalidateOutcome) -> Result<(), CacheError> { + w.u64(value.generation); + w.u8(state_byte(value.state)); + Ok(()) +} + +fn read_invalidate(r: &mut ByteReader<'_>) -> Result { + Ok(InvalidateOutcome { + generation: r.u64()?, + state: read_state(r.u8()?)?, + }) +} + +fn write_error(w: &mut ByteWriter, err: &CacheError) { + match err { + CacheError::InvalidTransition { from, .. } => { + w.u16(1); + w.u8(state_byte(*from)); + } + CacheError::NotReclaimable { from } => { + w.u16(2); + w.u8(state_byte(*from)); + } + CacheError::InUse { refs } => { + w.u16(3); + w.u32(*refs); + } + CacheError::FencingRejected { .. } => w.u16(4), + CacheError::ChecksumMismatch => w.u16(5), + CacheError::LengthMismatch { expected, actual } => { + w.u16(6); + w.u64(*expected); + w.u64(*actual); + } + CacheError::GenerationMismatch { expected, actual } => { + w.u16(7); + w.u64(*expected); + w.u64(*actual); + } + CacheError::UnknownGeneration => w.u16(8), + CacheError::IdempotencyConflict => w.u16(9), + CacheError::QuotaExceeded { kind } => { + w.u16(10); + w.u8(quota_byte(*kind)); + } + CacheError::ObjectTooLarge { limit, actual } => { + w.u16(11); + w.u64(*limit); + w.u64(*actual); + } + CacheError::Unauthorized => w.u16(12), + CacheError::TimedOut => w.u16(13), + CacheError::Cancelled => w.u16(14), + CacheError::Malformed { .. } => w.u16(15), + CacheError::WireVersionUnsupported { local, remote } => { + w.u16(16); + w.u16(*local); + w.u16(*remote); + } + CacheError::NotExposed { state } => { + w.u16(17); + w.u8(state_byte(*state)); + } + CacheError::Incompatible => w.u16(18), + CacheError::EpochMismatch { store, request } => { + w.u16(19); + w.u64(*store); + w.u64(*request); + } + CacheError::IdentityInvalid { .. } => w.u16(20), + CacheError::Storage { .. } => w.u16(21), + } +} + +fn read_error(r: &mut ByteReader<'_>) -> Result { + Ok(match r.u16()? { + 1 => CacheError::InvalidTransition { + from: read_state(r.u8()?)?, + event: "remote", + }, + 2 => CacheError::NotReclaimable { + from: read_state(r.u8()?)?, + }, + 3 => CacheError::InUse { refs: r.u32()? }, + 4 => CacheError::FencingRejected { reason: "remote" }, + 5 => CacheError::ChecksumMismatch, + 6 => CacheError::LengthMismatch { + expected: r.u64()?, + actual: r.u64()?, + }, + 7 => CacheError::GenerationMismatch { + expected: r.u64()?, + actual: r.u64()?, + }, + 8 => CacheError::UnknownGeneration, + 9 => CacheError::IdempotencyConflict, + 10 => CacheError::QuotaExceeded { + kind: read_quota(r.u8()?)?, + }, + 11 => CacheError::ObjectTooLarge { + limit: r.u64()?, + actual: r.u64()?, + }, + 12 => CacheError::Unauthorized, + 13 => CacheError::TimedOut, + 14 => CacheError::Cancelled, + 15 => CacheError::Malformed { reason: "remote" }, + 16 => CacheError::WireVersionUnsupported { + local: r.u16()?, + remote: r.u16()?, + }, + 17 => CacheError::NotExposed { + state: read_state(r.u8()?)?, + }, + 18 => CacheError::Incompatible, + 19 => CacheError::EpochMismatch { + store: r.u64()?, + request: r.u64()?, + }, + 20 => CacheError::IdentityInvalid { reason: "remote" }, + 21 => CacheError::Storage { reason: "remote" }, + _ => { + return Err(CacheError::Malformed { + reason: "error code", + }) + } + }) +} + +fn state_byte(state: BlockState) -> u8 { + match state { + BlockState::Absent => 0, + BlockState::Reserved => 1, + BlockState::Writing => 2, + BlockState::Committed => 3, + BlockState::Evicting => 4, + BlockState::Quarantined => 5, + } +} + +fn read_state(value: u8) -> Result { + Ok(match value { + 0 => BlockState::Absent, + 1 => BlockState::Reserved, + 2 => BlockState::Writing, + 3 => BlockState::Committed, + 4 => BlockState::Evicting, + 5 => BlockState::Quarantined, + _ => { + return Err(CacheError::Malformed { + reason: "block state", + }) + } + }) +} + +fn quota_byte(kind: QuotaKind) -> u8 { + match kind { + QuotaKind::InFlightRequests => 0, + QuotaKind::Registrations => 1, + QuotaKind::InFlightBytes => 2, + QuotaKind::RetainedBytes => 3, + QuotaKind::TenantRetainedBytes => 4, + QuotaKind::BatchItems => 5, + QuotaKind::IdempotencyTable => 6, + QuotaKind::PrefixLinks => 7, + } +} + +fn read_quota(value: u8) -> Result { + Ok(match value { + 0 => QuotaKind::InFlightRequests, + 1 => QuotaKind::Registrations, + 2 => QuotaKind::InFlightBytes, + 3 => QuotaKind::RetainedBytes, + 4 => QuotaKind::TenantRetainedBytes, + 5 => QuotaKind::BatchItems, + 6 => QuotaKind::IdempotencyTable, + 7 => QuotaKind::PrefixLinks, + _ => { + return Err(CacheError::Malformed { + reason: "quota kind", + }) + } + }) +} + +fn miss_byte(reason: MissReason) -> u8 { + match reason { + MissReason::Absent => 1, + MissReason::NotCommitted => 2, + MissReason::Incompatible => 3, + MissReason::Evicting => 4, + MissReason::Quarantined => 5, + MissReason::Expired => 6, + } +} + +fn miss_reason(value: u8) -> Result { + Ok(match value { + 1 => MissReason::Absent, + 2 => MissReason::NotCommitted, + 3 => MissReason::Incompatible, + 4 => MissReason::Evicting, + 5 => MissReason::Quarantined, + 6 => MissReason::Expired, + _ => { + return Err(CacheError::Malformed { + reason: "miss reason", + }) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpucache_core_types::{content_checksum, BlockState}; + + fn id(n: u128) -> RequestId { + RequestId::from_counter(n).unwrap() + } + + #[test] + fn reply_roundtrip_covers_hit_prefix_and_failure() { + let sum = content_checksum(b"abc"); + let lookup = ReplyBody::Lookup(BatchReply { + store_epoch: 4, + items: vec![ + ItemResult { + request_id: id(1), + body: Ok(LookupOutcome::Hit { + generation: 9, + length: 3, + checksum: sum, + valid_length: 16, + }), + }, + ItemResult { + request_id: id(2), + body: Ok(LookupOutcome::PrefixHit { + generation: 3, + length: 3, + checksum: sum, + valid_length: 16, + matched_prefix_blocks: 1, + }), + }, + ItemResult { + request_id: id(3), + body: Err(CacheError::Unauthorized), + }, + ], + }); + let bytes = encode_reply(&lookup).unwrap(); + assert_eq!(decode_reply(&bytes).unwrap(), lookup); + assert!(decode_reply(&bytes[..bytes.len() - 1]).is_err()); + + let failed = ReplyBody::Failed { + op: OP_GET, + store_epoch: 4, + error: CacheError::EpochMismatch { + store: 4, + request: 1, + }, + }; + let bytes = encode_reply(&failed).unwrap(); + assert_eq!(decode_reply(&bytes).unwrap(), failed); + + let got = ReplyBody::Get(BatchReply { + store_epoch: 4, + items: vec![ItemResult { + request_id: id(8), + body: Ok(GetHit { + generation: 9, + length: 3, + checksum: sum, + region_id: 2, + lease_deadline_ms: 50, + payload: b"abc".to_vec(), + }), + }], + }); + assert_eq!(decode_reply(&encode_reply(&got).unwrap()).unwrap(), got); + + let reserved = ReplyBody::Reserve(BatchReply { + store_epoch: 1, + items: vec![ItemResult { + request_id: id(4), + body: Ok(ReserveOutcome { + generation: 2, + state: BlockState::Writing, + region_id: Some(1), + lease_deadline_ms: 10, + }), + }], + }); + assert_eq!( + decode_reply(&encode_reply(&reserved).unwrap()).unwrap(), + reserved + ); + } +} diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 07b281b..99e9a2a 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true rust-version.workspace = true authors.workspace = true -description = "In-process MemoryStore for the GPUCache V1 A contract. No GPU or DPU dependencies." +description = "MemoryStore and file-backed store for the GPUCache V1 contract. No GPU or DPU dependencies." publish = false [dependencies] diff --git a/crates/storage/src/file.rs b/crates/storage/src/file.rs new file mode 100644 index 0000000..bb6def1 --- /dev/null +++ b/crates/storage/src/file.rs @@ -0,0 +1,611 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 +//! File-backed shared KV store. +//! +//! Bytes live in ordinary files under a caller-chosen directory. This module +//! does not open block devices, format disks, or issue NVMe admin commands. +//! A lost or corrupt file is a miss. There is no erasure coding and no replica. + +use std::collections::VecDeque; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; + +use gpucache_core_types::{ + verify_checksum, BlockState, ByteReader, ByteWriter, CacheError, QuotaKind, ResourceLimits, +}; +use gpucache_protocol::{ + AuthContext, BatchReply, CommitItem, CommitOutcome, GetHit, GetItem, InvalidateItem, + InvalidateOutcome, ItemResult, LookupItem, LookupOutcome, ReleaseCancelItem, ReleaseOutcome, + ReserveOutcome, ReservePutItem, TenantPolicy, +}; + +use crate::memory::{DurableBlock, MemoryStore}; + +const MANIFEST_MAGIC: &[u8; 4] = b"GMAN"; +const MANIFEST_VERSION: u16 = 1; +const BLOCK_MAGIC: &[u8; 4] = b"GBLK"; +const BLOCK_VERSION: u16 = 1; +const LRU_MAGIC: &[u8; 4] = b"GLRU"; +const LRU_VERSION: u16 = 1; + +/// Directory-backed store used by the V1 B TCP service. +/// +/// Admission evicts the least-recently-used unpinned `COMMITTED` block when a +/// tenant or global retained-byte quota would otherwise fail. Pinned, +/// quarantined, and in-flight blocks are not evicted. `MemoryStore` itself +/// still refuses admission without evicting; only this backend reclaims. +pub struct FileStore { + root: PathBuf, + inner: MemoryStore, + lru: VecDeque, + skipped_corrupt: u32, +} + +impl FileStore { + pub fn open( + path: impl AsRef, + limits: ResourceLimits, + policies: Vec, + ) -> Result { + if policies.is_empty() { + return Err(CacheError::Unauthorized); + } + limits.validate()?; + let root = prepare_root(path.as_ref())?; + let mut inner = MemoryStore::new(limits, policies.clone())?; + let manifest_existed = root.join("MANIFEST").is_file(); + let previous_epoch = read_manifest(&root)?.unwrap_or(1); + let epoch = if manifest_existed { + previous_epoch.saturating_add(1).max(1) + } else { + 1 + }; + inner.restore_epoch(epoch)?; + let (_loaded, skipped_corrupt, order) = load_blocks(&root, &policies, &mut inner)?; + let store = Self { + root, + inner, + lru: order, + skipped_corrupt, + }; + store.write_manifest()?; + store.write_lru()?; + Ok(store) + } + + pub fn path(&self) -> &Path { + &self.root + } + + pub fn epoch(&self) -> u64 { + self.inner.epoch() + } + + pub fn limits(&self) -> ResourceLimits { + self.inner.limits() + } + + pub fn stats(&self) -> crate::StoreStats { + self.inner.stats() + } + + pub fn skipped_corrupt(&self) -> u32 { + self.skipped_corrupt + } + + pub fn committed_path(&self, generation: u64) -> PathBuf { + block_path(&self.root, generation) + } + + pub fn lookup( + &mut self, + auth: &AuthContext, + now_ms: u64, + items: Vec, + ) -> Result, CacheError> { + let reply = self.inner.lookup(auth, now_ms, items)?; + for item in &reply.items { + if let Ok( + LookupOutcome::Hit { generation, .. } | LookupOutcome::PrefixHit { generation, .. }, + ) = &item.body + { + self.touch(*generation); + } + } + Ok(reply) + } + + pub fn reserve_put( + &mut self, + auth: &AuthContext, + now_ms: u64, + items: Vec, + ) -> Result, CacheError> { + self.ensure_batch(items.len())?; + let mut out = Vec::with_capacity(items.len()); + let mut epoch = self.inner.epoch(); + for item in items { + self.ensure_room(auth.tenant.as_bytes(), item.length, now_ms)?; + let reply = self.inner.reserve_put(auth, now_ms, vec![item])?; + epoch = reply.store_epoch; + let Some(result) = reply.items.into_iter().next() else { + return Err(CacheError::Malformed { + reason: "reserve batch", + }); + }; + out.push(result); + } + Ok(BatchReply { + store_epoch: epoch, + items: out, + }) + } + + pub fn commit( + &mut self, + auth: &AuthContext, + now_ms: u64, + items: Vec, + ) -> Result, CacheError> { + let reply = self.inner.commit(auth, now_ms, items)?; + let mut out = Vec::with_capacity(reply.items.len()); + for result in reply.items { + let request_id = result.request_id; + let body = match result.body { + Ok(outcome) => match self.persist_committed(outcome.generation) { + Ok(()) => { + self.touch(outcome.generation); + Ok(outcome) + } + Err(err) => Err(err), + }, + Err(err) => Err(err), + }; + out.push(ItemResult { request_id, body }); + } + Ok(BatchReply { + store_epoch: reply.store_epoch, + items: out, + }) + } + + pub fn get( + &mut self, + auth: &AuthContext, + now_ms: u64, + items: Vec, + ) -> Result, CacheError> { + let reply = self.inner.get(auth, now_ms, items)?; + for item in &reply.items { + if let Ok(hit) = &item.body { + self.touch(hit.generation); + } + } + Ok(reply) + } + + pub fn release_cancel( + &mut self, + auth: &AuthContext, + now_ms: u64, + items: Vec, + ) -> Result, CacheError> { + self.inner.release_cancel(auth, now_ms, items) + } + + pub fn invalidate( + &mut self, + auth: &AuthContext, + now_ms: u64, + items: Vec, + ) -> Result, CacheError> { + let generations: Vec = items.iter().map(|item| item.generation).collect(); + let reply = self.inner.invalidate(auth, now_ms, items)?; + for (generation, result) in generations.iter().zip(&reply.items) { + if result.body.is_ok() { + self.lru + .retain(|generation_in_lru| generation_in_lru != generation); + self.delete_block(*generation); + } + } + let _ = self.write_lru(); + Ok(reply) + } + + fn ensure_batch(&self, len: usize) -> Result<(), CacheError> { + if len == 0 { + return Err(CacheError::Malformed { + reason: "empty batch", + }); + } + if len > self.limits().max_batch_items as usize { + return Err(CacheError::QuotaExceeded { + kind: QuotaKind::BatchItems, + }); + } + Ok(()) + } + + fn ensure_room(&mut self, tenant: &[u8], extra: u64, now_ms: u64) -> Result<(), CacheError> { + if extra > self.limits().max_retained_bytes { + return Ok(()); + } + loop { + let global = self.inner.stats().retained_bytes; + let tenant_used = self.inner.tenant_retained_bytes(tenant); + let global_ok = global.saturating_add(extra) <= self.limits().max_retained_bytes; + let tenant_ok = + tenant_used.saturating_add(extra) <= self.limits().max_retained_bytes_per_tenant; + if global_ok && tenant_ok { + return Ok(()); + } + let only_tenant = if tenant_ok { None } else { Some(tenant) }; + if !self.evict_oldest(now_ms, only_tenant)? { + return Ok(()); + } + } + } + + fn evict_oldest( + &mut self, + now_ms: u64, + only_tenant: Option<&[u8]>, + ) -> Result { + let candidates = self.inner.eviction_candidates(now_ms); + let n = self.lru.len(); + let mut deferred = Vec::new(); + let mut freed = false; + for _ in 0..n { + let Some(generation) = self.lru.pop_front() else { + break; + }; + let Some(candidate) = candidates.iter().find(|item| item.generation == generation) + else { + continue; + }; + let wrong_tenant = + only_tenant.is_some_and(|tenant| tenant != candidate.tenant.as_slice()); + if candidate.pinned || wrong_tenant { + deferred.push(generation); + continue; + } + let state = self.inner.force_evict_committed(generation, now_ms)?; + self.delete_block(generation); + if state != BlockState::Absent { + self.lru.push_back(generation); + continue; + } + freed = true; + break; + } + for generation in deferred { + self.lru.push_back(generation); + } + if freed { + let _ = self.write_lru(); + } + Ok(freed) + } + + fn persist_committed(&mut self, generation: u64) -> Result<(), CacheError> { + let block = self.inner.export_committed(generation)?; + let bytes = encode_block(&block)?; + let path = block_path(&self.root, generation); + write_atomic(&path, &bytes) + } + + fn touch(&mut self, generation: u64) { + self.lru.retain(|existing| *existing != generation); + self.lru.push_back(generation); + let _ = self.write_lru(); + } + + fn delete_block(&self, generation: u64) { + let path = block_path(&self.root, generation); + if path.is_file() { + let _ = fs::remove_file(path); + } + } + + fn write_manifest(&self) -> Result<(), CacheError> { + let mut w = ByteWriter::with_capacity(16); + w.raw(MANIFEST_MAGIC); + w.u16(MANIFEST_VERSION); + w.u64(self.inner.epoch()); + write_atomic(&self.root.join("MANIFEST"), &w.finish()) + } + + fn write_lru(&self) -> Result<(), CacheError> { + let mut w = ByteWriter::with_capacity(16 + self.lru.len() * 8); + w.raw(LRU_MAGIC); + w.u16(LRU_VERSION); + let n = u32::try_from(self.lru.len()).unwrap_or(u32::MAX); + w.u32(n); + for generation in &self.lru { + w.u64(*generation); + } + write_atomic(&self.root.join("LRU"), &w.finish()) + } +} + +fn prepare_root(path: &Path) -> Result { + reject_path(path)?; + if path.exists() { + let meta = fs::metadata(path).map_err(|_| CacheError::Storage { reason: "store io" })?; + if !meta.is_dir() { + return Err(CacheError::Malformed { + reason: "data path", + }); + } + reject_special(&meta)?; + } else { + fs::create_dir_all(path).map_err(|_| CacheError::Storage { reason: "store io" })?; + } + let root = fs::canonicalize(path).map_err(|_| CacheError::Storage { reason: "store io" })?; + reject_path(&root)?; + fs::create_dir_all(root.join("blocks")) + .map_err(|_| CacheError::Storage { reason: "store io" })?; + Ok(root) +} + +fn reject_path(path: &Path) -> Result<(), CacheError> { + if path.as_os_str().is_empty() || path == Path::new("/") { + return Err(CacheError::Malformed { + reason: "data path", + }); + } + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(CacheError::Malformed { + reason: "data path", + }); + } + let text = path.to_string_lossy(); + if text.starts_with("/dev") || text.starts_with("/proc") || text.starts_with("/sys") { + return Err(CacheError::Malformed { + reason: "data path", + }); + } + Ok(()) +} + +fn reject_special(meta: &fs::Metadata) -> Result<(), CacheError> { + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + let kind = meta.file_type(); + if kind.is_block_device() || kind.is_char_device() || kind.is_fifo() || kind.is_socket() { + return Err(CacheError::Malformed { + reason: "data path", + }); + } + } + let _ = meta; + Ok(()) +} + +fn read_manifest(root: &Path) -> Result, CacheError> { + let path = root.join("MANIFEST"); + if !path.is_file() { + return Ok(None); + } + let bytes = read_capped(&path, 64)?; + let mut r = ByteReader::new(&bytes); + let magic = r.raw(4)?; + if magic != MANIFEST_MAGIC { + return Err(CacheError::Storage { + reason: "store corrupt", + }); + } + let version = r.u16()?; + if version != MANIFEST_VERSION { + return Err(CacheError::Storage { + reason: "store corrupt", + }); + } + let epoch = r.u64()?; + r.finish()?; + if epoch == 0 { + return Err(CacheError::Storage { + reason: "store corrupt", + }); + } + Ok(Some(epoch)) +} + +fn load_blocks( + root: &Path, + policies: &[TenantPolicy], + inner: &mut MemoryStore, +) -> Result<(u32, u32, VecDeque), CacheError> { + let dir = root.join("blocks"); + let mut paths = Vec::new(); + let entries = fs::read_dir(&dir).map_err(|_| CacheError::Storage { reason: "store io" })?; + for entry in entries { + let entry = entry.map_err(|_| CacheError::Storage { reason: "store io" })?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("gblk") { + continue; + } + paths.push(path); + } + paths.sort(); + let mut loaded = Vec::new(); + let mut skipped = 0u32; + let max_file = inner.limits().max_object_bytes.saturating_add(1024 * 1024); + for path in paths { + match read_block(&path, max_file) { + Ok(block) => { + let allowed = policies.iter().any(|policy| { + policy.tenant == block.identity.tenant + && policy.trust_domain == block.identity.trust_domain + }); + let generation = block.generation; + if allowed && inner.restore_committed(block).is_ok() { + loaded.push(generation); + } else { + skipped = skipped.saturating_add(1); + reject_file(&path); + } + } + Err(_) => { + skipped = skipped.saturating_add(1); + reject_file(&path); + } + } + } + let order = read_lru(root, &loaded); + Ok(( + u32::try_from(loaded.len()).unwrap_or(u32::MAX), + skipped, + order, + )) +} + +fn read_lru(root: &Path, loaded: &[u64]) -> VecDeque { + let path = root.join("LRU"); + let bytes = fs::read(&path).unwrap_or_default(); + let mut order = VecDeque::new(); + if let Some(gens) = decode_lru(&bytes) { + for generation in gens { + if loaded.contains(&generation) && !order.contains(&generation) { + order.push_back(generation); + } + } + } + for generation in loaded { + if !order.contains(generation) { + order.push_back(*generation); + } + } + order +} + +fn decode_lru(bytes: &[u8]) -> Option> { + let mut r = ByteReader::new(bytes); + let magic = r.raw(4).ok()?; + if magic != LRU_MAGIC || r.u16().ok()? != LRU_VERSION { + return None; + } + let n = r.u32().ok()? as usize; + if n > 1_000_000 { + return None; + } + let mut out = Vec::with_capacity(n); + for _ in 0..n { + out.push(r.u64().ok()?); + } + r.finish().ok()?; + Some(out) +} + +fn read_block(path: &Path, max_file: u64) -> Result { + let meta = fs::metadata(path).map_err(|_| CacheError::Storage { reason: "store io" })?; + if meta.len() > max_file { + return Err(CacheError::ObjectTooLarge { + limit: max_file, + actual: meta.len(), + }); + } + let bytes = read_capped(path, max_file as usize)?; + let mut r = ByteReader::new(&bytes); + let magic = r.raw(4)?; + if magic != BLOCK_MAGIC { + return Err(CacheError::Storage { + reason: "store corrupt", + }); + } + if r.u16()? != BLOCK_VERSION { + return Err(CacheError::Storage { + reason: "store corrupt", + }); + } + let generation = r.u64()?; + let checksum = gpucache_core_types::Checksum(r.array32()?); + let identity_bytes = r.bytes(1024 * 1024)?; + let identity = gpucache_core_types::CacheIdentity::from_bytes(identity_bytes)?; + let payload = r.bytes(gpucache_core_types::HARD_BYTES_CAP)?.to_vec(); + r.finish()?; + verify_checksum(&payload, checksum)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + if file_name != format!("{generation:016x}.gblk") { + return Err(CacheError::Storage { + reason: "store corrupt", + }); + } + Ok(DurableBlock { + generation, + identity, + checksum, + payload, + }) +} + +fn encode_block(block: &DurableBlock) -> Result, CacheError> { + verify_checksum(&block.payload, block.checksum)?; + let identity = block.identity.to_bytes()?; + let mut w = ByteWriter::with_capacity(64 + identity.len() + block.payload.len()); + w.raw(BLOCK_MAGIC); + w.u16(BLOCK_VERSION); + w.u64(block.generation); + w.raw(&block.checksum.0); + w.bytes(&identity)?; + w.bytes(&block.payload)?; + Ok(w.finish()) +} + +fn block_path(root: &Path, generation: u64) -> PathBuf { + root.join("blocks").join(format!("{generation:016x}.gblk")) +} + +fn reject_file(path: &Path) { + let dest = path.with_extension("rejected"); + let _ = fs::rename(path, dest); +} + +fn read_capped(path: &Path, max: usize) -> Result, CacheError> { + let mut file = File::open(path).map_err(|_| CacheError::Storage { reason: "store io" })?; + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + loop { + let n = file + .read(&mut chunk) + .map_err(|_| CacheError::Storage { reason: "store io" })?; + if n == 0 { + break; + } + if buf.len().saturating_add(n) > max { + return Err(CacheError::ObjectTooLarge { + limit: max as u64, + actual: buf.len() as u64 + n as u64, + }); + } + buf.extend_from_slice(&chunk[..n]); + } + Ok(buf) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), CacheError> { + let tmp = path.with_extension("tmp"); + { + let mut file = + File::create(&tmp).map_err(|_| CacheError::Storage { reason: "store io" })?; + file.write_all(bytes) + .map_err(|_| CacheError::Storage { reason: "store io" })?; + file.sync_all() + .map_err(|_| CacheError::Storage { reason: "store io" })?; + } + fs::rename(&tmp, path).map_err(|_| CacheError::Storage { reason: "store io" })?; + if let Some(parent) = path.parent() { + if let Ok(dir) = File::open(parent) { + let _ = dir.sync_all(); + } + } + Ok(()) +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 20a3079..fb459d8 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,15 +1,18 @@ // Copyright 2024-2026 RustFS, Inc. // SPDX-License-Identifier: Apache-2.0 -//! In-process MemoryStore for the V1 A cache contract. +//! MemoryStore and a file-backed store for the V1 cache contract. //! -//! File, NVMe, and RustFS backends are not implemented here. This crate does -//! not link a GPU, RDMA, or DPU library. +//! The file backend writes ordinary files under a caller-chosen directory. +//! It does not format disks. RustFS is a separate cold-tier crate and is not +//! on this hot path. This crate does not link a GPU, RDMA, or DPU library. #![forbid(unsafe_code)] +mod file; mod memory; mod transfer; +pub use file::FileStore; pub use memory::{MemoryStore, ReclaimRequest, StoreStats}; pub use transfer::{import_committed_block, ImportReport}; diff --git a/crates/storage/src/memory.rs b/crates/storage/src/memory.rs index 7d199b0..945c138 100644 --- a/crates/storage/src/memory.rs +++ b/crates/storage/src/memory.rs @@ -90,6 +90,32 @@ pub struct ReclaimRequest { pub observed_epoch: u64, } +/// Committed block image used by the file backend to reload after restart. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DurableBlock { + pub generation: u64, + pub identity: CacheIdentity, + pub checksum: Checksum, + pub payload: Vec, +} + +/// A committed block the file backend may evict under quota pressure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EvictionCandidate { + pub generation: u64, + pub tenant: Vec, + pub length: u64, + pub pinned: bool, +} + +struct PrefixChoice { + generation: u64, + chain_len: usize, + length: u64, + checksum: Checksum, + valid_length: u32, +} + /// Process-local shared KV store. Only `COMMITTED` compatible blocks are hits. pub struct MemoryStore { limits: ResourceLimits, @@ -396,6 +422,224 @@ impl MemoryStore { Ok(self.reply(out)) } + pub fn limits(&self) -> ResourceLimits { + self.limits + } + + pub(crate) fn tenant_retained_bytes(&self, tenant: &[u8]) -> u64 { + self.tenant_retained.get(tenant).copied().unwrap_or(0) + } + + /// Recovery only. The store must still be empty, and `epoch` must be non-zero. + pub(crate) fn restore_epoch(&mut self, epoch: u64) -> Result<(), CacheError> { + if epoch == 0 || !self.slots.is_empty() { + return Err(CacheError::Malformed { + reason: "restore epoch", + }); + } + self.epoch = epoch; + Ok(()) + } + + /// Insert one checksum-valid committed block during process recovery. + /// + /// In-flight reservations are not restored. A block that fails its checksum + /// is not inserted and therefore cannot be a hit after restart. + pub(crate) fn restore_committed(&mut self, block: DurableBlock) -> Result<(), CacheError> { + if block.generation == 0 || self.slots.contains_key(&block.generation) { + return Err(CacheError::Malformed { + reason: "restore generation", + }); + } + block.identity.validate(&self.identity_limits)?; + if block.payload.is_empty() { + return Err(CacheError::Malformed { + reason: "restore payload", + }); + } + if block.payload.len() as u64 > self.limits.max_object_bytes { + return Err(CacheError::ObjectTooLarge { + limit: self.limits.max_object_bytes, + actual: block.payload.len() as u64, + }); + } + verify_checksum(&block.payload, block.checksum)?; + let physical = encode_physical_key(&block.identity, &self.identity_limits)?; + let logical = encode_logical_key(&block.identity, &self.identity_limits)?; + if self.by_physical.contains_key(&physical) { + return Err(CacheError::InvalidTransition { + from: BlockState::Committed, + event: "restore", + }); + } + let mut life = Lifecycle::absent(); + life.apply(Event::Reserve)?; + life.apply(Event::BeginWrite)?; + life.apply(Event::Commit)?; + let length = block.payload.len() as u64; + let generation = block.generation; + let region_id = self.alloc_region(); + self.slots.insert( + generation, + Slot { + identity: block.identity, + physical: physical.clone(), + logical: logical.clone(), + life, + length, + checksum: block.checksum, + bytes: Some(block.payload), + reserve_epoch: self.epoch, + lease_deadline_ms: u64::MAX, + region_id: Some(region_id), + pins: Vec::new(), + charges: Charges::NONE, + fail: None, + }, + ); + self.index_add(physical, logical, generation); + if generation >= self.next_generation { + self.next_generation = generation.saturating_add(1); + } + if let Err(err) = self.reconcile(generation) { + self.remove_slot(generation); + return Err(err); + } + Ok(()) + } + + pub(crate) fn export_committed(&self, generation: u64) -> Result { + let slot = self + .slots + .get(&generation) + .ok_or(CacheError::UnknownGeneration)?; + if !slot.life.is_hit_visible() { + return Err(CacheError::NotExposed { + state: slot.life.state, + }); + } + let payload = slot.bytes.clone().unwrap_or_default(); + verify_checksum(&payload, slot.checksum)?; + Ok(DurableBlock { + generation, + identity: slot.identity.clone(), + checksum: slot.checksum, + payload, + }) + } + + pub(crate) fn eviction_candidates(&mut self, now_ms: u64) -> Vec { + self.poll(now_ms); + let mut out = Vec::new(); + for (generation, slot) in &self.slots { + if !slot.life.is_hit_visible() { + continue; + } + out.push(EvictionCandidate { + generation: *generation, + tenant: slot.identity.tenant.as_bytes().to_vec(), + length: slot.length, + pinned: !slot.pins.is_empty() || slot.life.refs > 0, + }); + } + out + } + + /// Drop one unpinned committed block. Used by file-backend admission. + /// + /// Pinned, quarantined, and in-flight blocks are left in place. This is not + /// a client-facing invalidate and it does not consult the idempotency table. + pub(crate) fn force_evict_committed( + &mut self, + generation: u64, + now_ms: u64, + ) -> Result { + self.poll(now_ms); + let slot = self + .slots + .get(&generation) + .ok_or(CacheError::UnknownGeneration)?; + if !slot.life.is_hit_visible() { + return Err(CacheError::NotExposed { + state: slot.life.state, + }); + } + if !slot.pins.is_empty() || slot.life.refs > 0 { + return Err(CacheError::InUse { + refs: slot.life.refs.max(1), + }); + } + self.apply_event(generation, Event::BeginEvict, None)?; + self.maybe_finish(generation)?; + Ok(self + .slots + .get(&generation) + .map(|slot| slot.life.state) + .unwrap_or(BlockState::Absent)) + } + + fn find_prefix_hit( + &mut self, + query: &CacheIdentity, + ) -> Result, CacheError> { + let generations: Vec = self.slots.keys().copied().collect(); + let mut corrupt = Vec::new(); + let mut best: Option = None; + for generation in generations { + let Some(slot) = self.slots.get(&generation) else { + continue; + }; + if slot.identity.tenant != query.tenant + || slot.identity.trust_domain != query.trust_domain + { + continue; + } + if !slot.life.is_hit_visible() { + continue; + } + let stored_len = slot.identity.prefix_chain.len(); + let query_len = query.prefix_chain.len(); + if stored_len == 0 || stored_len >= query_len { + continue; + } + if slot.identity.prefix_chain.as_slice() != &query.prefix_chain[..stored_len] { + continue; + } + if !layout_compatible(&slot.identity, query) { + continue; + } + let payload = slot.bytes.clone().unwrap_or_default(); + if payload.len() as u64 != slot.length + || verify_checksum(&payload, slot.checksum).is_err() + { + corrupt.push(generation); + continue; + } + let choice = PrefixChoice { + generation, + chain_len: stored_len, + length: slot.length, + checksum: slot.checksum, + valid_length: slot.identity.valid_length, + }; + let replace = match &best { + None => true, + Some(prev) => { + choice.chain_len > prev.chain_len + || (choice.chain_len == prev.chain_len + && choice.generation > prev.generation) + } + }; + if replace { + best = Some(choice); + } + } + for generation in corrupt { + let _ = self.apply_event(generation, Event::CorruptHold, Some(FailKind::Corrupt)); + } + Ok(best) + } + fn reply(&self, items: Vec>) -> BatchReply { BatchReply { store_epoch: self.epoch, @@ -465,6 +709,19 @@ impl MemoryStore { reason: miss_reason(slot.life.state), }); } + if let Some(choice) = self.find_prefix_hit(&item.identity)? { + let matched_prefix_blocks = + u32::try_from(choice.chain_len).map_err(|_| CacheError::Malformed { + reason: "prefix length", + })?; + return Ok(LookupOutcome::PrefixHit { + generation: choice.generation, + length: choice.length, + checksum: choice.checksum, + valid_length: choice.valid_length, + matched_prefix_blocks, + }); + } if self .logical .get(&logical) @@ -1306,6 +1563,22 @@ impl MemoryStore { } } +fn layout_compatible(stored: &CacheIdentity, query: &CacheIdentity) -> bool { + stored.version == query.version + && stored.model_revision == query.model_revision + && stored.model_config_hash == query.model_config_hash + && stored.tokenizer_revision == query.tokenizer_revision + && stored.adapter == query.adapter + && stored.kv_dtype == query.kv_dtype + && stored.quant_format == query.quant_format + && stored.layout == query.layout + && stored.shard == query.shard + && stored.position == query.position + && stored.rope == query.rope + && stored.multimodal == query.multimodal + && stored.block_token_count == query.block_token_count +} + fn charge_target(state: BlockState) -> ChargeTarget { match state { BlockState::Absent => ChargeTarget { diff --git a/crates/storage/src/tests.rs b/crates/storage/src/tests.rs index c226ac4..7ea09b5 100644 --- a/crates/storage/src/tests.rs +++ b/crates/storage/src/tests.rs @@ -744,6 +744,92 @@ fn request_deadline_is_enforced() { assert_eq!(store.stats().retained_bytes, 0); } +#[test] +fn partial_prefix_returns_the_whole_shorter_block_and_does_not_slice() { + let (mut store, auth) = open(None); + let payload = b"prefix-block".to_vec(); + let generation = commit_bytes(&mut store, &auth, 10, &payload); + let mut longer = synthetic_identity(1, 1); + longer + .prefix_chain + .push(gpucache_core_types::Digest([0xab; 32])); + let looked = store + .lookup( + &auth, + 0, + vec![LookupItem { + header: hdr(&store, 30, 0), + identity: longer.clone(), + }], + ) + .unwrap(); + match looked.items[0].body.clone().unwrap() { + LookupOutcome::PrefixHit { + generation: got_generation, + matched_prefix_blocks, + valid_length, + length, + .. + } => { + assert_eq!(got_generation, generation); + assert_eq!(matched_prefix_blocks, 1); + assert_eq!(length, payload.len() as u64); + let matched = gpucache_protocol::identity_for_prefix_hit( + &longer, + valid_length, + matched_prefix_blocks, + ) + .unwrap(); + assert_eq!(matched, synthetic_identity(1, 1)); + let fetched = store + .get( + &auth, + 0, + vec![GetItem { + header: hdr(&store, 31, 0), + generation, + identity: matched, + pin_ttl_ms: 100, + }], + ) + .unwrap(); + assert_eq!(fetched.items[0].body.as_ref().unwrap().payload, payload); + let wrong = store + .get( + &auth, + 0, + vec![GetItem { + header: hdr(&store, 32, 0), + generation, + identity: longer, + pin_ttl_ms: 100, + }], + ) + .unwrap(); + assert!(matches!(wrong.items[0].body, Err(CacheError::Incompatible))); + } + other => panic!("expected a whole-block prefix hit, got {other:?}"), + } + let mut sliced = synthetic_identity(1, 1); + sliced.valid_length = 4; + let slice_lookup = store + .lookup( + &auth, + 0, + vec![LookupItem { + header: hdr(&store, 33, 0), + identity: sliced, + }], + ) + .unwrap(); + assert!(matches!( + slice_lookup.items[0].body.as_ref().unwrap(), + LookupOutcome::Miss { + reason: gpucache_protocol::MissReason::Incompatible + } + )); +} + fn hex(bytes: &[u8]) -> String { const DIGITS: &[u8; 16] = b"0123456789abcdef"; let mut out = String::with_capacity(bytes.len() * 2); diff --git a/crates/storage/src/transfer.rs b/crates/storage/src/transfer.rs index cb83acb..785a9c9 100644 --- a/crates/storage/src/transfer.rs +++ b/crates/storage/src/transfer.rs @@ -98,6 +98,7 @@ pub fn import_committed_block( match one(looked.items)? { LookupOutcome::Hit { checksum, .. } if checksum == frame.checksum => {} LookupOutcome::Hit { .. } => return Err(CacheError::ChecksumMismatch), + LookupOutcome::PrefixHit { .. } => return Err(CacheError::Incompatible), LookupOutcome::Miss { .. } => { return Err(CacheError::NotExposed { state: gpucache_core_types::BlockState::Absent, diff --git a/crates/storage/tests/file_backend.rs b/crates/storage/tests/file_backend.rs new file mode 100644 index 0000000..35d778a --- /dev/null +++ b/crates/storage/tests/file_backend.rs @@ -0,0 +1,426 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use gpucache_core_types::{ + content_checksum, synthetic_identity, CacheError, QuotaKind, RequestId, ResourceLimits, + TenantId, +}; +use gpucache_protocol::{ + AuthContext, CommitItem, GetItem, ItemHeader, LookupItem, LookupOutcome, ReleaseCancelItem, + ReservePutItem, TenantPolicy, +}; +use gpucache_storage::FileStore; + +const FP_A: [u8; 32] = [9u8; 32]; +const FP_B: [u8; 32] = [8u8; 32]; + +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new() -> Self { + static N: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "gpucache-file-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&path); + Self { path } + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn limits(retained: u64, per_tenant: u64) -> ResourceLimits { + let mut limits = ResourceLimits::dev_defaults(); + limits.max_object_bytes = 32; + limits.max_retained_bytes = retained; + limits.max_retained_bytes_per_tenant = per_tenant; + limits.max_in_flight_bytes_per_tenant = retained; + limits.max_registrations = 32; + limits.max_in_flight_requests = 32; + limits +} + +fn policy(nonce: u8, fingerprint: [u8; 32]) -> (TenantPolicy, AuthContext) { + let id = synthetic_identity(nonce, 1); + let policy = + TenantPolicy::new(id.tenant.clone(), id.trust_domain.clone(), fingerprint).unwrap(); + let auth = AuthContext::new(id.tenant, id.trust_domain, fingerprint).unwrap(); + (policy, auth) +} + +fn header(epoch: u64, n: u128) -> ItemHeader { + ItemHeader { + request_id: RequestId::from_counter(n).unwrap(), + epoch, + deadline_ms: 1_000, + } +} + +fn put( + epoch: u64, + n: u128, + bytes: &[u8], + identity: gpucache_core_types::CacheIdentity, +) -> ReservePutItem { + ReservePutItem { + header: header(epoch, n), + identity, + generation: 0, + length: bytes.len() as u64, + checksum: content_checksum(bytes), + payload: Some(bytes.to_vec()), + lease_ttl_ms: 5_000, + } +} + +fn commit_block( + store: &mut FileStore, + auth: &AuthContext, + n: u128, + bytes: &[u8], + identity: gpucache_core_types::CacheIdentity, +) -> u64 { + let epoch = store.epoch(); + let reserved = store + .reserve_put(auth, 0, vec![put(epoch, n, bytes, identity)]) + .unwrap(); + let generation = reserved.items[0].body.as_ref().unwrap().generation; + let epoch = store.epoch(); + store + .commit( + auth, + 0, + vec![CommitItem { + header: header(epoch, n + 1), + generation, + length: bytes.len() as u64, + checksum: content_checksum(bytes), + }], + ) + .unwrap(); + generation +} + +#[test] +fn refuses_device_and_parent_paths() { + let (policy, _) = policy(1, FP_A); + assert!(matches!( + must_err(FileStore::open( + "/dev/nvme0n1", + ResourceLimits::dev_defaults(), + vec![policy.clone()], + )), + CacheError::Malformed { + reason: "data path" + } + )); + assert!(matches!( + must_err(FileStore::open( + "/tmp/gpucache-not-created/../escape", + ResourceLimits::dev_defaults(), + vec![policy], + )), + CacheError::Malformed { + reason: "data path" + } + )); +} + +fn must_err(result: Result) -> CacheError { + match result { + Err(err) => err, + Ok(_) => panic!("expected open to fail"), + } +} + +#[test] +fn empty_policy_is_refused() { + let dir = TempDir::new(); + assert!(matches!( + must_err(FileStore::open(&dir.path, limits(64, 64), vec![])), + CacheError::Unauthorized + )); +} + +#[test] +fn restart_reloads_committed_bytes_and_bumps_epoch() { + let dir = TempDir::new(); + let (policy, auth) = policy(1, FP_A); + let payload = b"durable-kv".to_vec(); + let epoch; + { + let mut store = FileStore::open(&dir.path, limits(64, 64), vec![policy.clone()]).unwrap(); + epoch = store.epoch(); + let generation = commit_block(&mut store, &auth, 10, &payload, synthetic_identity(1, 1)); + let looked = store + .lookup( + &auth, + 0, + vec![LookupItem { + header: header(store.epoch(), 30), + identity: synthetic_identity(1, 1), + }], + ) + .unwrap(); + assert!(matches!( + looked.items[0].body, + Ok(LookupOutcome::Hit { .. }) + )); + assert!(store.committed_path(generation).is_file()); + } + let mut store = FileStore::open(&dir.path, limits(64, 64), vec![policy]).unwrap(); + assert_eq!(store.epoch(), epoch + 1); + let looked = store + .lookup( + &auth, + 0, + vec![LookupItem { + header: header(store.epoch(), 31), + identity: synthetic_identity(1, 1), + }], + ) + .unwrap(); + let Ok(LookupOutcome::Hit { + generation, + checksum, + .. + }) = looked.items[0].body + else { + panic!("restart must hit"); + }; + assert_eq!(checksum, content_checksum(&payload)); + let got = store + .get( + &auth, + 0, + vec![GetItem { + header: header(store.epoch(), 32), + generation, + identity: synthetic_identity(1, 1), + pin_ttl_ms: 100, + }], + ) + .unwrap(); + let hit = got.items.into_iter().next().unwrap().body.unwrap(); + assert_eq!(hit.payload, payload); +} + +#[test] +fn corrupt_file_is_not_a_hit_after_restart() { + let dir = TempDir::new(); + let (policy, auth) = policy(1, FP_A); + let payload = b"checksum-on-disk".to_vec(); + let path; + { + let mut store = FileStore::open(&dir.path, limits(64, 64), vec![policy.clone()]).unwrap(); + let generation = commit_block(&mut store, &auth, 10, &payload, synthetic_identity(1, 1)); + path = store.committed_path(generation); + } + let mut bytes = fs::read(&path).unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 0x01; + fs::write(&path, bytes).unwrap(); + let mut store = FileStore::open(&dir.path, limits(64, 64), vec![policy]).unwrap(); + assert!(store.skipped_corrupt() >= 1); + let looked = store + .lookup( + &auth, + 0, + vec![LookupItem { + header: header(store.epoch(), 40), + identity: synthetic_identity(1, 1), + }], + ) + .unwrap(); + assert!(matches!( + looked.items[0].body, + Ok(LookupOutcome::Miss { .. }) + )); +} + +fn block(fill: u8) -> Vec { + vec![fill; 16] +} + +#[test] +fn lru_evicts_the_oldest_unpinned_block() { + let dir = TempDir::new(); + let (policy, auth) = policy(1, FP_A); + let mut store = FileStore::open(&dir.path, limits(32, 32), vec![policy]).unwrap(); + let mut id_a = synthetic_identity(1, 1); + id_a.prefix_chain[0].0[0] = 1; + let mut id_b = synthetic_identity(1, 1); + id_b.prefix_chain[0].0[0] = 2; + let mut id_c = synthetic_identity(1, 1); + id_c.prefix_chain[0].0[0] = 3; + let gen_a = commit_block(&mut store, &auth, 10, &block(1), id_a.clone()); + commit_block(&mut store, &auth, 20, &block(2), id_b.clone()); + let got = store + .get( + &auth, + 0, + vec![GetItem { + header: header(store.epoch(), 40), + generation: gen_a, + identity: id_a.clone(), + pin_ttl_ms: 1_000, + }], + ) + .unwrap(); + let pin = got.items.into_iter().next().unwrap().body.unwrap(); + store + .release_cancel( + &auth, + 0, + vec![ReleaseCancelItem { + header: header(store.epoch(), 41), + generation: gen_a, + region_id: Some(pin.region_id), + pin_owner: Some(RequestId::from_counter(40).unwrap()), + attestation: gpucache_protocol::Attestation::Drained, + }], + ) + .unwrap(); + commit_block(&mut store, &auth, 50, &block(3), id_c); + let miss_b = store + .lookup( + &auth, + 0, + vec![LookupItem { + header: header(store.epoch(), 60), + identity: id_b, + }], + ) + .unwrap(); + assert!(matches!( + miss_b.items[0].body, + Ok(LookupOutcome::Miss { .. }) + )); + let hit_a = store + .lookup( + &auth, + 0, + vec![LookupItem { + header: header(store.epoch(), 61), + identity: id_a, + }], + ) + .unwrap(); + assert!(matches!(hit_a.items[0].body, Ok(LookupOutcome::Hit { .. }))); +} + +#[test] +fn pinned_block_is_not_evicted_to_make_room() { + let dir = TempDir::new(); + let (policy, auth) = policy(1, FP_A); + let mut tight = limits(16, 16); + tight.max_object_bytes = 16; + let mut store = FileStore::open(&dir.path, tight, vec![policy]).unwrap(); + let mut id_a = synthetic_identity(1, 1); + id_a.prefix_chain[0].0[0] = 1; + let gen_a = commit_block(&mut store, &auth, 10, &block(1), id_a.clone()); + let got = store + .get( + &auth, + 0, + vec![GetItem { + header: header(store.epoch(), 20), + generation: gen_a, + identity: id_a, + pin_ttl_ms: 1_000, + }], + ) + .unwrap(); + assert!(got.items[0].body.is_ok()); + let mut id_b = synthetic_identity(1, 1); + id_b.prefix_chain[0].0[0] = 2; + let denied = store + .reserve_put(&auth, 0, vec![put(store.epoch(), 30, &block(2), id_b)]) + .unwrap(); + assert!(matches!( + denied.items[0].body, + Err(CacheError::QuotaExceeded { + kind: QuotaKind::RetainedBytes + }) + )); +} + +#[test] +fn tenant_quota_does_not_evict_another_tenant() { + let dir = TempDir::new(); + let (policy_a, auth_a) = policy(1, FP_A); + let id_b = { + let mut id = synthetic_identity(2, 1); + id.tenant = TenantId::new(b"tenant-b".to_vec()).unwrap(); + id + }; + let policy_b = TenantPolicy::new(id_b.tenant.clone(), id_b.trust_domain.clone(), FP_B).unwrap(); + let auth_b = AuthContext::new(id_b.tenant.clone(), id_b.trust_domain.clone(), FP_B).unwrap(); + let mut store = FileStore::open(&dir.path, limits(64, 16), vec![policy_a, policy_b]).unwrap(); + let bytes_b = block(9); + let bytes_a1 = block(1); + let bytes_a2 = block(2); + let mut id_b1 = id_b.clone(); + id_b1.prefix_chain[0].0[0] = 9; + commit_block(&mut store, &auth_b, 10, &bytes_b, id_b1.clone()); + let mut id_a1 = synthetic_identity(1, 1); + id_a1.prefix_chain[0].0[0] = 1; + commit_block(&mut store, &auth_a, 20, &bytes_a1, id_a1.clone()); + let mut id_a2 = synthetic_identity(1, 1); + id_a2.prefix_chain[0].0[0] = 2; + commit_block(&mut store, &auth_a, 30, &bytes_a2, id_a2.clone()); + let still_b = store + .lookup( + &auth_b, + 0, + vec![LookupItem { + header: header(store.epoch(), 40), + identity: id_b1, + }], + ) + .unwrap(); + assert!(matches!( + still_b.items[0].body, + Ok(LookupOutcome::Hit { .. }) + )); + let gone_a = store + .lookup( + &auth_a, + 0, + vec![LookupItem { + header: header(store.epoch(), 41), + identity: id_a1, + }], + ) + .unwrap(); + assert!(matches!( + gone_a.items[0].body, + Ok(LookupOutcome::Miss { .. }) + )); + let hit_a2 = store + .lookup( + &auth_a, + 0, + vec![LookupItem { + header: header(store.epoch(), 42), + identity: id_a2, + }], + ) + .unwrap(); + assert!(matches!( + hit_a2.items[0].body, + Ok(LookupOutcome::Hit { .. }) + )); +} diff --git a/crates/transport/Cargo.toml b/crates/transport/Cargo.toml new file mode 100644 index 0000000..c6c8950 --- /dev/null +++ b/crates/transport/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "gpucache-transport" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +authors.workspace = true +description = "Localhost TCP transport for the GPUCache V1 B cache service. No RDMA or DPU dependencies." +publish = false + +[dependencies] +gpucache-core-types.workspace = true +gpucache-protocol.workspace = true +gpucache-storage.workspace = true + +[[bin]] +name = "gpucache-tcp" +path = "src/bin/gpucache_tcp.rs" + +[[bin]] +name = "share-demo" +path = "src/bin/share_demo.rs" + +[lints] +workspace = true diff --git a/crates/transport/src/bin/gpucache_tcp.rs b/crates/transport/src/bin/gpucache_tcp.rs new file mode 100644 index 0000000..4c04a27 --- /dev/null +++ b/crates/transport/src/bin/gpucache_tcp.rs @@ -0,0 +1,96 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 +//! File-backed GPUCache TCP service. +//! +//! Default bind is loopback. A fingerprint is required. The process creates +//! files only under `--data-dir` and does not format a device. + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitCode; +use std::thread; + +use gpucache_core_types::{ResourceLimits, TenantId, TrustDomain}; +use gpucache_protocol::TenantPolicy; +use gpucache_storage::FileStore; +use gpucache_transport::{serve, ServerConfig}; + +fn main() -> ExitCode { + match serve_from_args(std::env::args().skip(1)) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("error={message}"); + eprintln!( + "usage: gpucache-tcp serve --data-dir DIR --tenant ID --trust DOMAIN --fingerprint HEX64 [--bind 127.0.0.1:9735] [--allow-remote]" + ); + ExitCode::from(1) + } + } +} + +fn serve_from_args(args: impl Iterator) -> Result<(), &'static str> { + let mut args = args.peekable(); + if args.next().as_deref() != Some("serve") { + return Err("usage"); + } + let mut bind: SocketAddr = "127.0.0.1:9735".parse().map_err(|_| "bind")?; + let mut data_dir: Option = None; + let mut tenant: Option = None; + let mut trust: Option = None; + let mut fingerprint: Option = None; + let mut allow_remote = false; + while let Some(arg) = args.next() { + match arg.as_str() { + "--bind" => { + let value = args.next().ok_or("bind")?; + bind = value.parse().map_err(|_| "bind")?; + } + "--data-dir" => data_dir = Some(PathBuf::from(args.next().ok_or("data dir")?)), + "--tenant" => tenant = Some(args.next().ok_or("tenant")?), + "--trust" => trust = Some(args.next().ok_or("trust")?), + "--fingerprint" => fingerprint = Some(args.next().ok_or("fingerprint")?), + "--allow-remote" => allow_remote = true, + _ => return Err("usage"), + } + } + let data_dir = data_dir.ok_or("data dir")?; + let tenant = TenantId::new(tenant.ok_or("tenant")?.into_bytes()).map_err(|_| "tenant")?; + let trust = TrustDomain::new(trust.ok_or("trust")?.into_bytes()).map_err(|_| "trust")?; + let fingerprint = parse_hex32(&fingerprint.ok_or("fingerprint")?).map_err(|_| "fingerprint")?; + let policy = TenantPolicy::new(tenant, trust, fingerprint).map_err(|_| "fingerprint")?; + let store = FileStore::open(&data_dir, ResourceLimits::dev_defaults(), vec![policy]) + .map_err(|_| "data dir")?; + let cfg = ServerConfig { + bind, + allow_remote, + max_frame_bytes: 2 * 1024 * 1024, + ..ServerConfig::default() + }; + let handle = serve(store, cfg).map_err(|_| "bind")?; + println!("listening={}", handle.addr()); + thread::park(); + Ok(()) +} + +fn parse_hex32(text: &str) -> Result<[u8; 32], ()> { + if text.len() != 64 { + return Err(()); + } + let mut out = [0u8; 32]; + let bytes = text.as_bytes(); + for i in 0..32 { + let hi = hex_val(bytes[i * 2])?; + let lo = hex_val(bytes[i * 2 + 1])?; + out[i] = (hi << 4) | lo; + } + Ok(out) +} + +fn hex_val(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => Err(()), + } +} diff --git a/crates/transport/src/bin/share_demo.rs b/crates/transport/src/bin/share_demo.rs new file mode 100644 index 0000000..452f70f --- /dev/null +++ b/crates/transport/src/bin/share_demo.rs @@ -0,0 +1,23 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 +//! Two localhost clients sharing one file-backed GPUCache service. +//! +//! The bytes are synthetic. This is not a vLLM run and not a DPU run. + +use std::process::ExitCode; + +use gpucache_transport::run_share_demo; + +fn main() -> ExitCode { + match run_share_demo() { + Ok(report) => { + println!("{report}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("status=REUSE_FAILED"); + eprintln!("error={err}"); + ExitCode::from(2) + } + } +} diff --git a/crates/transport/src/client.rs b/crates/transport/src/client.rs new file mode 100644 index 0000000..64285c9 --- /dev/null +++ b/crates/transport/src/client.rs @@ -0,0 +1,155 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::{SocketAddr, TcpStream}; +use std::sync::Arc; +use std::time::Duration; + +use gpucache_core_types::CacheError; +use gpucache_protocol::{ + decode_hello_ack, decode_reply, encode_hello, encode_request, AuthContext, BatchReply, + CommitItem, CommitOutcome, GetHit, GetItem, InvalidateItem, InvalidateOutcome, LookupItem, + LookupOutcome, ReleaseCancelItem, ReleaseOutcome, ReplyBody, RequestBody, ReserveOutcome, + ReservePutItem, WIRE_VERSION, +}; + +use crate::clock::Clock; +use crate::frame::{read_envelope, write_envelope}; +use crate::pool::Pool; + +pub struct TcpClient { + stream: TcpStream, + auth: AuthContext, + pool: Arc, + max_frame: usize, + epoch: u64, + clock: Clock, +} + +impl TcpClient { + pub fn connect( + addr: SocketAddr, + auth: AuthContext, + timeout: Duration, + max_frame: usize, + ) -> Result { + if max_frame < 64 { + return Err(CacheError::Malformed { + reason: "server config", + }); + } + let mut stream = TcpStream::connect(addr).map_err(|_| CacheError::Storage { + reason: "transport io", + })?; + stream + .set_read_timeout(Some(timeout)) + .map_err(|_| CacheError::Storage { + reason: "transport io", + })?; + stream + .set_write_timeout(Some(timeout)) + .map_err(|_| CacheError::Storage { + reason: "transport io", + })?; + let _ = stream.set_nodelay(true); + let pool = Arc::new(Pool::new(1)); + let hello = encode_hello(&[WIRE_VERSION])?; + write_envelope(&mut stream, 0, &hello, max_frame)?; + let envelope = read_envelope(&mut stream, pool.checkout()?, max_frame)?; + let (version, epoch) = decode_hello_ack(&envelope.body)?; + if version != WIRE_VERSION { + return Err(CacheError::WireVersionUnsupported { + local: WIRE_VERSION, + remote: version, + }); + } + Ok(Self { + stream, + auth, + pool, + max_frame, + epoch, + clock: Clock::new(envelope.server_now), + }) + } + + pub fn epoch(&self) -> u64 { + self.epoch + } + + pub fn deadline(&self, ttl_ms: u64) -> u64 { + self.clock.server_now().saturating_add(ttl_ms).max(1) + } + + pub fn lookup( + &mut self, + items: Vec, + ) -> Result, CacheError> { + self.call(RequestBody::Lookup(items), |body| match body { + ReplyBody::Lookup(batch) => Ok(batch), + _ => Err(CacheError::Malformed { reason: "reply op" }), + }) + } + + pub fn reserve_put( + &mut self, + items: Vec, + ) -> Result, CacheError> { + self.call(RequestBody::ReservePut(items), |body| match body { + ReplyBody::Reserve(batch) => Ok(batch), + _ => Err(CacheError::Malformed { reason: "reply op" }), + }) + } + + pub fn commit( + &mut self, + items: Vec, + ) -> Result, CacheError> { + self.call(RequestBody::Commit(items), |body| match body { + ReplyBody::Commit(batch) => Ok(batch), + _ => Err(CacheError::Malformed { reason: "reply op" }), + }) + } + + pub fn get(&mut self, items: Vec) -> Result, CacheError> { + self.call(RequestBody::Get(items), |body| match body { + ReplyBody::Get(batch) => Ok(batch), + _ => Err(CacheError::Malformed { reason: "reply op" }), + }) + } + + pub fn release_cancel( + &mut self, + items: Vec, + ) -> Result, CacheError> { + self.call(RequestBody::ReleaseCancel(items), |body| match body { + ReplyBody::Release(batch) => Ok(batch), + _ => Err(CacheError::Malformed { reason: "reply op" }), + }) + } + + pub fn invalidate( + &mut self, + items: Vec, + ) -> Result, CacheError> { + self.call(RequestBody::Invalidate(items), |body| match body { + ReplyBody::Invalidate(batch) => Ok(batch), + _ => Err(CacheError::Malformed { reason: "reply op" }), + }) + } + + fn call( + &mut self, + request: RequestBody, + map: impl FnOnce(ReplyBody) -> Result, + ) -> Result { + let bytes = encode_request(&self.auth, &request)?; + write_envelope(&mut self.stream, 0, &bytes, self.max_frame)?; + let envelope = read_envelope(&mut self.stream, self.pool.checkout()?, self.max_frame)?; + self.clock.observe(envelope.server_now); + match decode_reply(&envelope.body)? { + ReplyBody::Failed { error, .. } => Err(error), + other => map(other), + } + } +} diff --git a/crates/transport/src/clock.rs b/crates/transport/src/clock.rs new file mode 100644 index 0000000..9603f97 --- /dev/null +++ b/crates/transport/src/clock.rs @@ -0,0 +1,37 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::OnceLock; +use std::time::Instant; + +pub fn mono_ms() -> u64 { + static START: OnceLock = OnceLock::new(); + let start = START.get_or_init(Instant::now); + u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +/// Client view of the server monotonic clock, sampled from response envelopes. +#[derive(Clone, Debug)] +pub struct Clock { + server_base: u64, + local_base: Instant, +} + +impl Clock { + pub fn new(server_now: u64) -> Self { + Self { + server_base: server_now, + local_base: Instant::now(), + } + } + + pub fn server_now(&self) -> u64 { + let elapsed = u64::try_from(self.local_base.elapsed().as_millis()).unwrap_or(0); + self.server_base.saturating_add(elapsed) + } + + pub fn observe(&mut self, server_now: u64) { + self.server_base = server_now; + self.local_base = Instant::now(); + } +} diff --git a/crates/transport/src/demo.rs b/crates/transport/src/demo.rs new file mode 100644 index 0000000..34a5d32 --- /dev/null +++ b/crates/transport/src/demo.rs @@ -0,0 +1,298 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use gpucache_core_types::{ + content_checksum, synthetic_identity, CacheError, Digest, RequestId, ResourceLimits, +}; +use gpucache_protocol::{ + identity_for_prefix_hit, AuthContext, CommitItem, GetItem, ItemHeader, LookupItem, + LookupOutcome, MissReason, ReservePutItem, TenantPolicy, +}; +use gpucache_storage::FileStore; + +use crate::client::TcpClient; +use crate::server::{serve, ServerConfig}; + +/// Two TCP clients sharing one file-backed service. +/// +/// Partial reuse is a whole-block prefix hit. A shorter `valid_length` on the +/// same chain is an incompatible miss: this demo does not slice inside a block. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShareReport { + pub exact_hit: &'static str, + pub unknown_miss: &'static str, + pub partial_prefix: &'static str, + pub matched_prefix_blocks: u32, + pub intra_block_slice: &'static str, + pub layout_mismatch: &'static str, + pub cross_tenant: &'static str, + pub bytes_match: &'static str, +} + +impl std::fmt::Display for ShareReport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "status=REUSE_OK")?; + writeln!(f, "exact_hit={}", self.exact_hit)?; + writeln!(f, "unknown_miss={}", self.unknown_miss)?; + writeln!( + f, + "partial_prefix={} matched_blocks={}", + self.partial_prefix, self.matched_prefix_blocks + )?; + writeln!(f, "intra_block_slice={}", self.intra_block_slice)?; + writeln!(f, "layout_mismatch={}", self.layout_mismatch)?; + writeln!(f, "cross_tenant={}", self.cross_tenant)?; + write!(f, "bytes_match={}", self.bytes_match) + } +} + +pub fn run_share_demo() -> Result { + let dir = temp_dir(); + let result = run_in(&dir.path); + let _ = fs::remove_dir_all(&dir.path); + result +} + +struct TempPath { + path: PathBuf, +} + +fn temp_dir() -> TempPath { + static N: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "gpucache-share-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&path); + TempPath { path } +} + +fn run_in(path: &std::path::Path) -> Result { + let writer_id = synthetic_identity(1, 1); + let fingerprint = [9u8; 32]; + let other_fingerprint = [4u8; 32]; + let policy = TenantPolicy::new( + writer_id.tenant.clone(), + writer_id.trust_domain.clone(), + fingerprint, + )?; + let mut other_id = synthetic_identity(3, 1); + other_id.tenant = gpucache_core_types::TenantId::new(b"tenant-b".to_vec())?; + let other_policy = TenantPolicy::new( + other_id.tenant.clone(), + other_id.trust_domain.clone(), + other_fingerprint, + )?; + let store = FileStore::open( + path, + ResourceLimits::dev_defaults(), + vec![policy, other_policy], + )?; + let server = serve(store, ServerConfig::default())?; + let auth = AuthContext::new( + writer_id.tenant.clone(), + writer_id.trust_domain.clone(), + fingerprint, + )?; + let mut writer = TcpClient::connect( + server.addr(), + auth.clone(), + Duration::from_secs(5), + 1024 * 1024, + )?; + let mut reader = TcpClient::connect(server.addr(), auth, Duration::from_secs(5), 1024 * 1024)?; + let payload = b"shared-kv-block".to_vec(); + let generation = put_commit(&mut writer, &payload, writer_id.clone())?; + let hit = lookup_one(&mut reader, writer_id.clone())?; + let LookupOutcome::Hit { + generation: hit_generation, + checksum, + .. + } = hit + else { + return Err(CacheError::Malformed { + reason: "demo exact hit", + }); + }; + if hit_generation != generation || checksum != content_checksum(&payload) { + return Err(CacheError::ChecksumMismatch); + } + let got = get_one(&mut reader, generation, writer_id.clone())?; + if got != payload { + return Err(CacheError::ChecksumMismatch); + } + let mut unknown = writer_id.clone(); + unknown.prefix_chain[0] = Digest([0x91; 32]); + match lookup_one(&mut reader, unknown)? { + LookupOutcome::Miss { + reason: MissReason::Absent, + } => {} + _ => { + return Err(CacheError::Malformed { + reason: "demo miss", + }) + } + } + let mut longer = writer_id.clone(); + longer.prefix_chain.push(Digest([0xab; 32])); + let prefix = lookup_one(&mut reader, longer.clone())?; + let LookupOutcome::PrefixHit { + generation: prefix_generation, + valid_length, + matched_prefix_blocks, + checksum: prefix_checksum, + .. + } = prefix + else { + return Err(CacheError::Malformed { + reason: "demo prefix", + }); + }; + if matched_prefix_blocks != 1 || prefix_checksum != content_checksum(&payload) { + return Err(CacheError::Incompatible); + } + let matched = identity_for_prefix_hit(&longer, valid_length, matched_prefix_blocks)?; + if get_one(&mut reader, prefix_generation, longer.clone()).is_ok() { + return Err(CacheError::Incompatible); + } + let prefix_bytes = get_one(&mut reader, prefix_generation, matched)?; + if prefix_bytes != payload { + return Err(CacheError::ChecksumMismatch); + } + let mut sliced = writer_id.clone(); + sliced.valid_length = 4; + match lookup_one(&mut reader, sliced)? { + LookupOutcome::Miss { + reason: MissReason::Incompatible, + } => {} + _ => { + return Err(CacheError::Malformed { + reason: "demo slice", + }) + } + } + let mut layout = writer_id.clone(); + layout.layout.family = 9; + match lookup_one(&mut reader, layout)? { + LookupOutcome::Miss { + reason: MissReason::Incompatible, + } => {} + _ => { + return Err(CacheError::Malformed { + reason: "demo layout", + }) + } + } + let foreign = lookup_raw(&mut reader, other_id); + if !matches!(foreign, Err(CacheError::Unauthorized)) { + return Err(CacheError::Malformed { + reason: "demo tenant", + }); + } + Ok(ShareReport { + exact_hit: "PASS", + unknown_miss: "PASS", + partial_prefix: "WHOLE_BLOCK_PREFIX", + matched_prefix_blocks, + intra_block_slice: "DEGRADED_MISS", + layout_mismatch: "MISS", + cross_tenant: "UNAUTHORIZED", + bytes_match: "PASS", + }) +} + +fn put_commit( + client: &mut TcpClient, + payload: &[u8], + identity: gpucache_core_types::CacheIdentity, +) -> Result { + let epoch = client.epoch(); + let reserved = client.reserve_put(vec![ReservePutItem { + header: header(client, 1, epoch), + identity: identity.clone(), + generation: 0, + length: payload.len() as u64, + checksum: content_checksum(payload), + payload: Some(payload.to_vec()), + lease_ttl_ms: 30_000, + }])?; + let generation = match reserved.items.into_iter().next() { + Some(item) => item.body?.generation, + None => { + return Err(CacheError::Malformed { + reason: "demo reserve", + }) + } + }; + let committed = client.commit(vec![CommitItem { + header: header(client, 2, epoch), + generation, + length: payload.len() as u64, + checksum: content_checksum(payload), + }])?; + match committed.items.into_iter().next() { + Some(item) => { + item.body?; + Ok(generation) + } + None => Err(CacheError::Malformed { + reason: "demo commit", + }), + } +} + +fn lookup_one( + client: &mut TcpClient, + identity: gpucache_core_types::CacheIdentity, +) -> Result { + let epoch = client.epoch(); + let reply = client.lookup(vec![LookupItem { + header: header(client, 3, epoch), + identity, + }])?; + match reply.items.into_iter().next() { + Some(item) => item.body, + None => Err(CacheError::Malformed { + reason: "demo lookup", + }), + } +} + +fn lookup_raw( + client: &mut TcpClient, + identity: gpucache_core_types::CacheIdentity, +) -> Result { + lookup_one(client, identity) +} + +fn get_one( + client: &mut TcpClient, + generation: u64, + identity: gpucache_core_types::CacheIdentity, +) -> Result, CacheError> { + let epoch = client.epoch(); + let reply = client.get(vec![GetItem { + header: header(client, 4, epoch), + generation, + identity, + pin_ttl_ms: 1_000, + }])?; + match reply.items.into_iter().next() { + Some(item) => Ok(item.body?.payload), + None => Err(CacheError::Malformed { reason: "demo get" }), + } +} + +fn header(client: &TcpClient, n: u128, epoch: u64) -> ItemHeader { + ItemHeader { + request_id: RequestId::from_counter(n).unwrap_or(RequestId([1; 16])), + epoch, + deadline_ms: client.deadline(30_000), + } +} diff --git a/crates/transport/src/frame.rs b/crates/transport/src/frame.rs new file mode 100644 index 0000000..04894fe --- /dev/null +++ b/crates/transport/src/frame.rs @@ -0,0 +1,70 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::io::{Read, Write}; +use std::net::TcpStream; + +use gpucache_core_types::CacheError; + +use crate::pool::Pooled; + +pub struct Envelope { + pub server_now: u64, + pub body: Vec, +} + +pub fn write_envelope( + stream: &mut TcpStream, + server_now: u64, + body: &[u8], + max_frame: usize, +) -> Result<(), CacheError> { + if body.len() > max_frame { + return Err(CacheError::ObjectTooLarge { + limit: max_frame as u64, + actual: body.len() as u64, + }); + } + let payload_len = u32::try_from(8 + body.len()).map_err(|_| CacheError::ObjectTooLarge { + limit: max_frame as u64, + actual: body.len() as u64, + })?; + let mut buf = Vec::with_capacity(4 + payload_len as usize); + buf.extend_from_slice(&payload_len.to_le_bytes()); + buf.extend_from_slice(&server_now.to_le_bytes()); + buf.extend_from_slice(body); + stream.write_all(&buf).map_err(map_io)?; + Ok(()) +} + +pub fn read_envelope( + stream: &mut TcpStream, + mut buffer: Pooled, + max_frame: usize, +) -> Result { + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).map_err(map_io)?; + let payload_len = u32::from_le_bytes(len_buf) as usize; + let max_payload = max_frame.saturating_add(8); + if payload_len < 8 || payload_len > max_payload { + return Err(CacheError::ObjectTooLarge { + limit: max_frame as u64, + actual: payload_len.saturating_sub(8) as u64, + }); + } + buffer.buf_mut().resize(payload_len, 0); + stream.read_exact(buffer.buf_mut()).map_err(map_io)?; + let server_now = u64::from_le_bytes(buffer.buf_mut()[..8].try_into().unwrap_or([0; 8])); + let body = buffer.buf_mut()[8..].to_vec(); + Ok(Envelope { server_now, body }) +} + +pub fn map_io(err: std::io::Error) -> CacheError { + if err.kind() == std::io::ErrorKind::TimedOut || err.kind() == std::io::ErrorKind::WouldBlock { + CacheError::TimedOut + } else { + CacheError::Storage { + reason: "transport io", + } + } +} diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs new file mode 100644 index 0000000..69474e7 --- /dev/null +++ b/crates/transport/src/lib.rs @@ -0,0 +1,20 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 +//! TCP service for the V1 B shared KV tier. +//! +//! The listener defaults to loopback and rejects a start with no tenant policy. +//! Frames are length-delimited. The GCK1 request bytes are unchanged. Replies +//! use GCKR. There is no RDMA, CUDA, or DPU dependency in this crate. + +#![forbid(unsafe_code)] + +mod client; +mod clock; +mod demo; +mod frame; +mod pool; +mod server; + +pub use client::TcpClient; +pub use demo::{run_share_demo, ShareReport}; +pub use server::{serve, ServerConfig, ServerHandle}; diff --git a/crates/transport/src/pool.rs b/crates/transport/src/pool.rs new file mode 100644 index 0000000..91520d0 --- /dev/null +++ b/crates/transport/src/pool.rs @@ -0,0 +1,96 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::Mutex; + +use gpucache_core_types::{CacheError, QuotaKind}; + +/// Fixed number of reusable read buffers. Checkout fails instead of allocating +/// another one once `capacity` buffers exist. +pub struct Pool { + inner: Mutex, +} + +struct PoolInner { + free: Vec>, + allocated: usize, + capacity: usize, +} + +pub struct Pooled { + buf: Vec, + pool: std::sync::Arc, +} + +impl Pool { + pub fn new(capacity: usize) -> Self { + Self { + inner: Mutex::new(PoolInner { + free: Vec::new(), + allocated: 0, + capacity: capacity.max(1), + }), + } + } + + pub fn checkout(self: &std::sync::Arc) -> Result { + let mut guard = self.inner.lock().unwrap_or_else(|err| err.into_inner()); + if let Some(buf) = guard.free.pop() { + return Ok(Pooled { + buf, + pool: std::sync::Arc::clone(self), + }); + } + if guard.allocated >= guard.capacity { + return Err(CacheError::QuotaExceeded { + kind: QuotaKind::InFlightRequests, + }); + } + guard.allocated += 1; + drop(guard); + Ok(Pooled { + buf: Vec::new(), + pool: std::sync::Arc::clone(self), + }) + } +} + +impl Pooled { + pub fn buf_mut(&mut self) -> &mut Vec { + &mut self.buf + } +} + +impl Drop for Pooled { + fn drop(&mut self) { + let buf = std::mem::take(&mut self.buf); + let mut guard = self + .pool + .inner + .lock() + .unwrap_or_else(|err| err.into_inner()); + guard.free.push(buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn checkout_stops_at_capacity() { + let pool = Arc::new(Pool::new(2)); + let a = pool.checkout().unwrap(); + let b = pool.checkout().unwrap(); + assert!(matches!( + pool.checkout(), + Err(CacheError::QuotaExceeded { + kind: QuotaKind::InFlightRequests + }) + )); + drop(a); + assert!(pool.checkout().is_ok()); + drop(b); + } +} diff --git a/crates/transport/src/server.rs b/crates/transport/src/server.rs new file mode 100644 index 0000000..4f89e95 --- /dev/null +++ b/crates/transport/src/server.rs @@ -0,0 +1,250 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use gpucache_core_types::CacheError; +use gpucache_protocol::{ + decode_hello, decode_request, encode_hello_ack, encode_reply, negotiate, request_op, ReplyBody, + RequestBody, OP_HELLO, WIRE_VERSION, +}; +use gpucache_storage::FileStore; + +use crate::clock::mono_ms; +use crate::frame::{read_envelope, write_envelope}; +use crate::pool::Pool; + +#[derive(Clone, Debug)] +pub struct ServerConfig { + pub bind: SocketAddr, + pub allow_remote: bool, + pub max_connections: usize, + pub max_frame_bytes: usize, + pub pool_buffers: usize, + pub io_timeout: Duration, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + bind: SocketAddr::from(([127, 0, 0, 1], 0)), + allow_remote: false, + max_connections: 16, + max_frame_bytes: 1024 * 1024, + pool_buffers: 16, + io_timeout: Duration::from_secs(5), + } + } +} + +impl ServerConfig { + pub fn validate(&self) -> Result<(), CacheError> { + if self.max_connections == 0 || self.pool_buffers == 0 || self.max_frame_bytes < 64 { + return Err(CacheError::Malformed { + reason: "server config", + }); + } + if self.max_frame_bytes > 32 * 1024 * 1024 { + return Err(CacheError::ObjectTooLarge { + limit: 32 * 1024 * 1024, + actual: self.max_frame_bytes as u64, + }); + } + if !self.allow_remote && !self.bind.ip().is_loopback() { + return Err(CacheError::Malformed { + reason: "bind must be loopback", + }); + } + Ok(()) + } +} + +pub struct ServerHandle { + addr: SocketAddr, + shutdown: Arc, + thread: Option>, +} + +impl ServerHandle { + pub fn addr(&self) -> SocketAddr { + self.addr + } +} + +impl Drop for ServerHandle { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Relaxed); + let _ = TcpStream::connect(self.addr); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +struct Shared { + store: Mutex, + pool: Arc, + cfg: ServerConfig, + shutdown: Arc, + connections: AtomicUsize, +} + +/// Bind a loopback TCP cache service. `store` must already have tenant policies. +pub fn serve(store: FileStore, cfg: ServerConfig) -> Result { + cfg.validate()?; + let listener = + TcpListener::bind(cfg.bind).map_err(|_| CacheError::Storage { reason: "bind" })?; + let addr = listener + .local_addr() + .map_err(|_| CacheError::Storage { reason: "bind" })?; + let shutdown = Arc::new(AtomicBool::new(false)); + let shared = Arc::new(Shared { + store: Mutex::new(store), + pool: Arc::new(Pool::new(cfg.pool_buffers)), + cfg, + shutdown: Arc::clone(&shutdown), + connections: AtomicUsize::new(0), + }); + let thread_shared = Arc::clone(&shared); + let thread = std::thread::Builder::new() + .name("gpucache-tcp".to_string()) + .spawn(move || accept_loop(listener, thread_shared)) + .map_err(|_| CacheError::Storage { reason: "bind" })?; + Ok(ServerHandle { + addr, + shutdown, + thread: Some(thread), + }) +} + +fn accept_loop(listener: TcpListener, shared: Arc) { + loop { + if shared.shutdown.load(Ordering::Relaxed) { + break; + } + let Ok((stream, _)) = listener.accept() else { + break; + }; + if shared.shutdown.load(Ordering::Relaxed) { + break; + } + let previous = shared.connections.fetch_add(1, Ordering::Relaxed); + if previous >= shared.cfg.max_connections { + shared.connections.fetch_sub(1, Ordering::Relaxed); + continue; + } + let shared_for_thread = Arc::clone(&shared); + let spawned = std::thread::Builder::new() + .name("gpucache-conn".to_string()) + .spawn(move || { + struct Guard(Arc); + impl Drop for Guard { + fn drop(&mut self) { + self.0.connections.fetch_sub(1, Ordering::Relaxed); + } + } + let _guard = Guard(Arc::clone(&shared_for_thread)); + connection(stream, shared_for_thread); + }); + if spawned.is_err() { + shared.connections.fetch_sub(1, Ordering::Relaxed); + } + } +} + +fn connection(mut stream: TcpStream, shared: Arc) { + let _ = stream.set_read_timeout(Some(shared.cfg.io_timeout)); + let _ = stream.set_write_timeout(Some(shared.cfg.io_timeout)); + let _ = stream.set_nodelay(true); + loop { + if shared.shutdown.load(Ordering::Relaxed) { + break; + } + let pooled = match shared.pool.checkout() { + Ok(buf) => buf, + Err(_) => break, + }; + let envelope = match read_envelope(&mut stream, pooled, shared.cfg.max_frame_bytes) { + Ok(envelope) => envelope, + Err(_) => break, + }; + let reply = { + let mut store = shared.store.lock().unwrap_or_else(|err| err.into_inner()); + handle_frame(&mut store, &envelope.body) + }; + if write_envelope(&mut stream, mono_ms(), &reply, shared.cfg.max_frame_bytes).is_err() { + break; + } + } +} + +fn handle_frame(store: &mut FileStore, body: &[u8]) -> Vec { + if body.len() >= 8 && &body[..4] == b"GCK1" { + let op = u16::from_le_bytes([body[6], body[7]]); + if op == OP_HELLO { + return match decode_hello(body) + .and_then(|versions| negotiate(&[WIRE_VERSION], &versions)) + { + Ok(version) => encode_hello_ack(version, store.epoch()), + Err(err) => fail(OP_HELLO, store.epoch(), &err), + }; + } + } + let now = mono_ms(); + match decode_request(body) { + Ok((auth, request)) => dispatch(store, &auth, request, now), + Err(err) => fail(0, store.epoch(), &err), + } +} + +fn dispatch( + store: &mut FileStore, + auth: &gpucache_protocol::AuthContext, + request: RequestBody, + now: u64, +) -> Vec { + let op = request_op(&request); + let encoded = match request { + RequestBody::Lookup(items) => store + .lookup(auth, now, items) + .map(ReplyBody::Lookup) + .and_then(|body| encode_reply(&body)), + RequestBody::ReservePut(items) => store + .reserve_put(auth, now, items) + .map(ReplyBody::Reserve) + .and_then(|body| encode_reply(&body)), + RequestBody::Commit(items) => store + .commit(auth, now, items) + .map(ReplyBody::Commit) + .and_then(|body| encode_reply(&body)), + RequestBody::Get(items) => store + .get(auth, now, items) + .map(ReplyBody::Get) + .and_then(|body| encode_reply(&body)), + RequestBody::ReleaseCancel(items) => store + .release_cancel(auth, now, items) + .map(ReplyBody::Release) + .and_then(|body| encode_reply(&body)), + RequestBody::Invalidate(items) => store + .invalidate(auth, now, items) + .map(ReplyBody::Invalidate) + .and_then(|body| encode_reply(&body)), + }; + match encoded { + Ok(bytes) => bytes, + Err(err) => fail(op, store.epoch(), &err), + } +} + +fn fail(op: u16, epoch: u64, err: &CacheError) -> Vec { + encode_reply(&ReplyBody::Failed { + op, + store_epoch: epoch, + error: err.clone(), + }) + .unwrap_or_else(|_| encode_hello_ack(WIRE_VERSION, epoch)) +} diff --git a/crates/transport/tests/tcp_path.rs b/crates/transport/tests/tcp_path.rs new file mode 100644 index 0000000..90e9962 --- /dev/null +++ b/crates/transport/tests/tcp_path.rs @@ -0,0 +1,269 @@ +// Copyright 2024-2026 RustFS, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use gpucache_core_types::{ + content_checksum, synthetic_identity, CacheError, RequestId, ResourceLimits, +}; +use gpucache_protocol::{ + AuthContext, CommitItem, ItemHeader, LookupItem, LookupOutcome, MissReason, ReservePutItem, + TenantPolicy, +}; +use gpucache_storage::FileStore; +use gpucache_transport::{run_share_demo, serve, ServerConfig, TcpClient}; + +const FP: [u8; 32] = [9u8; 32]; + +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new() -> Self { + static N: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "gpucache-tcp-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&path); + Self { path } + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn open_store(path: &std::path::Path, limits: ResourceLimits) -> (FileStore, AuthContext) { + let id = synthetic_identity(1, 1); + let policy = TenantPolicy::new(id.tenant.clone(), id.trust_domain.clone(), FP).unwrap(); + let auth = AuthContext::new(id.tenant, id.trust_domain, FP).unwrap(); + let store = FileStore::open(path, limits, vec![policy]).unwrap(); + (store, auth) +} + +fn header(client: &TcpClient, n: u128) -> ItemHeader { + ItemHeader { + request_id: RequestId::from_counter(n).unwrap(), + epoch: client.epoch(), + deadline_ms: client.deadline(30_000), + } +} + +fn put( + client: &TcpClient, + n: u128, + bytes: &[u8], + identity: gpucache_core_types::CacheIdentity, +) -> ReservePutItem { + ReservePutItem { + header: header(client, n), + identity, + generation: 0, + length: bytes.len() as u64, + checksum: content_checksum(bytes), + payload: Some(bytes.to_vec()), + lease_ttl_ms: 30_000, + } +} + +#[test] +fn two_instances_share_hit_miss_and_whole_block_prefix() { + let report = run_share_demo().unwrap(); + assert_eq!(report.exact_hit, "PASS"); + assert_eq!(report.unknown_miss, "PASS"); + assert_eq!(report.partial_prefix, "WHOLE_BLOCK_PREFIX"); + assert_eq!(report.matched_prefix_blocks, 1); + assert_eq!(report.intra_block_slice, "DEGRADED_MISS"); + assert_eq!(report.layout_mismatch, "MISS"); + assert_eq!(report.cross_tenant, "UNAUTHORIZED"); + assert_eq!(report.bytes_match, "PASS"); + let text = report.to_string(); + assert!(text.starts_with("status=REUSE_OK")); +} + +#[test] +fn non_loopback_bind_is_refused_without_an_explicit_flag() { + let dir = TempDir::new(); + let (store, _) = open_store(&dir.path, ResourceLimits::dev_defaults()); + let cfg = ServerConfig { + bind: SocketAddr::from(([0, 0, 0, 0], 0)), + allow_remote: false, + ..ServerConfig::default() + }; + match serve(store, cfg) { + Err(CacheError::Malformed { + reason: "bind must be loopback", + }) => {} + Err(err) => panic!("unexpected error: {err}"), + Ok(_) => panic!("remote bind must be refused"), + } +} + +#[test] +fn wrong_fingerprint_is_unauthorized_and_checksum_mismatch_hides_bytes() { + let dir = TempDir::new(); + let (store, auth) = open_store(&dir.path, ResourceLimits::dev_defaults()); + let server = serve(store, ServerConfig::default()).unwrap(); + let mut client = + TcpClient::connect(server.addr(), auth, Duration::from_secs(5), 1024 * 1024).unwrap(); + let payload = b"checksum-over-tcp".to_vec(); + let identity = synthetic_identity(1, 1); + let reserved = client + .reserve_put(vec![put(&client, 1, &payload, identity.clone())]) + .unwrap(); + let generation = reserved.items[0].body.as_ref().unwrap().generation; + let mut bad = content_checksum(&payload); + bad.0[0] ^= 0xff; + let committed = client + .commit(vec![CommitItem { + header: header(&client, 2), + generation, + length: payload.len() as u64, + checksum: bad, + }]) + .unwrap(); + assert!(matches!( + committed.items[0].body, + Err(CacheError::ChecksumMismatch) + )); + let looked = client + .lookup(vec![LookupItem { + header: header(&client, 3), + identity: identity.clone(), + }]) + .unwrap(); + assert!(matches!( + looked.items[0].body, + Ok(LookupOutcome::Miss { + reason: MissReason::Absent + }) + )); + let bad_auth = AuthContext::new( + identity.tenant.clone(), + identity.trust_domain.clone(), + [3u8; 32], + ) + .unwrap(); + let mut intruder = + TcpClient::connect(server.addr(), bad_auth, Duration::from_secs(5), 1024 * 1024).unwrap(); + let denied = intruder.lookup(vec![LookupItem { + header: header(&intruder, 4), + identity, + }]); + assert!(matches!(denied, Err(CacheError::Unauthorized))); +} + +#[test] +fn restart_and_quota_eviction_survive_the_tcp_path() { + let dir = TempDir::new(); + let mut limits = ResourceLimits::dev_defaults(); + limits.max_object_bytes = 16; + limits.max_retained_bytes = 32; + limits.max_retained_bytes_per_tenant = 32; + limits.max_in_flight_bytes_per_tenant = 32; + let payload_a = vec![1u8; 16]; + let payload_b = vec![2u8; 16]; + let payload_c = vec![3u8; 16]; + let mut id_a = synthetic_identity(1, 1); + id_a.prefix_chain[0].0[0] = 1; + let mut id_b = synthetic_identity(1, 1); + id_b.prefix_chain[0].0[0] = 2; + let mut id_c = synthetic_identity(1, 1); + id_c.prefix_chain[0].0[0] = 3; + let auth = { + let (store, auth) = open_store(&dir.path, limits); + let server = serve(store, ServerConfig::default()).unwrap(); + let mut client = TcpClient::connect( + server.addr(), + auth.clone(), + Duration::from_secs(5), + 1024 * 1024, + ) + .unwrap(); + commit_tcp(&mut client, 10, &payload_a, id_a.clone()); + drop(client); + drop(server); + auth + }; + let store = FileStore::open( + &dir.path, + limits, + vec![TenantPolicy::new(auth.tenant.clone(), auth.trust_domain.clone(), FP).unwrap()], + ) + .unwrap(); + let server = serve(store, ServerConfig::default()).unwrap(); + let mut client = + TcpClient::connect(server.addr(), auth, Duration::from_secs(5), 1024 * 1024).unwrap(); + let looked = client + .lookup(vec![ + LookupItem { + header: header(&client, 30), + identity: id_a.clone(), + }, + LookupItem { + header: header(&client, 31), + identity: id_b.clone(), + }, + ]) + .unwrap(); + assert!(matches!( + looked.items[0].body, + Ok(LookupOutcome::Hit { .. }) + )); + assert!(matches!( + looked.items[1].body, + Ok(LookupOutcome::Miss { .. }) + )); + commit_tcp(&mut client, 40, &payload_b, id_b.clone()); + commit_tcp(&mut client, 50, &payload_c, id_c.clone()); + let after = client + .lookup(vec![LookupItem { + header: header(&client, 60), + identity: id_a, + }]) + .unwrap(); + assert!(matches!( + after.items[0].body, + Ok(LookupOutcome::Miss { .. }) + )); + let kept = client + .lookup(vec![LookupItem { + header: header(&client, 61), + identity: id_c, + }]) + .unwrap(); + assert!(matches!(kept.items[0].body, Ok(LookupOutcome::Hit { .. }))); +} + +fn commit_tcp( + client: &mut TcpClient, + n: u128, + bytes: &[u8], + identity: gpucache_core_types::CacheIdentity, +) { + let reserved = client + .reserve_put(vec![put(client, n, bytes, identity)]) + .unwrap(); + let generation = reserved.items[0].body.as_ref().unwrap().generation; + client + .commit(vec![CommitItem { + header: header(client, n + 1), + generation, + length: bytes.len() as u64, + checksum: content_checksum(bytes), + }]) + .unwrap() + .items[0] + .body + .as_ref() + .unwrap(); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4b81f6f..e4cf3b6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,22 +1,27 @@ -# Architecture (V1 A) +# Architecture (V1 B) -GPUCache is a shared KV data tier. The hot tier is G3.5. RustFS is an optional colder G4 capacity tier and is not implemented in this milestone. The inference engine still owns tokenization, attention, and scheduling. This repo does not replace them. +GPUCache is a shared KV data tier. The hot tier is G3.5. RustFS is an optional colder G4 capacity tier. It is not on the prefill-to-decode path. The inference engine still owns tokenization, attention, and scheduling. This repo does not replace them. ```text engine (not in tree; spike NOT_RUN_HARDWARE) | | CacheIdentity + batch API v -gpucache-protocol +gpucache-transport TCP, loopback by default, tenant fingerprint + | + v +gpucache-storage::FileStore --> directory of ordinary files | v gpucache-storage::MemoryStore | v gpucache-core-types (identity, keys, lifecycle, errors) + +gpucache-cold optional G4 pack/manifest. Not linked by transport. ``` -Planned crates that are **not** in V1 A: `metadata` as a standalone service, `transport` (TCP), file/NVMe store, RustFS object store, engine adapter, DPU agent, bench harness, metrics. Their contracts are reserved so later milestones do not push GPU or DPU types into `core-types`. +`MemoryStore` remains the in-process contract. `FileStore` is what the TCP service uses. Planned crates still absent: a standalone metadata service, an engine adapter, a DPU agent, a bench harness, and metrics. Their contracts stay out of `core-types` so GPU and DPU types do not leak in. ## Durability @@ -33,7 +38,9 @@ Keys: - Logical prefix key (`GPCL`): tenant, model, tokenizer, adapter, prefix hashes, multimodal digests. Layout and shard are absent on purpose. - Physical byte key (`GPCK`): the logical fields plus dtype, quant, layout, shard, RoPE, and lengths. -Both encodings are versioned and length-delimited. V1 hits only when the physical key matches a `COMMITTED` block. A different physical key with the same logical key is an incompatible miss. Partial-prefix reuse is not implemented; a shorter chain is a different key. +Both encodings are versioned and length-delimited. An exact hit requires the physical key and a `COMMITTED` block. A different physical key with the same logical key is an incompatible miss. + +A longer hash chain can return `PrefixHit` for a committed block whose chain is a strict prefix, when layout and the other byte-affecting fields match. That hit is the whole stored block. See [adr/0003-partial-prefix.md](adr/0003-partial-prefix.md). The store does not slice a block down to a shorter `valid_length`. ## Lifecycle @@ -43,14 +50,18 @@ ABSENT -> RESERVED -> WRITING -> COMMITTED -> EVICTING -> ABSENT \-> ABSENT (no buffer was registered) ``` -`COMMITTED` is the only readable state, and only when the stored checksum still matches the bytes. `Get` of anything else returns an error and no payload. `Lookup` returns a miss reason and no payload. +`COMMITTED` is the only readable state, and only when the stored checksum still matches the bytes. `Get` of anything else returns an error and no payload. `Lookup` returns a hit, a whole-block `PrefixHit`, or a miss reason. It does not return payload bytes. `QUARANTINED` means a registered region might still be in use. Timeout, lease expiry, and `KnownIdle` do not free it. Reclaim requires either `Attestation::Drained` from an authorized tenant caller, or `bump_epoch` plus `reclaim_fenced` with the new epoch. Region ids are monotonic and are not recycled. ## Quotas -`ResourceLimits` bounds object size, in-flight requests, registrations (including committed and quarantined buffers), per-tenant in-flight bytes, global retained bytes, and per-tenant retained bytes. The idempotency table is capped and entries expire. Over-capacity admission fails; V1 A does not silently evict a committed block to make room. `Invalidate` is the explicit eviction path. +`ResourceLimits` bounds object size, in-flight requests, registrations (including committed and quarantined buffers), per-tenant in-flight bytes, global retained bytes, and per-tenant retained bytes. The idempotency table is capped and entries expire. `MemoryStore` still refuses admission instead of evicting. `FileStore` evicts the oldest unpinned committed block when a retained-byte quota would fail. It does not evict another tenant to satisfy a tenant quota, and it does not evict a pinned or quarantined block. `Invalidate` remains the explicit client path. ## Process model -`MemoryStore` is single-threaded and in-process. The cross-process test exports a committed frame and runs `kv-block-xfer` in a child, which imports through the same public API. That is a wire-format check, not a network service. TCP is V1 B. +`MemoryStore` is single-threaded and in-process. The cross-process test exports a committed frame and runs `kv-block-xfer` in a child. The TCP service is a thread per connection with one mutex around `FileStore`. The read-buffer pool is bounded. Restart reloads committed files, bumps the epoch, and drops in-flight reservations. A corrupt file is skipped and is not a hit. + +## Cold tier + +`gpucache-cold` packs several blocks into one object and a manifest of payload offsets. Put, Head, RangeGet, and Delete are a trait. The mock implements them in memory. A live HTTP client runs only when the operator sets `GPUCACHE_RUSTFS_*`. Transport does not depend on this crate. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index f96bae9..8ce2409 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -1,12 +1,12 @@ # Benchmarks -No performance run was executed for V1 A. +No token-goodput run was executed for V1 A or V1 B. The TCP tests check reuse, quotas, checksums, and restart. They are not a bandwidth result. | Comparison the V1 exit criteria ask for | Result | | --- | --- | | Engine-native prefix cache | `NOT_RUN_HARDWARE` | | Same-box local offload | `NOT_RUN_HARDWARE` | -| GPUCache CPU/TCP | `NOT_RUN` (TCP service is V1 B; no tokens were served) | +| GPUCache CPU/TCP | Correctness `PASS` via `cargo test` and `share-demo`. Token goodput `NOT_RUN_HARDWARE` (no engine, no GPU) | | Cold / warm / no reuse / multi-turn / HBM cliff | `NOT_RUN_HARDWARE` | Memcpy or a synthetic store bandwidth number is not token goodput and is not reported. There is no `FAIL_PERFORMANCE` figure because there is no paired measurement to fail. The CPU contract tests in `cargo test --workspace --all-targets` check correctness only. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 25b2e45..d0b12d5 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -1,6 +1,6 @@ -# Operations (V1 A) +# Operations (V1 B) -V1 A is a library plus one import helper. There is no daemon, no package, and no production config. +V1 B is a library, an import helper, and a localhost TCP service. There is no package and no production config. The service creates files only under the directory you pass. It does not format NVMe, change a NIC, or touch a DPU. ## Reproduce the CPU tests @@ -20,6 +20,46 @@ cargo run -p gpucache-storage --bin kv-block-xfer -- import <64-hex-char fingerp The fingerprint is the tenant credential hash, not a prompt. The tool creates a one-tenant `MemoryStore`, commits the block, and prints `status=IMPORTED` only after lookup and get succeed. A checksum failure exits 2 and prints `error=` on stderr. This path uses synthetic or caller-supplied bytes. It does not contact vLLM. +## Two-client reuse + +```bash +cargo run -p gpucache-transport --bin share-demo +``` + +This starts a loopback server on an ephemeral port, writes one synthetic block from one client, and reads it from a second client. Success is `status=REUSE_OK`. `partial_prefix=WHOLE_BLOCK_PREFIX` means the longer chain reused the whole shorter block. `intra_block_slice=DEGRADED_MISS` means a shorter `valid_length` was not sliced out of that block. + +## TCP service + +```bash +cargo run -p gpucache-transport --bin gpucache-tcp -- serve \ + --data-dir /path/to/an/empty/test/dir \ + --tenant tenant-a \ + --trust td-local \ + --fingerprint <64 hex chars of a credential hash> +``` + +Default `--bind` is `127.0.0.1:9735`. `--allow-remote` is required before a non-loopback address is accepted. The process still checks the fingerprint. + +### Config schema + +| Field | Default | Meaning | +| --- | --- | --- | +| bind | `127.0.0.1:9735` (binary), port `0` in tests | Socket. Non-loopback needs `allow_remote` | +| data_dir | required, no default | Directory of `MANIFEST`, `LRU`, and `blocks/*.gblk` | +| tenant, trust, fingerprint | required | One V1 policy. Fingerprint is 32 bytes, not all zero | +| allow_remote | false | Does not disable auth | +| max_connections | 16 | Extra connections are dropped | +| max_frame_bytes | 1 MiB in the library, 2 MiB in the binary | Body cap. Larger declared lengths are rejected before read | +| pool_buffers | 16 | Read-buffer pool. Checkout fails at the cap | +| io_timeout | 5 s | Read and write timeout on each connection | +| ResourceLimits | `dev_defaults()` | Object size, in-flight, registrations, retained bytes | + +There is no on-disk config file. Flags are the schema. + +## RustFS probe + +Leave `GPUCACHE_RUSTFS_ENDPOINT`, `GPUCACHE_RUSTFS_BUCKET`, `GPUCACHE_RUSTFS_PREFIX`, `GPUCACHE_RUSTFS_ACCESS_KEY`, and `GPUCACHE_RUSTFS_SECRET_KEY` unset unless an operator has authorized a test bucket. Unset means `NOT_RUN_HARDWARE`. The mock smoke still runs inside `cargo test`. `https://` is `NOT_RUN_HARDWARE` in this milestone because no TLS stack is linked. Do not invent credentials. + ## Not operated here -No NVMe format, no DPU mode change, no firmware, no IOMMU or BIOS change, no production network change, no RustFS bucket. Those need a separate authorization. There is no website deploy and no crates.io publish in this milestone. +No NVMe format, no DPU mode change, no firmware, no IOMMU or BIOS change, no production network change. Those need a separate authorization. There is no website deploy and no crates.io publish in this milestone. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 02e69c6..76f8762 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -1,6 +1,6 @@ # Protocol (wire version 1) -V1 A defines the messages. It does not open a socket. A later TCP listener must speak this framing unchanged. +Request frames are unchanged from V1 A. V1 B adds reply frames and a TCP envelope. The TCP listener speaks the V1 A request framing as the envelope body. ## Framing @@ -50,4 +50,22 @@ Request deadlines abort the call. Writer leases move `RESERVED` with no buffer b ## Compatibility -A hit requires the physical key and a `COMMITTED` block. Logical-key overlap with a different layout, dtype, quant, or shard is a miss. V1 A does not assemble a partial prefix. Engines that need a shorter hit must ask again at V1 B, which must either implement a safe partial-prefix rule or keep whole-block hits. +A hit requires the physical key and a `COMMITTED` block. Logical-key overlap with a different layout, dtype, quant, or shard is a miss. + +`PrefixHit` is a whole stored block whose hash chain is a strict prefix of the query and whose layout-affecting fields match. `matched_prefix_blocks` is the stored chain length. `Get` uses `identity_for_prefix_hit`. The same chain with a different `valid_length` is an incompatible miss: the service does not return a sliced payload. See [adr/0003-partial-prefix.md](adr/0003-partial-prefix.md). + +## Replies + +Reply magic is `GCKR`, version 1. `op` matches the request (`1` through `6`). `op` `0` is a batch-level failure and carries the request op, the store epoch, and an error code. Item status `1` is success and `0` is an item error. Lookup success tag `1` is an exact hit, `2` is `PrefixHit`, `3` is a miss. Static error strings are not on the wire; decoded remote errors use the reason `remote`. + +## TCP envelope + +Each TCP message is: + +| Field | Contents | +| --- | --- | +| payload_len | `u32` little-endian, includes the clock field | +| server_now_ms | `u64` little-endian. Clients send `0`. The server sends its monotonic clock | +| body | a `GCK1` request or hello, or a `GCKR` reply or hello ack | + +Deadlines are absolute on the server clock. `TcpClient` samples `server_now_ms` from the hello ack and from each reply. A length above the configured frame cap is rejected before the body is read. The server read-buffer pool does not grow past its capacity. diff --git a/docs/RISKS.md b/docs/RISKS.md index a1bddd1..3b20bbd 100644 --- a/docs/RISKS.md +++ b/docs/RISKS.md @@ -1,4 +1,4 @@ -# Risks (V1 A) +# Risks (V1 B) | Risk | Status | Note | | --- | --- | --- | @@ -8,7 +8,11 @@ | Idempotency window expiry | Accepted | After TTL, a request id may run again. Callers must not reuse ids they still care about. | | `Drained` is only tenant-authenticated | Accepted for V1 A | Not a device proof. Documented in SECURITY.md. | | No real engine bytes | `NOT_RUN_HARDWARE` | Synthetic frames can pass while a real vLLM layout still mismatches. | -| No TCP, NVMe, or RustFS | Not started | V1 B. A mock must not be described as a measured RustFS run. | +| TCP exposure | Mitigated for the default bind | Loopback unless `--allow-remote`. Empty policy and bad fingerprints fail closed. No mTLS yet. | +| File backend | Mitigated for the test directory | Ordinary files only. Device paths are rejected. A corrupt file is a miss after restart. | +| LRU admission | Mitigated with a pin check | Unpinned committed blocks can be evicted. Pinned and quarantined blocks are not. After a missing `LRU` file, order falls back to ascending generation. | +| Partial prefix | Documented in ADR 0003 | Whole-block prefix only. A client that ignores `matched_prefix_blocks` can mis-align. Intra-block slice is a miss. | +| RustFS live probe | `NOT_RUN_HARDWARE` | Mock Put/Head/RangeGet/Delete passes. A mock is not a measured RustFS run. | | No RDMA / DPU measurement | `NOT_RUN_HARDWARE` | Do not infer a speedup from this milestone. | | License of a future DOCA or model stack | `NOT_RUN_LICENSE` | No DOCA binary and no model weights are in the tree. | | GPU rental or DPU purchase | `NOT_RUN_BUDGET` | No paid API, GPU, or DPU was used. | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f267fea..5bcb5f3 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,6 +1,8 @@ -# Security (V1 A) +# Security (V1 B) -V1 A is a library. It does not listen on a network. Binding it to a public address without authentication would violate the product rule; nothing in this milestone opens a socket. +The library calls still require an `AuthContext`. The TCP service does too. It binds `127.0.0.1` unless the operator sets `--allow-remote`, and it refuses to start with an empty tenant policy. A data request with an unknown tenant, a mismatched trust domain, or a wrong fingerprint is `Unauthorized` and returns no payload. Hello carries versions and the store epoch only. + +The fingerprint is a hash of a credential, never the credential. Do not pass a raw secret on the command line in a place that records process arguments if you can avoid it; V1 still takes the hex fingerprint as an argument because there is no secret store yet. An all-zero fingerprint is rejected before a request is built. ## What is enforced @@ -17,7 +19,9 @@ V1 A is a library. It does not listen on a network. Binding it to a public addre - mTLS, replay protection beyond the idempotency window, secret rotation, and audit shipping are later work (the product note puts production hardening in V3; V1 still requires this minimum gate before any network exposure). - `Attestation::Drained` is accepted from any caller who already passed the tenant gate. It is not a device measurement. A buggy client inside the tenant can reclaim its own in-flight buffer early. - The idempotency table forgets entries after `idempotency_ttl_ms` or when it is full (new ids are then rejected until expiry). Reusing an id after expiry can run the operation again. -- This crate does not encrypt KV bytes at rest. MemoryStore keeps them in process memory. +- This tree does not encrypt KV bytes at rest. `MemoryStore` keeps them in process memory. `FileStore` writes them as ordinary files in the directory you name. That directory must not be a production disk or a device node. The open path rejects `/dev`, `/proc`, `/sys`, `/`, and `..`. +- TCP has no mTLS and no replay window beyond idempotency. Do not expose it past a trusted host. `--allow-remote` does not add authentication; the fingerprint check is still the only gate. +- `PrefixHit` bytes cover only `matched_prefix_blocks` of the query chain. A client that ignores that field and decodes them as the full query will mis-align. The server will not do that alignment for the client. ## Fallback diff --git a/docs/V1B_TEST_MATRIX.md b/docs/V1B_TEST_MATRIX.md new file mode 100644 index 0000000..92575c5 --- /dev/null +++ b/docs/V1B_TEST_MATRIX.md @@ -0,0 +1,33 @@ +# V1 B test matrix + +Recorded on the V1 B build host after `cargo test --workspace --all-targets` and `cargo run -p gpucache-transport --bin share-demo`. This is not a GPU, DPU, or token-goodput report. + +| Check | Result | Where | +| --- | --- | --- | +| TCP Lookup / Reserve-Put / Commit / Get, two clients, exact hit | PASS | `crates/transport/tests/tcp_path.rs`, `share-demo` | +| Miss, layout mismatch, cross-tenant unauthorized | PASS | `share-demo` prints `unknown_miss=PASS`, `layout_mismatch=MISS`, `cross_tenant=UNAUTHORIZED` | +| Whole-block prefix hit; intra-block slice stays a miss | PASS | `share-demo` prints `partial_prefix=WHOLE_BLOCK_PREFIX` and `intra_block_slice=DEGRADED_MISS` | +| Wrong fingerprint unauthorized; bad checksum hides bytes | PASS | `wrong_fingerprint_is_unauthorized_and_checksum_mismatch_hides_bytes` | +| Non-loopback bind refused | PASS | `non_loopback_bind_is_refused_without_an_explicit_flag` | +| Tenant and global quotas, LRU eviction, pinned block kept | PASS | `crates/storage/tests/file_backend.rs` and the TCP restart/eviction test | +| Corrupt file is not a hit after restart | PASS | `corrupt_file_is_not_a_hit_after_restart` | +| Restart reloads a committed block and bumps the epoch | PASS | `restart_reloads_committed_bytes_and_bumps_epoch`, TCP restart test | +| RustFS mock Put / Head / RangeGet / Delete plus pack and manifest | PASS | `crates/cold` `mock_put_head_range_get_delete` | +| RustFS live endpoint | NOT_RUN_HARDWARE | `missing_endpoint_is_not_run_hardware` when `GPUCACHE_RUSTFS_ENDPOINT` is unset | +| vLLM KV export/import and ADR 0001 route choice | NOT_RUN_HARDWARE | ADR 0001 stays Pending | +| aarch64 on this x86_64 host | NOT_RUN | CI jobs `test-aarch64` and `test-aarch64-qemu` still run the same `cargo test` | +| Token goodput vs engine prefix cache | NOT_RUN_HARDWARE | No GPU and no engine. See `docs/BENCHMARKS.md` | +| DOCA / model license review | NOT_RUN_LICENSE | No DOCA binary and no model weights | +| Paid GPU or DPU | NOT_RUN_BUDGET | No rental and no purchase | + +One-command CPU repro: + +```bash +cargo test --workspace --all-targets +``` + +Two-instance demo: + +```bash +cargo run -p gpucache-transport --bin share-demo +``` diff --git a/docs/adr/0001-integration-route.md b/docs/adr/0001-integration-route.md index e85ee94..29844d0 100644 --- a/docs/adr/0001-integration-route.md +++ b/docs/adr/0001-integration-route.md @@ -17,7 +17,7 @@ V1 must pick one primary path. Maintaining both half-implemented is how layout b **No route is selected.** The spike was not run. -This environment has no GPU, no approved model weights, and no budget for a rented accelerator. The spike is `NOT_RUN_HARDWARE`. V1 A therefore stops at the CPU contract: versioned identity, the batch protocol, and `MemoryStore`. Neither vLLM nor LMCache is a crate dependency. +This environment has no GPU, no approved model weights, and no budget for a rented accelerator. The spike is `NOT_RUN_HARDWARE`. V1 A stopped at the CPU contract. V1 B adds TCP and a file backend and still does not select a route. Neither vLLM nor LMCache is a crate dependency. Status stays **Pending** until the spike exists. When a GPU is authorized, the spike should: diff --git a/docs/adr/0002-no-erasure-coding.md b/docs/adr/0002-no-erasure-coding.md index b4e1b4a..b1c2582 100644 --- a/docs/adr/0002-no-erasure-coding.md +++ b/docs/adr/0002-no-erasure-coding.md @@ -23,6 +23,6 @@ DOCA EC, SPDK RAID targets, and similar offloads are not GPUCache components. Th ## Consequences - The README and roadmap do not list an EC milestone. -- The V1 A crates have no EC types, no parity buffers, and no replica placement. -- RustFS remains responsible for any durable cold object it is later asked to store. That client is not in V1 A. +- The V1 crates have no EC types, no parity buffers, and no replica placement. The V1 B file backend deletes or skips a bad file; it does not rebuild one. +- RustFS remains responsible for any durable cold object it is asked to store. The V1 B cold client is off the hot path and is a contract mock unless an authorized endpoint is configured. - A cache miss after a node loss is correct behavior. The KV block is not the user's only copy. diff --git a/docs/adr/0003-partial-prefix.md b/docs/adr/0003-partial-prefix.md new file mode 100644 index 0000000..a755704 --- /dev/null +++ b/docs/adr/0003-partial-prefix.md @@ -0,0 +1,25 @@ +# ADR 0003: Partial prefix reuse is a whole stored block + +- Status: **Accepted** +- Date: 2026-10-15 +- Milestone: V1 B + +## Context + +A shared KV lookup often asks for a token prefix longer than the blocks already stored. Returning a suffix of a stored block, or bytes that correspond to a longer chain than the caller asked for, would mis-align decode. V1 A only hit on an exact physical key. + +## Decision + +V1 B returns three lookup results: + +- `Hit` — the physical key matches a committed block. The bytes are that whole block. +- `PrefixHit` — a committed block's hash chain is a strict prefix of the query, and tenant, model, tokenizer, adapter, dtype, quant, layout, shard, RoPE, and block token count match. `matched_prefix_blocks` is the stored chain length. `Get` must use `identity_for_prefix_hit`. A `Get` with the longer query identity is `Incompatible`. +- `Miss` — including a same-chain identity whose `valid_length` differs. That case is not a sliced block. It stays a miss. + +The service does not cut a payload down to a shorter `valid_length`. If a caller needs bytes for a prefix that was not stored as its own block, the result is a miss and the engine recomputes. + +## Consequences + +- `share-demo` prints `partial_prefix=WHOLE_BLOCK_PREFIX` and `intra_block_slice=DEGRADED_MISS`. +- A wrong-layout or wrong-tenant query never becomes a `PrefixHit`. +- Engines must branch on the variant. Treating every success as the full query identity is a client bug.