From 6d2e11753347e56f33f6dfb59e9c85c43690dd69 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 31 Oct 2026 11:13:22 +0000 Subject: [PATCH 1/6] bench: add a #[cachekit] L1-hit case (macro_hit) to bench_hot_path --- Makefile | 4 +- README.md | 6 +- crates/cachekit/examples/bench_hot_path.rs | 90 ++++++++++++++++++++-- 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index b217868..0874aeb 100644 --- a/Makefile +++ b/Makefile @@ -54,10 +54,10 @@ test-wasm: # measured nothing: bad flag, empty filter, valgrind failure); FILTER= # narrows the cases (e.g. FILTER=l2_hit). bench: - $(CARGO) run --release --example bench_hot_path -- wall $(if $(FILTER),'$(FILTER)') + $(CARGO) run --release --features macros --example bench_hot_path -- wall $(if $(FILTER),'$(FILTER)') bench-instr: - $(CARGO) build --release --example bench_hot_path + $(CARGO) build --release --features macros --example bench_hot_path target/release/examples/bench_hot_path instr $(if $(BASE),--base '$(BASE)') $(if $(FILTER),'$(FILTER)') # Supply-chain gate — deny is identical to CI's; audit runs the strict form diff --git a/README.md b/README.md index 89999d7..8a12333 100644 --- a/README.md +++ b/README.md @@ -885,6 +885,10 @@ MessagePack, is the smallest case, where per-call fixed costs dominate). Two mor `prev` reads, L1 and L2 hits, on a client holding one previous key, of entries written under that key; `oneshot` builds an encrypting client (`enc` or `prev`) and reads one L2 hit, the cost a one-read process pays beyond its start-up. +`macro_hit` reads an L1 hit through a `#[cachekit]` function (`plain`, `rel`, +or `enc` through a `secure` function) whose interop key is 120 B, so it prices +the macro's key derivation; it needs the `macros` feature, which the `make` +targets turn on. ```bash make bench # wall ns/op: indicative only @@ -898,7 +902,7 @@ Wall time on a shared machine moves by tens of percent between runs, so `bench-instr` is the number a change is judged on. It counts only the timed loop (`--toggle-collect`), on a single-threaded tokio runtime, five runs per case, and reports the median and the spread. For an A/B, build the example at -the base commit, copy `target/release/examples/bench_hot_path` outside +the base commit with `--features macros`, copy `target/release/examples/bench_hot_path` outside `target/`, and pass it as `BASE`; the runs interleave ABBA, a delta counts only when it beats `max(3 × spread, 0.5%)`, and the target exits 1 when a case got slower by more than that, or 2 when it measured nothing (an unknown flag, a diff --git a/crates/cachekit/examples/bench_hot_path.rs b/crates/cachekit/examples/bench_hot_path.rs index df060d8..91199a7 100644 --- a/crates/cachekit/examples/bench_hot_path.rs +++ b/crates/cachekit/examples/bench_hot_path.rs @@ -7,8 +7,12 @@ //! valgrind's callgrind, which is deterministic on one thread. The runtime is //! tokio `current_thread`: work stealing would add scheduler noise. //! +//! `macro_hit` reads through a `#[cachekit]` function, so it builds only with +//! the `macros` feature (`make bench` and `make bench-instr` turn it on); a +//! build without it lists no `macro_hit` case. +//! //! ```text -//! bench_hot_path list every case name +//! bench_hot_path list every case name //! bench_hot_path run one case; indicative wall ns/op //! bench_hot_path wall [filter] every case; indicative wall ns/op //! bench_hot_path readme the README's Performance table @@ -40,7 +44,15 @@ use cachekit::backend::{Backend, HealthStatus}; use cachekit::reliability::ReliabilityConfig; use cachekit::{BackendError, CacheKit, CachekitError}; -const OPS: [&str; 6] = ["l1_hit", "l2_hit", "set", "delete", "exists", "oneshot"]; +const OPS: [&str; 7] = [ + "l1_hit", + "l2_hit", + "set", + "delete", + "exists", + "oneshot", + "macro_hit", +]; const MODES: [&str; 4] = ["plain", "rel", "enc", "prev"]; /// The current master key of every encrypting reader and every writer but /// `prev`'s. @@ -60,6 +72,13 @@ const TTL: Duration = Duration::from_secs(300); const WARM: usize = 16; /// Demangled name of the function callgrind counts (see [`measured`]). const MEASURED: &str = "bench_hot_path::measured"; +/// `macro_hit`'s interop segments and arguments. With the 64-hex hash, its +/// key is 120 B, the length of a real interop key: a short bench key +/// understates what a key-path change saves. +const MACRO_NAMESPACE: &str = "bench.accounts.v2"; +const MACRO_OPERATION: &str = "users.fetch_profile_by_email_and_size"; +const MACRO_EMAIL: &str = "alice@example.com"; +const MACRO_ID: u64 = 42; // ── In-memory backend ──────────────────────────────────────────────────────── @@ -124,6 +143,41 @@ impl Backend for MemBackend { // ── Cases ──────────────────────────────────────────────────────────────────── +/// `macro_hit/plain` and `macro_hit/rel`'s function. Setup writes its entry +/// under the key the macro derives, so the body runs only on a miss, which +/// fails the run. +#[cfg(feature = "macros")] +#[cachekit::cachekit( + client = cache, + ttl = 300, + interop = "users.fetch_profile_by_email_and_size", + namespace = "bench.accounts.v2" +)] +async fn macro_read(cache: &CacheKit, email: &str, id: u64) -> Result { + Err(CachekitError::Config(format!( + "macro_hit: expected a hit for {email}/{id}, got a miss" + ))) +} + +/// `macro_hit/enc`'s function: [`macro_read`] with `secure`. +#[cfg(feature = "macros")] +#[cachekit::cachekit( + client = cache, + ttl = 300, + interop = "users.fetch_profile_by_email_and_size", + namespace = "bench.accounts.v2", + secure +)] +async fn macro_read_secure( + cache: &CacheKit, + email: &str, + id: u64, +) -> Result { + Err(CachekitError::Config(format!( + "macro_hit: expected a hit for {email}/{id}, got a miss" + ))) +} + /// One bench case: `/`, e.g. `l2_hit/enc`. struct Bench { op: &'static str, @@ -167,10 +221,12 @@ fn writer(backend: &Arc, mode: &str) -> Result/` is a case. `prev` is the rotation read path, so it /// pairs only with reads; `oneshot` prices building an encrypting client, so -/// it pairs only with the encrypted modes. +/// it pairs only with the encrypted modes; `macro_hit` exists only with the +/// `macros` feature, and its `enc` mode reads through a `secure` function. fn is_case(op: &str, mode: &str) -> bool { match op { "oneshot" => matches!(mode, "enc" | "prev"), + "macro_hit" => cfg!(feature = "macros") && mode != "prev", "l1_hit" | "l2_hit" => true, _ => mode != "prev", } @@ -199,9 +255,18 @@ impl Bench { // its capacity into shards, and a shard sized exactly to its keys // evicts a few of them when the hash spreads them unevenly. let reader = client(&backend, mode, 2 * (iters + WARM) + 8)?; - let keys: Vec = (0..iters + WARM) - .map(|i| format!("bench:{op}:{i:08}")) - .collect(); + let keys: Vec = if op == "macro_hit" { + // The key `#[cachekit]` derives for `macro_read(MACRO_EMAIL, MACRO_ID)`. + vec![cachekit::interop::interop_key( + MACRO_NAMESPACE, + MACRO_OPERATION, + &[MACRO_EMAIL.into(), MACRO_ID.into()], + )?] + } else { + (0..iters + WARM) + .map(|i| format!("bench:{op}:{i:08}")) + .collect() + }; let bench = Self { op, mode, @@ -261,6 +326,19 @@ impl Bench { // backend: what a one-read process pays past start-up (the warm-up // has already paid first-use costs, and L1 holds 8 entries). "oneshot" => read(&client(&self.backend, self.mode, 8)?, true, key).await, + // An L1 hit through `#[cachekit]`: `l1_hit`'s read plus the key + // derivation, the freshness check and the hit count. + #[cfg(feature = "macros")] + "macro_hit" => { + let (email, id) = (black_box(MACRO_EMAIL), black_box(MACRO_ID)); + let got = if self.enc { + macro_read_secure(&self.reader, email, id).await? + } else { + macro_read(&self.reader, email, id).await? + }; + drop(black_box(got)); + Ok(()) + } _ => read(&self.reader, self.enc, key).await, } } From 54daf3c29d6aa7302eeb1de7b9702d09fb230850 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 31 Oct 2026 11:20:42 +0000 Subject: [PATCH 2/6] perf(macros): build the #[cachekit] key prefix once per function and hex-encode from a table --- crates/cachekit-macros/src/lib.rs | 14 ++--- crates/cachekit/src/interop.rs | 89 ++++++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/crates/cachekit-macros/src/lib.rs b/crates/cachekit-macros/src/lib.rs index 1031eb5..4443fbb 100644 --- a/crates/cachekit-macros/src/lib.rs +++ b/crates/cachekit-macros/src/lib.rs @@ -23,7 +23,7 @@ struct MacroArgs { /// Compile-time mirror of the interop/v1 segment grammar /// (`^[a-z0-9][a-z0-9._-]{0,63}$` — see `cachekit::interop`'s /// `validate_segment`, the canonical implementation). Duplicated because a -/// proc-macro crate cannot depend on the runtime crate; `interop_key` +/// proc-macro crate cannot depend on the runtime crate; `__KeyPrefix` /// re-validates at runtime, so drift fails loudly, never silently. The `..` /// ban and the reserved-namespace check are mirrored in `parse_segment`. fn segment_is_valid(segment: &str) -> bool { @@ -555,15 +555,17 @@ fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result { { // interop/v1 key: {namespace}:{operation}:{args_hash} — the same // key is computable from any SDK (protocol spec/interop-mode.md). + // The prefix is validated and joined once per function, so a call + // only hashes its arguments; the bytes equal `interop_key`'s. // The generated `.clone()` per argument keeps the binding usable // by the body; for Copy types it is a copy and for `&str` it is // a deliberate reference copy — hence the allows. #[allow(clippy::clone_on_copy, noop_method_call)] - let __ck_key = cachekit::interop::interop_key( - #namespace, - #operation, - &[#(#interop_args),*], - )?; + let __ck_key = { + static __CK_KEY_PREFIX: cachekit::interop::__KeyPrefix = + cachekit::interop::__KeyPrefix::new(#namespace, #operation); + __CK_KEY_PREFIX.key(&[#(#interop_args),*])? + }; // Try cache hit, classified against the stale-while-revalidate // freshness window. A Serialization error means the stored entry diff --git a/crates/cachekit/src/interop.rs b/crates/cachekit/src/interop.rs index 32dd1c2..4480059 100644 --- a/crates/cachekit/src/interop.rs +++ b/crates/cachekit/src/interop.rs @@ -52,6 +52,7 @@ //! ``` use std::collections::BTreeMap; +use std::sync::OnceLock; use blake2::{digest::consts::U32, Blake2b, Digest}; use serde::de::DeserializeOwned; @@ -321,12 +322,86 @@ pub fn interop_key( validate_segment("namespace", namespace)?; validate_segment("operation", operation)?; - let encoded = canonical_args(args)?; - let mut hasher = Blake2b256::new(); - hasher.update(&encoded); - let args_hash = hex::encode(hasher.finalize()); + let mut key = String::with_capacity(namespace.len() + operation.len() + 2 + ARGS_HASH_LEN); + key.push_str(namespace); + key.push(':'); + key.push_str(operation); + key.push(':'); + push_args_hash(key, args) +} + +/// Length of `args_hash`: a 32-byte digest in hex. +const ARGS_HASH_LEN: usize = 64; + +/// Append `args_hash` to `key`, which holds `{namespace}:{operation}:` and +/// room for the hash. The one implementation of the hash segment, shared by +/// [`interop_key`] and [`__KeyPrefix::key`], so both mint the same bytes. +fn push_args_hash(mut key: String, args: &[InteropValue]) -> Result { + const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef"; + let digest = Blake2b256::digest(canonical_args(args)?); + let mut hex = [0u8; ARGS_HASH_LEN]; + for (pair, byte) in hex.as_chunks_mut::<2>().0.iter_mut().zip(digest) { + *pair = [ + HEX_DIGITS[usize::from(byte >> 4)], + HEX_DIGITS[usize::from(byte & 0x0f)], + ]; + } + // Every byte is an ASCII hex digit, so this cannot fail. + let hex = std::str::from_utf8(&hex) + .map_err(|e| CachekitError::Serialization(format!("args_hash is not hex: {e}")))?; + key.push_str(hex); + Ok(key) +} + +/// One `#[cachekit]` function's key prefix, `{namespace}:{operation}:`, which +/// the macro keeps in a `static`: its segments are validated and joined on the +/// first call that passes validation, and each call after that only hashes its +/// arguments. Keys are byte-identical to [`interop_key`]'s. +/// +/// The macro validates both segments at compile time against a mirror of +/// [`validate_segment`]; this runtime check is the drift guard behind that +/// mirror. Only a valid prefix is kept, so a segment the mirror wrongly +/// accepts fails every call with [`CachekitError::InvalidKey`], not just the +/// first. +/// +/// No argument is kept: the prefix depends on the function alone. +#[doc(hidden)] +pub struct __KeyPrefix { + namespace: &'static str, + operation: &'static str, + prefix: OnceLock, +} + +impl __KeyPrefix { + /// A prefix for `namespace` and `operation`, validated on first use. + #[must_use] + pub const fn new(namespace: &'static str, operation: &'static str) -> Self { + Self { + namespace, + operation, + prefix: OnceLock::new(), + } + } - Ok(format!("{namespace}:{operation}:{args_hash}")) + /// The interop/v1 key for `args`: `interop_key(namespace, operation, args)`. + /// + /// # Errors + /// + /// As [`interop_key`]. + pub fn key(&self, args: &[InteropValue]) -> Result { + let prefix = match self.prefix.get() { + Some(prefix) => prefix, + None => { + validate_segment("namespace", self.namespace)?; + validate_segment("operation", self.operation)?; + self.prefix + .get_or_init(|| format!("{}:{}:", self.namespace, self.operation)) + } + }; + let mut key = String::with_capacity(prefix.len() + ARGS_HASH_LEN); + key.push_str(prefix); + push_args_hash(key, args) + } } /// Encode the flat argument array to canonical MessagePack (argument profile: @@ -339,7 +414,9 @@ pub fn interop_key( /// /// [`CachekitError::Serialization`] for out-of-model values (see [`interop_key`]). pub fn canonical_args(args: &[InteropValue]) -> Result, CachekitError> { - let mut buf = Vec::new(); + // Most argument lists encode in under 64 bytes: one allocation, where + // growing from empty took three. + let mut buf = Vec::with_capacity(64); encode_array_header(&mut buf, args.len())?; for arg in args { encode(&mut buf, arg, Profile::Args)?; From 596e4791fee99cfd2b778d502adcf198d5f81b08 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 31 Oct 2026 11:41:24 +0000 Subject: [PATCH 3/6] test(interop): run the key and error vectors and a seeded property test through the #[cachekit] key path --- crates/cachekit-macros/src/lib.rs | 2 +- crates/cachekit/src/interop.rs | 64 +++++- crates/cachekit/tests/interop_vector_tests.rs | 206 +++++++++++++++++- crates/cachekit/tests/macro_tests.rs | 9 +- 4 files changed, 270 insertions(+), 11 deletions(-) diff --git a/crates/cachekit-macros/src/lib.rs b/crates/cachekit-macros/src/lib.rs index 4443fbb..2f45517 100644 --- a/crates/cachekit-macros/src/lib.rs +++ b/crates/cachekit-macros/src/lib.rs @@ -562,7 +562,7 @@ fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result { // a deliberate reference copy — hence the allows. #[allow(clippy::clone_on_copy, noop_method_call)] let __ck_key = { - static __CK_KEY_PREFIX: cachekit::interop::__KeyPrefix = + static __CK_KEY_PREFIX: cachekit::interop::__KeyPrefix<'static> = cachekit::interop::__KeyPrefix::new(#namespace, #operation); __CK_KEY_PREFIX.key(&[#(#interop_args),*])? }; diff --git a/crates/cachekit/src/interop.rs b/crates/cachekit/src/interop.rs index 4480059..2c29c36 100644 --- a/crates/cachekit/src/interop.rs +++ b/crates/cachekit/src/interop.rs @@ -366,16 +366,16 @@ fn push_args_hash(mut key: String, args: &[InteropValue]) -> Result { + namespace: &'a str, + operation: &'a str, prefix: OnceLock, } -impl __KeyPrefix { +impl<'a> __KeyPrefix<'a> { /// A prefix for `namespace` and `operation`, validated on first use. #[must_use] - pub const fn new(namespace: &'static str, operation: &'static str) -> Self { + pub const fn new(namespace: &'a str, operation: &'a str) -> Self { Self { namespace, operation, @@ -850,6 +850,60 @@ mod tests { } } + /// The drift guard behind `#[cachekit]`'s compile-time segment check: a + /// segment the macro's mirror wrongly accepted fails every call with + /// `interop_key`'s error, and no prefix is kept. + #[test] + fn key_prefix_rejects_an_invalid_segment_on_every_call() { + for (namespace, operation) in [ + ("ns", "get_user"), + ("nsapi", "get_user"), + ("Users", "get_user"), + ("users", "get user"), + ("a..b", "get_user"), + ("users", "x.."), + ("users\n", "get_user"), + ("", "get_user"), + ("users", ""), + ] { + let prefix = __KeyPrefix::new(namespace, operation); + let expected = interop_key(namespace, operation, &[]).unwrap_err(); + for call in 0..3 { + let err = prefix.key(&[]).unwrap_err(); + assert!( + matches!(err, CachekitError::InvalidKey(_)), + "{namespace:?}/{operation:?} call {call}: {err}" + ); + assert_eq!(err.to_string(), expected.to_string()); + } + assert!(prefix.prefix.get().is_none(), "{namespace:?}/{operation:?}"); + } + } + + /// A valid prefix is joined once and reused, and an argument error leaves + /// it in place. + #[test] + fn key_prefix_keeps_a_valid_prefix() { + let prefix = __KeyPrefix::new("users", "get_user"); + let args = [InteropValue::from(42i64)]; + assert_eq!( + prefix.key(&args).unwrap(), + interop_key("users", "get_user", &args).unwrap() + ); + assert_eq!( + prefix.prefix.get().map(String::as_str), + Some("users:get_user:") + ); + assert!(matches!( + prefix.key(&[InteropValue::from(f64::NAN)]), + Err(CachekitError::Serialization(_)) + )); + assert_eq!( + prefix.key(&args).unwrap(), + interop_key("users", "get_user", &args).unwrap() + ); + } + #[test] fn systemtime_pre_epoch_floors_toward_negative_infinity() { use std::time::{Duration, SystemTime, UNIX_EPOCH}; diff --git a/crates/cachekit/tests/interop_vector_tests.rs b/crates/cachekit/tests/interop_vector_tests.rs index 11d7b26..12acf60 100644 --- a/crates/cachekit/tests/interop_vector_tests.rs +++ b/crates/cachekit/tests/interop_vector_tests.rs @@ -19,7 +19,7 @@ use std::collections::BTreeMap; -use cachekit::interop::{canonical_args, interop_key, serialize_value, InteropValue}; +use cachekit::interop::{__KeyPrefix, canonical_args, interop_key, serialize_value, InteropValue}; use serde_json::Value as Json; const VECTORS_JSON: &str = include_str!("vectors/interop-mode.json"); @@ -267,6 +267,20 @@ fn key_vectors_all_pass() { vector["args_hash"].as_str().expect("args_hash"), "[{name}] args_hash mismatch" ); + + // 4. `#[cachekit]`'s key: its per-function prefix mints the same + // bytes on the call that builds the prefix and on one that reuses it. + let prefix = __KeyPrefix::new(namespace, operation); + for call in ["first", "reused"] { + let macro_key = prefix + .key(&args) + .unwrap_or_else(|e| panic!("[{name}] __KeyPrefix::key ({call}): {e}")); + assert_eq!( + macro_key, + vector["expected_key"].as_str().expect("expected_key"), + "[{name}] #[cachekit] key mismatch ({call} call)" + ); + } } } @@ -496,6 +510,13 @@ fn error_vectors_all_reject() { result.is_err(), "[{name}] MUST be rejected, but produced key: {result:?}" ); + // `#[cachekit]`'s key rejects it with the same error. + let macro_result = __KeyPrefix::new(namespace, operation).key(&args); + assert_eq!( + macro_result.map_err(|e| e.to_string()), + result.map_err(|e| e.to_string()), + "[{name}] #[cachekit] key must reject as interop_key does" + ); } } } @@ -589,3 +610,186 @@ fn non_utc_offset_matches_utc_instant() { let b = iso8601_to_unix_micros("2024-01-01T12:30:45.123456+00:00").unwrap(); assert_eq!(a, b); } + +// ── #[cachekit]'s key against interop_key, on random input ────────────────── + +/// SplitMix64 from a fixed seed: no new dependency, and a failure replays. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) + } + + /// Uniform in `0..n`. + fn below(&mut self, n: usize) -> usize { + usize::try_from(self.next() % n as u64).unwrap() + } + + fn pick(&mut self, items: &[T]) -> T { + items[self.below(items.len())] + } +} + +/// A segment drawn mostly from the interop grammar, sometimes outside it +/// (bad first byte, `..`, a reserved namespace, too long, empty). +fn random_segment(rng: &mut Rng) -> String { + const FIRST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + const REST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789._-"; + match rng.below(20) { + 0 => rng + .pick(&["ns", "nsapi", "", "A", ".x", "a..b", "x\n"]) + .to_owned(), + 1 => "a".repeat(65), + _ => { + let len = 1 + rng.below(64); + let mut s = String::from(char::from(rng.pick(FIRST))); + (1..len).for_each(|_| s.push(char::from(rng.pick(REST)))); + s + } + } +} + +/// Integers at every msgpack width boundary, random ones, and the two just +/// outside the interop range. +fn random_int(rng: &mut Rng) -> i128 { + const EDGES: [i128; 16] = [ + 0, + -1, + 127, + 128, + -32, + -33, + 255, + 256, + 65_535, + 65_536, + -129, + -32_769, + i64::MIN as i128, + u64::MAX as i128, + u64::MAX as i128 + 1, + i64::MIN as i128 - 1, + ]; + match rng.below(3) { + 0 => rng.pick(&EDGES), + 1 => i128::from(rng.next()), + _ => i128::from(i64::from_ne_bytes(rng.next().to_ne_bytes())), + } +} + +/// Integral floats (which collapse to ints), arbitrary finite floats, and the +/// values the encoder rejects or must not collapse. +fn random_float(rng: &mut Rng) -> f64 { + match rng.below(4) { + 0 => rng.pick(&[ + 0.0, + -0.0, + 2.0, + -9_223_372_036_854_775_808.0, + 18_446_744_073_709_551_616.0, + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + ]), + 1 => f64::from(u32::try_from(rng.next() >> 32).unwrap()), + _ => { + let f = f64::from_bits(rng.next()); + if f.is_finite() { + f + } else { + 0.5 + } + } + } +} + +fn random_value(rng: &mut Rng, depth: u32) -> InteropValue { + let kinds = if depth == 0 { 8 } else { 11 }; + let items = |rng: &mut Rng| -> Vec { + (0..rng.below(4)) + .map(|_| random_value(rng, depth - 1)) + .collect() + }; + match rng.below(kinds) { + 0 => InteropValue::Null, + 1 => InteropValue::Bool(rng.below(2) == 1), + 2 => InteropValue::Int(random_int(rng)), + 3 => InteropValue::Float(random_float(rng)), + 4 => { + // ASCII, 2-, 3- and 4-byte UTF-8, past the fixstr and str8 limits. + let len = rng.pick(&[0, 1, 5, 31, 32, 100, 300]); + InteropValue::Str( + (0..len) + .map(|_| rng.pick(&['a', 'Z', '0', 'é', '日', '😀'])) + .collect(), + ) + } + 5 => InteropValue::bytes( + (0..rng.pick(&[0, 3, 255, 256])) + .map(|_| rng.next().to_ne_bytes()[0]) + .collect::>(), + ), + 6 => InteropValue::datetime_from_unix_micros(i64::from_ne_bytes(rng.next().to_ne_bytes())), + 7 => InteropValue::Uuid(uuid::Uuid::from_u64_pair(rng.next(), rng.next())), + 8 => InteropValue::Array(items(rng)), + 9 => InteropValue::Map( + items(rng) + .into_iter() + .map(|v| (format!("k{}", rng.below(5)), v)) + .collect(), + ), + _ => InteropValue::Set(items(rng)), + } +} + +/// `interop_key` as it was before the fast path: `format!` over `hex::encode`. +fn reference_key(namespace: &str, operation: &str, args: &[InteropValue]) -> String { + use blake2::{digest::consts::U32, Blake2b, Digest}; + let encoded = canonical_args(args).expect("reference encodes only what interop_key did"); + let args_hash = hex::encode(Blake2b::::digest(&encoded)); + format!("{namespace}:{operation}:{args_hash}") +} + +/// One prefix per segment pair serves many argument lists, as the static in +/// each `#[cachekit]` function does. Every key it mints, and every error, +/// matches `interop_key`'s, and every key matches the pre-fast-path formula. +#[test] +fn macro_key_matches_interop_key_on_random_input() { + let mut rng = Rng(0x1210_4c0d_e5ee_d000); + let (mut keys, mut errors) = (0, 0); + for _ in 0..400 { + let (namespace, operation) = (random_segment(&mut rng), random_segment(&mut rng)); + let prefix = __KeyPrefix::new(&namespace, &operation); + for _ in 0..10 { + let args: Vec = (0..rng.below(5)) + .map(|_| random_value(&mut rng, 2)) + .collect(); + let fresh = interop_key(&namespace, &operation, &args); + let kept = prefix.key(&args); + let case = format!("{namespace:?}/{operation:?} {args:?}"); + match (&fresh, &kept) { + (Ok(fresh), Ok(kept)) => { + assert_eq!(kept, fresh, "{case}"); + assert_eq!( + *fresh, + reference_key(&namespace, &operation, &args), + "{case}" + ); + keys += 1; + } + (Err(fresh), Err(kept)) => { + assert_eq!(kept.to_string(), fresh.to_string(), "{case}"); + errors += 1; + } + _ => panic!("{case}: interop_key {fresh:?}, #[cachekit] key {kept:?}"), + } + } + } + // Both outcomes must be well exercised, or the comparison proves little. + assert!(keys > 2_000 && errors > 300, "keys {keys}, errors {errors}"); +} diff --git a/crates/cachekit/tests/macro_tests.rs b/crates/cachekit/tests/macro_tests.rs index db5fb7c..a6bf34c 100644 --- a/crates/cachekit/tests/macro_tests.rs +++ b/crates/cachekit/tests/macro_tests.rs @@ -287,10 +287,11 @@ async fn macro_self_heals_undecodable_entry() { #[tokio::test] async fn macro_key_delegates_to_interop_key() { // The macro must mint EXACTLY interop_key(namespace, operation, args), - // operation being the `interop` attribute — this delegation is what - // makes the 48 protocol interop vectors - // (interop_vector_tests.rs) transitively cover #[cachekit] keys, and - // what makes the same entry addressable from the Python/TS SDKs. + // operation being the `interop` attribute: that is what makes the same + // entry addressable from the Python/TS SDKs. The macro calls + // `interop::__KeyPrefix::key`, which interop_vector_tests.rs runs + // against every key and error vector and against interop_key on random + // input; this test ties the macro expansion to it. let (cache, backend) = mock_client_counting(); get_user_multi_args(&cache, "acme".to_owned(), 7) .await From 0173ccccd7291dc62191d359c311bf4ed57c6293 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 31 Oct 2026 12:16:05 +0000 Subject: [PATCH 4/6] test(interop): pin that a segment error wins over an argument error on the #[cachekit] key path --- README.md | 4 +- crates/cachekit/examples/bench_hot_path.rs | 2 +- crates/cachekit/src/interop.rs | 11 ++++-- crates/cachekit/tests/interop_vector_tests.rs | 38 +++++++++++++------ 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8a12333..445b5fc 100644 --- a/README.md +++ b/README.md @@ -902,7 +902,9 @@ Wall time on a shared machine moves by tens of percent between runs, so `bench-instr` is the number a change is judged on. It counts only the timed loop (`--toggle-collect`), on a single-threaded tokio runtime, five runs per case, and reports the median and the spread. For an A/B, build the example at -the base commit with `--features macros`, copy `target/release/examples/bench_hot_path` outside +the base commit with `--features macros`, from this tree's `examples/bench_hot_path.rs` +so both builds run the same cases (a base without one, such as `macro_hit`, +fails that case and the run exits 2), copy `target/release/examples/bench_hot_path` outside `target/`, and pass it as `BASE`; the runs interleave ABBA, a delta counts only when it beats `max(3 × spread, 0.5%)`, and the target exits 1 when a case got slower by more than that, or 2 when it measured nothing (an unknown flag, a diff --git a/crates/cachekit/examples/bench_hot_path.rs b/crates/cachekit/examples/bench_hot_path.rs index 91199a7..5893499 100644 --- a/crates/cachekit/examples/bench_hot_path.rs +++ b/crates/cachekit/examples/bench_hot_path.rs @@ -12,7 +12,7 @@ //! build without it lists no `macro_hit` case. //! //! ```text -//! bench_hot_path list every case name +//! bench_hot_path list every case name //! bench_hot_path run one case; indicative wall ns/op //! bench_hot_path wall [filter] every case; indicative wall ns/op //! bench_hot_path readme the README's Performance table diff --git a/crates/cachekit/src/interop.rs b/crates/cachekit/src/interop.rs index 2c29c36..c1c9a40 100644 --- a/crates/cachekit/src/interop.rs +++ b/crates/cachekit/src/interop.rs @@ -347,8 +347,9 @@ fn push_args_hash(mut key: String, args: &[InteropValue]) -> Result Str #[test] fn macro_key_matches_interop_key_on_random_input() { let mut rng = Rng(0x1210_4c0d_e5ee_d000); - let (mut keys, mut errors) = (0, 0); + // Errors from an argument on a built prefix, and from a bad segment and a + // bad argument at once (the segment error must win). + let (mut keys, mut arg_errors, mut both_errors) = (0, 0, 0); for _ in 0..400 { let (namespace, operation) = (random_segment(&mut rng), random_segment(&mut rng)); + let segments_ok = interop_key(&namespace, &operation, &[]).is_ok(); let prefix = __KeyPrefix::new(&namespace, &operation); for _ in 0..10 { let args: Vec = (0..rng.below(5)) @@ -784,12 +791,21 @@ fn macro_key_matches_interop_key_on_random_input() { } (Err(fresh), Err(kept)) => { assert_eq!(kept.to_string(), fresh.to_string(), "{case}"); - errors += 1; + if canonical_args(&args).is_err() { + if segments_ok { + arg_errors += 1; + } else { + both_errors += 1; + } + } } _ => panic!("{case}: interop_key {fresh:?}, #[cachekit] key {kept:?}"), } } } - // Both outcomes must be well exercised, or the comparison proves little. - assert!(keys > 2_000 && errors > 300, "keys {keys}, errors {errors}"); + // Every outcome must be well exercised, or the comparison proves little. + assert!( + keys > 2_000 && arg_errors > 50 && both_errors > 10, + "keys {keys}, argument errors {arg_errors}, segment and argument errors {both_errors}" + ); } From 434a0d71eeb26d19c8f78f3aae8c90032dfdd6f8 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 31 Oct 2026 12:38:37 +0000 Subject: [PATCH 5/6] fix(macros): evaluate #[cachekit] arguments outside the key-prefix static's block --- crates/cachekit-macros/src/lib.rs | 12 +++++++++--- crates/cachekit/tests/macro_tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/cachekit-macros/src/lib.rs b/crates/cachekit-macros/src/lib.rs index 2f45517..dc295de 100644 --- a/crates/cachekit-macros/src/lib.rs +++ b/crates/cachekit-macros/src/lib.rs @@ -557,14 +557,20 @@ fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result { // key is computable from any SDK (protocol spec/interop-mode.md). // The prefix is validated and joined once per function, so a call // only hashes its arguments; the bytes equal `interop_key`'s. + // The arguments are evaluated outside the block that declares the + // static: an item is visible throughout its block, so a parameter + // with the static's name would otherwise resolve to the static. // The generated `.clone()` per argument keeps the binding usable // by the body; for Copy types it is a copy and for `&str` it is // a deliberate reference copy — hence the allows. #[allow(clippy::clone_on_copy, noop_method_call)] let __ck_key = { - static __CK_KEY_PREFIX: cachekit::interop::__KeyPrefix<'static> = - cachekit::interop::__KeyPrefix::new(#namespace, #operation); - __CK_KEY_PREFIX.key(&[#(#interop_args),*])? + let __ck_args: &[cachekit::interop::InteropValue] = &[#(#interop_args),*]; + { + static __CK_KEY_PREFIX: cachekit::interop::__KeyPrefix<'static> = + cachekit::interop::__KeyPrefix::new(#namespace, #operation); + __CK_KEY_PREFIX.key(__ck_args)? + } }; // Try cache hit, classified against the stale-while-revalidate diff --git a/crates/cachekit/tests/macro_tests.rs b/crates/cachekit/tests/macro_tests.rs index a6bf34c..b37b340 100644 --- a/crates/cachekit/tests/macro_tests.rs +++ b/crates/cachekit/tests/macro_tests.rs @@ -307,6 +307,34 @@ async fn macro_key_delegates_to_interop_key() { assert_eq!(keys, vec![expected]); } +/// Parameters named like the macro's key-prefix static and its argument +/// slice are still the parameters: the expansion evaluates the arguments +/// outside the block that declares the static, before the slice is bound. +#[cachekit(client = cache, ttl = 60, interop = "shadowed", namespace = "hygiene")] +#[allow(non_snake_case)] +async fn shadowed_prefix_name( + cache: &CacheKit, + __CK_KEY_PREFIX: u64, + __ck_args: u64, +) -> Result { + Ok(__CK_KEY_PREFIX + __ck_args) +} + +#[tokio::test] +async fn parameter_named_like_the_key_prefix_static_keys_by_its_value() { + let (cache, backend) = mock_client_counting(); + assert_eq!(shadowed_prefix_name(&cache, 7, 1).await.unwrap(), 8); + + let expected = interop_key( + "hygiene", + "shadowed", + &[InteropValue::from(7u64), InteropValue::from(1u64)], + ) + .unwrap(); + let keys: Vec = backend.inner.store.lock().await.keys().cloned().collect(); + assert_eq!(keys, vec![expected]); +} + // ── Reliability behaviour ──────────────────────────────────────────────────── /// Backend where every data operation fails with a transient error. From 304dd430564d539e3514bac069cfb698ca29146f Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 31 Oct 2026 12:43:35 +0000 Subject: [PATCH 6/6] refactor(interop): validate and join the key prefix in one place; honest error kind for the unreachable hex branch --- crates/cachekit/src/interop.rs | 47 +++++++++++++++------------- crates/cachekit/tests/macro_tests.rs | 2 +- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/crates/cachekit/src/interop.rs b/crates/cachekit/src/interop.rs index c1c9a40..f5f0835 100644 --- a/crates/cachekit/src/interop.rs +++ b/crates/cachekit/src/interop.rs @@ -319,23 +319,29 @@ pub fn interop_key( operation: &str, args: &[InteropValue], ) -> Result { - validate_segment("namespace", namespace)?; - validate_segment("operation", operation)?; - - let mut key = String::with_capacity(namespace.len() + operation.len() + 2 + ARGS_HASH_LEN); - key.push_str(namespace); - key.push(':'); - key.push_str(operation); - key.push(':'); - push_args_hash(key, args) + push_args_hash(checked_prefix(namespace, operation)?, args) } /// Length of `args_hash`: a 32-byte digest in hex. const ARGS_HASH_LEN: usize = 64; -/// Append `args_hash` to `key`, which holds `{namespace}:{operation}:` and -/// room for the hash. The one implementation of the hash segment, shared by -/// [`interop_key`] and [`__KeyPrefix::key`], so both mint the same bytes. +/// `{namespace}:{operation}:` once both segments pass [`validate_segment`], +/// with room for the hash. With [`push_args_hash`], the one implementation of +/// the key, shared by [`interop_key`] and [`__KeyPrefix::key`], so both mint +/// the same bytes and the same errors. +fn checked_prefix(namespace: &str, operation: &str) -> Result { + validate_segment("namespace", namespace)?; + validate_segment("operation", operation)?; + let mut prefix = String::with_capacity(namespace.len() + operation.len() + 2 + ARGS_HASH_LEN); + prefix.push_str(namespace); + prefix.push(':'); + prefix.push_str(operation); + prefix.push(':'); + Ok(prefix) +} + +/// Append `args_hash` to `key`, which holds a [`checked_prefix`] and room for +/// the hash. fn push_args_hash(mut key: String, args: &[InteropValue]) -> Result { const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef"; let digest = Blake2b256::digest(canonical_args(args)?); @@ -346,10 +352,11 @@ fn push_args_hash(mut key: String, args: &[InteropValue]) -> Result __KeyPrefix<'a> { let prefix = match self.prefix.get() { Some(prefix) => prefix, None => { - validate_segment("namespace", self.namespace)?; - validate_segment("operation", self.operation)?; - self.prefix - .get_or_init(|| format!("{}:{}:", self.namespace, self.operation)) + let checked = checked_prefix(self.namespace, self.operation)?; + self.prefix.get_or_init(|| checked) } }; let mut key = String::with_capacity(prefix.len() + ARGS_HASH_LEN); @@ -416,7 +421,7 @@ impl<'a> __KeyPrefix<'a> { /// [`CachekitError::Serialization`] for out-of-model values (see [`interop_key`]). pub fn canonical_args(args: &[InteropValue]) -> Result, CachekitError> { // Most argument lists encode in under 64 bytes: one allocation, where - // growing from empty took three. + // growing from empty took up to four (8, 16, 32, 64 bytes). let mut buf = Vec::with_capacity(64); encode_array_header(&mut buf, args.len())?; for arg in args { diff --git a/crates/cachekit/tests/macro_tests.rs b/crates/cachekit/tests/macro_tests.rs index b37b340..b4595c9 100644 --- a/crates/cachekit/tests/macro_tests.rs +++ b/crates/cachekit/tests/macro_tests.rs @@ -285,7 +285,7 @@ async fn macro_self_heals_undecodable_entry() { } #[tokio::test] -async fn macro_key_delegates_to_interop_key() { +async fn macro_key_matches_interop_key() { // The macro must mint EXACTLY interop_key(namespace, operation, args), // operation being the `interop` attribute: that is what makes the same // entry addressable from the Python/TS SDKs. The macro calls