diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 4d7cce4..dbb8fcb 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -29,4 +29,4 @@ jobs: shared-key: cargo-${{ inputs.runner }} # - name: Integration tests - # run: cargo nextest run --tests integration + # run: cargo nextest run --tests it diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index fad277b..e370ddb 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -29,7 +29,7 @@ jobs: shared-key: cargo-${{ inputs.runner }} - name: Unit tests (no features) - run: cargo nextest run + run: cargo nextest run --lib - name: Doc tests run: cargo test --doc --all-features diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7e9d231 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# SparseIO Agent Guide + +## Library Development Standards + +SparseIO is a Rust library, not an application runtime. Prefer futures-oriented crates and APIs that remain usable from Tokio, smol, async-std, and other executors. + +- Do not add Tokio as a normal library dependency unless the public API explicitly requires Tokio types. +- Runtime-specific crates are acceptable in tests, examples, or feature-gated integrations when they do not leak into the core API. +- Use executor-neutral primitives for library behavior. For example, prefer timer futures such as `futures-timer` over `tokio::time` in core code. +- Keep async traits object-safe for registry and builder usage. The current backend traits use `async_trait` and are stored behind trait objects. +- Prefer owned `Bytes` when transferring large payloads into storage-like APIs. Avoid forcing callers or mock implementations to clone large byte buffers. + +## Commenting Standards + +Write comments for future maintainers, not for the compiler. + +- Add brief rustdoc comments to structs, traits, public methods, and `pub(crate)` utilities that are meant to be reused across tests or modules. +- Describe behavior, constraints, or intent that is useful when changing the code later. +- Avoid comments that restate the function name or obvious Rust syntax. +- Keep comments short. One sentence is usually enough. +- Use inline comments sparingly, only when a small implementation detail would otherwise be easy to misread. +- Keep tests readable through names and assertions first; add comments only when the scenario itself is non-obvious. +- Avoid in-line context comments regarding conversations as they provide no value to the codebase. A good rule of thumb is that if it wont provide value to someone 5 commits down the road, it doesn't deserve to be a comment. + +## Project Layout + +The crate is organized around a small public API with implementation details split by responsibility. + +- Keep public API surfaces in the top-level `src/` modules. +- Keep backend traits and shared public types separate from concrete implementations. +- Keep test helpers and low-level support code under `src/utils/`. +- Keep architecture and design context in `docs/`; update it when behavior or architecture changes materially. +- Prefer small modules with clear ownership over broad utility buckets. + +## Test Utilities + +Test utilities should stay lightweight, deterministic, and easy to reason about. + +- Prefer simple in-memory implementations over elaborate fixtures. +- Keep simulated latency and randomness deterministic. +- Use explicit zero-latency profiles in tests that need immediate completion or precise timing. +- Avoid making test helpers more capable than the scenarios they support. + +## Dependency Guidance + +Keep the core dependency graph small and runtime-neutral. + +- Normal dependencies should support library internals or public API requirements. +- Dev dependencies may use Tokio for `#[tokio::test]` and other test-only needs. +- Before adding a dependency, check whether the standard library or an existing crate dependency is sufficient. +- Avoid dependencies that force a specific executor, global runtime, or background task model into core library code. + +## Verification + +Run the relevant checks before handing off code changes. + +```bash +cargo fmt --check +cargo test +cargo clippy --all-targets -- -D warnings +``` + +The repository currently has rustfmt settings that may warn on stable Rust about nightly-only options. Treat formatting failures as actionable; the nightly-option warnings alone are expected. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d82b619..120554f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bitflags" version = "2.12.1" @@ -20,6 +31,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "libc" version = "0.2.186" @@ -58,6 +75,30 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -83,10 +124,51 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" name = "sparseio" version = "0.0.1" dependencies = [ + "async-trait", "bytes", + "futures-timer", "parking_lot", + "tokio", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 87a7ef7..ac1f3c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,8 @@ license = "Apache-2.0" [dependencies] parking_lot = "0.12" bytes = "1.11" +async-trait = "0.1" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt", "time"] } +futures-timer = "3.0" diff --git a/src/builder.rs b/src/builder.rs index 94d7561..929cf63 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -93,21 +93,21 @@ mod tests { use super::Builder; use crate::ReaderRegistry; - use crate::utils::metadatas::StubMetadata; - use crate::utils::readers::StubReader; - use crate::utils::writers::StubWriter; + use crate::utils::metadatas::MockMetadata; + use crate::utils::readers::MockReader; + use crate::utils::writers::MockWriter; - fn writer() -> StubWriter { - StubWriter + fn writer() -> MockWriter { + MockWriter::new() } - fn metadata() -> StubMetadata { - StubMetadata + fn metadata() -> MockMetadata { + MockMetadata::new() } fn registry() -> ReaderRegistry { let mut registry = ReaderRegistry::new(); - registry.register("stub", StubReader); + registry.register("stub", MockReader::new(bytes::Bytes::new())); registry } diff --git a/src/registry.rs b/src/registry.rs index 9b49ec4..e09e60a 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -33,20 +33,22 @@ impl ReaderRegistry { mod tests { use std::io; + use async_trait::async_trait; use bytes::Bytes; use super::ReaderRegistry; use crate::Reader; - use crate::utils::readers::StubReader; + use crate::utils::readers::MockReader; struct LenReader(usize); + #[async_trait] impl Reader for LenReader { - fn len(&self) -> io::Result { + async fn len(&self) -> io::Result { Ok(self.0) } - fn read_at(&self, _offset: usize, _length: usize) -> io::Result { + async fn read_at(&self, _offset: usize, _length: usize) -> io::Result { unimplemented!("registry tests should not call reader read_at") } } @@ -69,14 +71,14 @@ mod tests { fn register_makes_reader_retrievable_by_name() { let mut registry = ReaderRegistry::new(); - registry.register("stub", StubReader); + registry.register("stub", MockReader::new(Bytes::new())); assert!(registry.get("stub").is_some()); assert!(registry.get("other").is_none()); } - #[test] - fn register_replaces_existing_reader_for_name() { + #[tokio::test] + async fn register_replaces_existing_reader_for_name() { let mut registry = ReaderRegistry::new(); registry.register("source", LenReader(10)); @@ -84,6 +86,6 @@ mod tests { let reader = registry.get("source").expect("replacement reader should be registered"); - assert_eq!(reader.len().expect("len should be readable"), 20); + assert_eq!(reader.len().await.expect("len should be readable"), 20); } } diff --git a/src/traits.rs b/src/traits.rs index 4b8749d..e74900d 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -2,38 +2,42 @@ use std::io; +use async_trait::async_trait; use bytes::Bytes; /// Reads explicit byte ranges from an upstream data source. +#[async_trait] #[allow(clippy::len_without_is_empty)] pub trait Reader: Send + Sync { /// Return the total number of bytes in the source object. - fn len(&self) -> io::Result; + async fn len(&self) -> io::Result; /// Read `length` bytes beginning at `offset`. - fn read_at(&self, offset: usize, length: usize) -> io::Result; + async fn read_at(&self, offset: usize, length: usize) -> io::Result; } /// Stores and retrieves cached byte ranges. +#[async_trait] pub trait Writer: Send + Sync { /// Write bytes into the cache under `key`. - fn write(&self, key: &str) -> io::Result<()>; + async fn write(&self, key: &str, value: Bytes) -> io::Result<()>; /// Read a byte range from the cache. - fn read(&self, key: &str) -> io::Result>; + async fn read(&self, key: &str) -> io::Result>; /// Delete all cached data associated with `key`. - fn delete(&self, key: &str) -> io::Result<()>; + async fn delete(&self, key: &str) -> io::Result<()>; } /// Key-value metadata store used for coverage maps and CAS reference counts. +#[async_trait] pub trait Metadata: Send + Sync { /// Retrieve a value from the metadata store. - fn get(&self, key: &str) -> io::Result>; + async fn get(&self, key: &str) -> io::Result>; /// Persist a value in the metadata store. - fn set(&self, key: &str, value: Bytes) -> io::Result<()>; + async fn set(&self, key: &str, value: Bytes) -> io::Result<()>; /// Delete a value from the metadata store. - fn delete(&self, key: &str) -> io::Result<()>; + async fn delete(&self, key: &str) -> io::Result<()>; } diff --git a/src/utils/metadatas/mock_metadata.rs b/src/utils/metadatas/mock_metadata.rs new file mode 100644 index 0000000..7922910 --- /dev/null +++ b/src/utils/metadatas/mock_metadata.rs @@ -0,0 +1,103 @@ +use std::collections::HashMap; +use std::io; + +use async_trait::async_trait; +use bytes::Bytes; +use parking_lot::RwLock; + +use crate::Metadata; +use crate::utils::rand::Latency; + +#[derive(Default)] +/// In-memory metadata store backed by a lock-protected byte map. +pub(crate) struct MockMetadata { + values: RwLock>, + latency: Latency, +} + +impl MockMetadata { + /// Create an empty metadata store using the default cache latency profile. + pub(crate) fn new() -> Self { + Self { + values: RwLock::new(HashMap::new()), + latency: Latency::intra_dc(), + } + } + + /// Create an empty metadata store with an explicit latency profile. + pub(crate) fn with_latency(latency: Latency) -> Self { + Self { + values: RwLock::new(HashMap::new()), + latency, + } + } +} + +#[async_trait] +impl Metadata for MockMetadata { + /// Read the metadata value currently stored for a key. + async fn get(&self, key: &str) -> io::Result> { + self.latency.pause().await; + + Ok(self.values.read().get(key).cloned()) + } + + /// Store a metadata value, replacing any existing value. + async fn set(&self, key: &str, value: Bytes) -> io::Result<()> { + self.latency.pause().await; + self.values.write().insert(key.to_owned(), value); + + Ok(()) + } + + /// Remove a key from the mock metadata store. + async fn delete(&self, key: &str) -> io::Result<()> { + self.latency.pause().await; + self.values.write().remove(key); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use bytes::Bytes; + + use super::MockMetadata; + use crate::Metadata; + use crate::utils::rand::Latency; + + #[tokio::test] + async fn set_get_and_delete_value() { + let metadata = MockMetadata::new(); + + metadata + .set("key", Bytes::from_static(b"metadata")) + .await + .expect("set should succeed"); + assert_eq!(metadata.get("key").await.expect("get should succeed"), Some(Bytes::from_static(b"metadata"))); + + metadata.delete("key").await.expect("delete should succeed"); + assert_eq!(metadata.get("key").await.expect("get should succeed"), None); + } + + #[tokio::test] + async fn delete_missing_key_succeeds() { + let metadata = MockMetadata::new(); + + metadata.delete("missing").await.expect("delete should succeed"); + } + + #[tokio::test] + async fn can_construct_with_latency() { + let metadata = MockMetadata::with_latency(Latency::new(Duration::ZERO, Duration::ZERO, 1)); + + metadata + .set("key", Bytes::from_static(b"metadata")) + .await + .expect("set should succeed"); + assert_eq!(metadata.get("key").await.expect("get should succeed"), Some(Bytes::from_static(b"metadata"))); + } +} diff --git a/src/utils/metadatas/mod.rs b/src/utils/metadatas/mod.rs index f35e21f..eac8afd 100644 --- a/src/utils/metadatas/mod.rs +++ b/src/utils/metadatas/mod.rs @@ -1,3 +1,3 @@ -mod stub_metadata; +mod mock_metadata; -pub(crate) use stub_metadata::StubMetadata; +pub(crate) use mock_metadata::MockMetadata; diff --git a/src/utils/metadatas/stub_metadata.rs b/src/utils/metadatas/stub_metadata.rs deleted file mode 100644 index ec3d5f9..0000000 --- a/src/utils/metadatas/stub_metadata.rs +++ /dev/null @@ -1,21 +0,0 @@ -use std::io; - -use bytes::Bytes; - -use crate::Metadata; - -pub(crate) struct StubMetadata; - -impl Metadata for StubMetadata { - fn get(&self, _key: &str) -> io::Result> { - unimplemented!("stub metadata get should not be called") - } - - fn set(&self, _key: &str, _value: Bytes) -> io::Result<()> { - unimplemented!("stub metadata set should not be called") - } - - fn delete(&self, _key: &str) -> io::Result<()> { - unimplemented!("stub metadata delete should not be called") - } -} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 2636127..4d2c48c 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,6 +1,8 @@ #[cfg(test)] pub(crate) mod metadatas; #[cfg(test)] +pub(crate) mod rand; +#[cfg(test)] pub(crate) mod readers; #[cfg(test)] pub(crate) mod writers; diff --git a/src/utils/rand.rs b/src/utils/rand.rs new file mode 100644 index 0000000..66eddc8 --- /dev/null +++ b/src/utils/rand.rs @@ -0,0 +1,158 @@ +use std::time::Duration; + +use futures_timer::Delay; +use parking_lot::Mutex; + +const MT_N: usize = 624; +const MT_M: usize = 397; +const MATRIX_A: u32 = 0x9908_b0df; +const UPPER_MASK: u32 = 0x8000_0000; +const LOWER_MASK: u32 = 0x7fff_ffff; + +/// Deterministic MT19937 generator used for repeatable mock jitter. +pub(crate) struct MersenneTwister { + state: [u32; MT_N], + index: usize, +} + +impl MersenneTwister { + /// Seed a generator with the standard MT19937 initialization routine. + pub(crate) fn new(seed: u32) -> Self { + let mut state = [0; MT_N]; + state[0] = seed; + + for i in 1..MT_N { + state[i] = 1_812_433_253u32 + .wrapping_mul(state[i - 1] ^ (state[i - 1] >> 30)) + .wrapping_add(i as u32); + } + + Self { state, index: MT_N } + } + + /// Return the next tempered 32-bit value from the generator. + pub(crate) fn next_u32(&mut self) -> u32 { + if self.index >= MT_N { + self.twist(); + } + + let mut y = self.state[self.index]; + self.index += 1; + + y ^= y >> 11; + y ^= (y << 7) & 0x9d2c_5680; + y ^= (y << 15) & 0xefc6_0000; + y ^ (y >> 18) + } + + /// Refresh the state array after the current batch is exhausted. + fn twist(&mut self) { + for i in 0..MT_N { + let x = (self.state[i] & UPPER_MASK) | (self.state[(i + 1) % MT_N] & LOWER_MASK); + let mut xa = x >> 1; + + if x & 1 != 0 { + xa ^= MATRIX_A; + } + + self.state[i] = self.state[(i + MT_M) % MT_N] ^ xa; + } + + self.index = 0; + } +} + +/// Async latency profile with deterministic jitter. +pub(crate) struct Latency { + base: Duration, + jitter: Duration, + rng: Mutex, +} + +impl Latency { + /// Create a latency profile with a fixed base delay and jitter range. + pub(crate) fn new(base: Duration, jitter: Duration, seed: u32) -> Self { + Self { + base, + jitter, + rng: Mutex::new(MersenneTwister::new(seed)), + } + } + + /// Return the default profile for upstream reader calls. + pub(crate) fn reader() -> Self { + Self::new(Duration::from_millis(40), Duration::from_millis(20), 0x5254_0001) + } + + /// Return the default profile for intra-datacenter cache calls. + pub(crate) fn intra_dc() -> Self { + Self::new(Duration::from_micros(200), Duration::from_micros(100), 0x1dc0_0001) + } + + /// Await the next configured delay without blocking the executor thread. + pub(crate) async fn pause(&self) { + let delay = self.next_delay(); + + if !delay.is_zero() { + Delay::new(delay).await; + } + } + + /// Calculate the next base-plus-jitter duration for this profile. + fn next_delay(&self) -> Duration { + let jitter_nanos = self.jitter.as_nanos(); + + if jitter_nanos == 0 { + return self.base; + } + + let jitter = u128::from(self.rng.lock().next_u32()) % (jitter_nanos + 1); + let total = self.base.as_nanos().saturating_add(jitter); + let nanos = total.min(u128::from(u64::MAX)) as u64; + + Duration::from_nanos(nanos) + } +} + +impl Default for Latency { + /// Create a no-delay profile for tests that need immediate completion. + fn default() -> Self { + Self::new(Duration::ZERO, Duration::ZERO, 0) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{Latency, MersenneTwister}; + + #[test] + fn mersenne_twister_is_deterministic_for_seed() { + let mut rng = MersenneTwister::new(5489); + + assert_eq!(rng.next_u32(), 3_499_211_612); + assert_eq!(rng.next_u32(), 581_869_302); + assert_eq!(rng.next_u32(), 3_890_346_734); + } + + #[test] + fn default_latency_has_no_delay() { + let latency = Latency::default(); + + assert_eq!(latency.next_delay(), Duration::ZERO); + } + + #[test] + fn latency_adds_seeded_jitter_to_base() { + let latency = Latency::new(Duration::from_nanos(10), Duration::from_nanos(3), 5489); + + assert_eq!(latency.next_delay(), Duration::from_nanos(10)); + assert_eq!(latency.next_delay(), Duration::from_nanos(12)); + } + + #[test] + fn reader_latency_is_significantly_higher_than_intra_dc_latency() { + assert!(Latency::reader().next_delay() > Latency::intra_dc().next_delay()); + } +} diff --git a/src/utils/readers/mock_reader.rs b/src/utils/readers/mock_reader.rs new file mode 100644 index 0000000..d09d4c1 --- /dev/null +++ b/src/utils/readers/mock_reader.rs @@ -0,0 +1,109 @@ +use std::io; + +use async_trait::async_trait; +use bytes::Bytes; + +use crate::Reader; +use crate::utils::rand::Latency; + +/// In-memory reader that serves windows from a fixed byte buffer. +pub(crate) struct MockReader { + data: Bytes, + latency: Latency, +} + +impl MockReader { + /// Create a reader using the default upstream latency profile. + pub(crate) fn new(data: impl Into) -> Self { + Self { + data: data.into(), + latency: Latency::reader(), + } + } + + /// Create a reader with an explicit latency profile. + pub(crate) fn with_latency(data: impl Into, latency: Latency) -> Self { + Self { + data: data.into(), + latency, + } + } +} + +#[async_trait] +impl Reader for MockReader { + /// Return the size of the fixed source buffer. + async fn len(&self) -> io::Result { + self.latency.pause().await; + + Ok(self.data.len()) + } + + /// Return a zero-copy slice for a valid byte window. + async fn read_at(&self, offset: usize, length: usize) -> io::Result { + self.latency.pause().await; + + let end = offset + .checked_add(length) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "reader range overflowed usize"))?; + + if end > self.data.len() { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "reader range extends past end of source")); + } + + Ok(self.data.slice(offset..end)) + } +} + +#[cfg(test)] +mod tests { + use std::io; + use std::time::Duration; + + use bytes::Bytes; + + use super::MockReader; + use crate::Reader; + use crate::utils::rand::Latency; + + #[tokio::test] + async fn len_returns_source_length() { + let reader = MockReader::new(Bytes::from_static(b"hello")); + + assert_eq!(reader.len().await.expect("len should be readable"), 5); + } + + #[tokio::test] + async fn read_at_returns_window_into_source_bytes() { + let data = Bytes::from_static(b"hello world"); + let reader = MockReader::new(data.clone()); + + let window = reader.read_at(6, 5).await.expect("range should be readable"); + + assert_eq!(window, Bytes::from_static(b"world")); + assert_eq!(window.as_ptr(), data.slice(6..11).as_ptr()); + } + + #[tokio::test] + async fn read_at_allows_empty_window_at_end() { + let reader = MockReader::new(Bytes::from_static(b"hello")); + + assert_eq!(reader.read_at(5, 0).await.expect("empty end range should be readable"), Bytes::new()); + } + + #[tokio::test] + async fn read_at_rejects_out_of_bounds_window() { + let reader = MockReader::new(Bytes::from_static(b"hello")); + let error = reader.read_at(4, 2).await.expect_err("range should fail"); + + assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); + } + + #[tokio::test] + async fn can_construct_with_latency() { + let latency = Latency::new(Duration::ZERO, Duration::ZERO, 1); + let reader = MockReader::with_latency(Bytes::from_static(b"hello"), latency); + + assert_eq!(reader.len().await.expect("len should be readable"), 5); + } +} diff --git a/src/utils/readers/mod.rs b/src/utils/readers/mod.rs index 9764552..19324ed 100644 --- a/src/utils/readers/mod.rs +++ b/src/utils/readers/mod.rs @@ -1,3 +1,3 @@ -mod stub_reader; +mod mock_reader; -pub(crate) use stub_reader::StubReader; +pub(crate) use mock_reader::MockReader; diff --git a/src/utils/readers/stub_reader.rs b/src/utils/readers/stub_reader.rs deleted file mode 100644 index 4986fd9..0000000 --- a/src/utils/readers/stub_reader.rs +++ /dev/null @@ -1,17 +0,0 @@ -use std::io; - -use bytes::Bytes; - -use crate::Reader; - -pub(crate) struct StubReader; - -impl Reader for StubReader { - fn len(&self) -> io::Result { - unimplemented!("stub reader len should not be called") - } - - fn read_at(&self, _offset: usize, _length: usize) -> io::Result { - unimplemented!("stub reader read_at should not be called") - } -} diff --git a/src/utils/writers/mock_writer.rs b/src/utils/writers/mock_writer.rs new file mode 100644 index 0000000..ab6ffc7 --- /dev/null +++ b/src/utils/writers/mock_writer.rs @@ -0,0 +1,99 @@ +use std::collections::HashMap; +use std::io; + +use async_trait::async_trait; +use bytes::Bytes; +use parking_lot::RwLock; + +use crate::Writer; +use crate::utils::rand::Latency; + +#[derive(Default)] +/// In-memory writer backed by a lock-protected byte map. +pub(crate) struct MockWriter { + values: RwLock>, + latency: Latency, +} + +impl MockWriter { + /// Create an empty writer using the default cache latency profile. + pub(crate) fn new() -> Self { + Self { + values: RwLock::new(HashMap::new()), + latency: Latency::intra_dc(), + } + } + + /// Create an empty writer with an explicit latency profile. + pub(crate) fn with_latency(latency: Latency) -> Self { + Self { + values: RwLock::new(HashMap::new()), + latency, + } + } +} + +#[async_trait] +impl Writer for MockWriter { + /// Store bytes under the given key, replacing any existing value. + async fn write(&self, key: &str, value: Bytes) -> io::Result<()> { + self.latency.pause().await; + self.values.write().insert(key.to_owned(), value); + + Ok(()) + } + + /// Read the bytes currently stored for a key. + async fn read(&self, key: &str) -> io::Result> { + self.latency.pause().await; + + Ok(self.values.read().get(key).cloned()) + } + + /// Remove a key from the mock cache. + async fn delete(&self, key: &str) -> io::Result<()> { + self.latency.pause().await; + self.values.write().remove(key); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use bytes::Bytes; + + use super::MockWriter; + use crate::Writer; + use crate::utils::rand::Latency; + + #[tokio::test] + async fn write_read_and_delete_value() { + let writer = MockWriter::new(); + let value = Bytes::from_static(b"cached"); + + writer.write("key", value).await.expect("write should succeed"); + assert_eq!(writer.read("key").await.expect("read should succeed"), Some(Bytes::from_static(b"cached"))); + + writer.delete("key").await.expect("delete should succeed"); + assert_eq!(writer.read("key").await.expect("read should succeed"), None); + } + + #[tokio::test] + async fn delete_missing_key_succeeds() { + let writer = MockWriter::new(); + + writer.delete("missing").await.expect("delete should succeed"); + } + + #[tokio::test] + async fn can_construct_with_latency() { + let writer = MockWriter::with_latency(Latency::new(Duration::ZERO, Duration::ZERO, 1)); + let value = Bytes::from_static(b"cached"); + + writer.write("key", value.clone()).await.expect("write should succeed"); + assert_eq!(writer.read("key").await.expect("read should succeed"), Some(value)); + } +} diff --git a/src/utils/writers/mod.rs b/src/utils/writers/mod.rs index 9c91529..a5b3cfb 100644 --- a/src/utils/writers/mod.rs +++ b/src/utils/writers/mod.rs @@ -1,3 +1,3 @@ -mod stub_writer; +mod mock_writer; -pub(crate) use stub_writer::StubWriter; +pub(crate) use mock_writer::MockWriter; diff --git a/src/utils/writers/stub_writer.rs b/src/utils/writers/stub_writer.rs deleted file mode 100644 index c0f3588..0000000 --- a/src/utils/writers/stub_writer.rs +++ /dev/null @@ -1,21 +0,0 @@ -use std::io; - -use bytes::Bytes; - -use crate::Writer; - -pub(crate) struct StubWriter; - -impl Writer for StubWriter { - fn write(&self, _key: &str) -> io::Result<()> { - unimplemented!("stub writer write should not be called") - } - - fn read(&self, _key: &str) -> io::Result> { - unimplemented!("stub writer read should not be called") - } - - fn delete(&self, _key: &str) -> io::Result<()> { - unimplemented!("stub writer delete should not be called") - } -}