cachekit_macros/lib.rs
1use proc_macro::TokenStream;
2use proc_macro2::TokenStream as TokenStream2;
3use quote::{quote, quote_spanned};
4use syn::{
5 parse::{Parse, ParseStream},
6 parse_macro_input,
7 spanned::Spanned,
8 Ident, ItemFn, LitInt, LitStr, ReturnType, Token, Type,
9};
10
11// ── Attribute argument parsing ───────────────────────────────────────────────
12
13struct MacroArgs {
14 client: Ident,
15 ttl: u64,
16 interop: String,
17 namespace: String,
18 secure: bool,
19}
20
21/// Compile-time mirror of the interop/v1 segment grammar
22/// (`^[a-z0-9][a-z0-9._-]{0,63}$` — see `cachekit::interop`'s
23/// `validate_segment`, the canonical implementation). Duplicated because a
24/// proc-macro crate cannot depend on the runtime crate; `interop_key`
25/// re-validates at runtime, so drift fails loudly, never silently. The `..`
26/// ban and the reserved-namespace check are mirrored in `parse_segment`.
27fn segment_is_valid(segment: &str) -> bool {
28 let bytes = segment.as_bytes();
29 matches!(bytes.first(), Some(b) if b.is_ascii_lowercase() || b.is_ascii_digit())
30 && bytes.len() <= 64
31 && bytes[1..].iter().all(|b| {
32 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
33 })
34}
35
36/// Extract and validate an interop segment from a string literal, spanning
37/// the error to the literal.
38///
39/// Mirrors `validate_segment`'s `..` ban (either segment; the CachekitIO
40/// server rejects `..` anywhere in a key) and its namespace reservation: a
41/// `namespace` of exactly `ns` or `nsapi` is rejected (the server parses a key
42/// starting `ns:` / `nsapi:` as namespace-prefixed); both stay valid as
43/// `interop`.
44fn parse_segment(kind: &str, lit: &LitStr) -> syn::Result<String> {
45 let value = lit.value();
46 if !segment_is_valid(&value) {
47 return Err(syn::Error::new(
48 lit.span(),
49 format!(
50 "`{kind}` {value:?} is not a valid interop/v1 key segment: must match \
51 ^[a-z0-9][a-z0-9._-]{{0,63}}$ (lowercase ASCII letters, digits, '.', '_', \
52 '-'; 1-64 chars)"
53 ),
54 ));
55 }
56 if value.contains("..") {
57 return Err(syn::Error::new(
58 lit.span(),
59 format!(
60 "`{kind}` {value:?} must not contain \"..\": the CachekitIO server rejects \
61 \"..\" anywhere in a key"
62 ),
63 ));
64 }
65 if kind == "namespace" && matches!(value.as_str(), "ns" | "nsapi") {
66 return Err(syn::Error::new(
67 lit.span(),
68 format!(
69 "`namespace` {value:?} is reserved: the CachekitIO server parses a key \
70 starting \"{value}:\" as namespace-prefixed"
71 ),
72 ));
73 }
74 Ok(value)
75}
76
77impl Parse for MacroArgs {
78 fn parse(input: ParseStream) -> syn::Result<Self> {
79 let mut client: Option<Ident> = None;
80 let mut ttl: Option<u64> = None;
81 let mut interop: Option<String> = None;
82 let mut namespace: Option<String> = None;
83 let mut secure = false;
84
85 while !input.is_empty() {
86 let key: Ident = input.parse()?;
87 match key.to_string().as_str() {
88 "client" => {
89 input.parse::<Token![=]>()?;
90 client = Some(input.parse()?);
91 }
92 "ttl" => {
93 input.parse::<Token![=]>()?;
94 let lit: LitInt = input.parse()?;
95 ttl = Some(lit.base10_parse()?);
96 }
97 "interop" => {
98 input.parse::<Token![=]>()?;
99 let lit: LitStr = input.parse()?;
100 interop = Some(parse_segment("interop", &lit)?);
101 }
102 "namespace" => {
103 input.parse::<Token![=]>()?;
104 let lit: LitStr = input.parse()?;
105 namespace = Some(parse_segment("namespace", &lit)?);
106 }
107 "secure" => {
108 secure = true;
109 }
110 other => {
111 return Err(syn::Error::new(
112 key.span(),
113 format!("unknown attribute `{other}`"),
114 ));
115 }
116 }
117
118 // Consume trailing comma if present
119 if !input.is_empty() {
120 input.parse::<Token![,]>()?;
121 }
122 }
123
124 let client = client
125 .ok_or_else(|| input.error("`client` is required: #[cachekit(client = name, ...)]"))?;
126 let ttl = ttl.ok_or_else(|| {
127 input.error("`ttl` is required: #[cachekit(client = name, ttl = 60)]")
128 })?;
129 let interop = interop.ok_or_else(|| {
130 input.error(
131 "`interop` is required: #[cachekit(client = name, ttl = 60, interop = \
132 \"get_user\", namespace = \"users\")] — it names the operation segment of the \
133 interop/v1 cache key `{namespace}:{operation}:{args_hash}`, and the spec \
134 requires it to be explicit (protocol spec/interop-mode.md, SDK requirement 1)",
135 )
136 })?;
137 let namespace = namespace.ok_or_else(|| {
138 input.error(
139 "`namespace` is required: #[cachekit(client = name, ttl = 60, interop = \
140 \"get_user\", namespace = \"users\")] — cache keys are interop/v1 \
141 `{namespace}:{operation}:{args_hash}` and the namespace segment cannot be empty",
142 )
143 })?;
144
145 Ok(MacroArgs {
146 client,
147 ttl,
148 interop,
149 namespace,
150 secure,
151 })
152 }
153}
154
155// ── Return-type extraction ───────────────────────────────────────────────────
156
157/// Extract `T` from `Result<T, E>` in a function's return type.
158fn extract_ok_type(ret: &ReturnType) -> syn::Result<Type> {
159 let ty = match ret {
160 ReturnType::Type(_, ty) => ty.as_ref(),
161 ReturnType::Default => {
162 return Err(syn::Error::new_spanned(
163 ret,
164 "#[cachekit] function must return Result<T, E> where E: From<CachekitError>",
165 ));
166 }
167 };
168
169 // Walk through the type to find Result<T, E> and extract T.
170 if let Type::Path(type_path) = ty {
171 if let Some(seg) = type_path.path.segments.last() {
172 if seg.ident != "Result" {
173 return Err(syn::Error::new_spanned(
174 ty,
175 "#[cachekit] function must return Result<T, E> where E: From<CachekitError>",
176 ));
177 }
178 if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
179 if let Some(syn::GenericArgument::Type(first)) = args.args.first() {
180 return Ok(first.clone());
181 }
182 }
183 }
184 }
185
186 Err(syn::Error::new_spanned(
187 ty,
188 "#[cachekit] function must return Result<T, E> where E: From<CachekitError>",
189 ))
190}
191
192// ── Proc-macro entry point ──────────────────────────────────────────────────
193
194/// Cache the result of an async function under an interop/v1 cache key.
195///
196/// Keys are `{namespace}:{operation}:{args_hash}` per the protocol's
197/// interop-mode spec, so the same entry is addressable from the Python and
198/// TypeScript SDKs — Python's `@cache(interop="get_user",
199/// namespace="users")` computes the identical key for the same arguments.
200///
201/// # Attributes
202///
203/// - `client = <ident>` (required): Name of the `&CacheKit` parameter.
204/// - `ttl = <integer>` (required): TTL in seconds.
205/// - `interop = "<string>"` (required): explicit, language-neutral operation
206/// segment (`^[a-z0-9][a-z0-9._-]{0,63}$`) — same meaning as Python's
207/// `interop=` / TypeScript's `interop:`.
208/// - `namespace = "<string>"` (required): interop/v1 namespace segment,
209/// same grammar. `ns` and `nsapi` are reserved as namespaces (a compile
210/// error — the CachekitIO server parses those key prefixes); operations are
211/// unaffected. Neither segment may contain `..` (a compile error — the
212/// server rejects `..` anywhere in a key); a lone `.` is fine.
213/// - `secure` (optional flag): require encryption — every call fails with
214/// `CachekitError::Config` on a client without it — and fail closed on
215/// every backend error (see *Reliability behaviour*). The flag is not what
216/// encrypts: on a client with encryption configured, a function without it
217/// stores and reads ciphertext too, because the client's value methods
218/// encrypt.
219///
220/// # Requirements
221///
222/// - The function must return `Result<T, E>` where `E: From<CachekitError>`
223/// — `CachekitError` itself, or the application's own error type. The
224/// body's errors reach the caller unchanged and are never cached;
225/// cache-layer errors that propagate (see *Reliability behaviour*) arrive
226/// converted through `From`. The error type is taken from the return type
227/// as written, so a one-argument alias such as `anyhow::Result<T>` works.
228/// - Every non-client argument must be a plain identifier (no destructuring
229/// patterns, no `self`) and must convert into
230/// `cachekit::interop::InteropValue` via `From` (`bool`, `i32`/`i64`/
231/// `u32`/`u64`/`i128`, `f64`, `&str`/`String`, `Uuid`). Unsupported
232/// argument types fail at compile time.
233/// - In-model *values* outside the interop ranges fail at CALL time with
234/// `CachekitError::Serialization` before the body runs: non-finite floats
235/// (`NAN`, `±INFINITY`) and `i128` outside `[-2^63, 2^64-1]`. This
236/// fail-loud contract matches the Python and TypeScript SDKs — silently
237/// running uncached would mask cross-SDK key divergence.
238/// - The client must be built **without** `.namespace()` — interop keys
239/// carry their own namespace segment, and reads fail closed on a
240/// namespaced client.
241/// - A stored entry that cannot be decoded as the return type
242/// (`CachekitError::Serialization`) is treated as a miss and overwritten
243/// (self-healing). On a client with encryption configured, `secure` or
244/// not, this covers only post-decrypt decode failures: an entry that fails
245/// AES-GCM authentication raises `CachekitError::Encryption`, which
246/// propagates (fail-closed). The failed read drops the key's L1 copy and
247/// leaves the backend entry in place, so the next call reads the backend
248/// again: it fails while that entry remains, succeeds once any process
249/// replaces it with ciphertext this client can decrypt, and is a miss once
250/// it expires or is deleted.
251///
252/// # Requirements (continued)
253///
254/// - The function must be `async` — the generated body awaits the cache
255/// read, the cold-miss single-flight, and SWR refresh scheduling. A sync
256/// function is a clear compile-time error, never a silent no-op.
257///
258/// # Reliability behaviour
259///
260/// - **Stale-while-revalidate** (client SWR enabled — the default with the
261/// `l1` feature on native targets): an L1 hit past the client's freshness
262/// threshold (`swr_threshold_ratio` × entry TTL, ±10% jitter) but before
263/// hard expiry is returned **immediately** — the caller never blocks on a
264/// merely-stale entry — while one background task re-executes the function.
265/// Its version-checked commit rewrites both layers only if no newer set or
266/// delete replaced the stale entry; a successful commit renews the L1 hard
267/// expiry with the full write-path TTL. Refresh dedup takes the
268/// `CacheKit::single_flight` locks without waiting on them: N concurrent
269/// stale readers trigger at most one re-execution per process (and, on
270/// lock-capable backends, per fleet), and none while the lock call fails,
271/// in which case the stale value serves until hard expiry. Hard-expired
272/// entries always take the normal blocking miss path.
273/// The refresh task needs a tokio runtime (skipped otherwise — the stale
274/// value was already served) and captures arguments by owned copy
275/// (`Clone`/`ToOwned` — already required for key derivation); the future
276/// must be `Send`, so `T` and anything the body holds across an `.await`
277/// must be too. `E` need not be: the refresh drops the body's error before
278/// it awaits again, so `Box<dyn Error>` works. Refresh failures are
279/// absorbed: the stale value keeps serving until hard expiry, where the
280/// blocking path surfaces errors.
281/// - **Graceful degradation**: on an outage-class backend failure —
282/// transient, timeout, an open circuit breaker, or a backpressure shed —
283/// the plain path fails *open*: the function executes uncached and its
284/// result is returned.
285/// Permanent and authentication errors propagate even on the plain path
286/// (a wrong API key must fail loudly, not silently disable caching
287/// forever). With `secure`, *every* backend and decryption error fails
288/// *closed* and propagates: an encrypted workload never silently
289/// degrades. Without the flag, a client with encryption configured still
290/// propagates decryption errors and still encrypts what it writes; only
291/// the outage-class backend errors above fail open.
292/// - **Cold-miss single-flight**: concurrent calls that miss on the same
293/// key are collapsed to one execution per process (and per fleet, when
294/// the backend supports distributed fill locks — CachekitIO and Redis do,
295/// with the `reliability` feature). Waiters re-read the filled entry
296/// instead of recomputing. See `cachekit::flight`.
297///
298/// # Example
299///
300/// ```ignore
301/// #[cachekit(client = cache, ttl = 60, interop = "get_user", namespace = "users")]
302/// async fn get_user(cache: &CacheKit, id: u64) -> Result<User, CachekitError> {
303/// Ok(User { name: format!("User {id}") })
304/// }
305/// ```
306#[proc_macro_attribute]
307pub fn cachekit(attr: TokenStream, item: TokenStream) -> TokenStream {
308 let args = parse_macro_input!(attr as MacroArgs);
309 let input_fn = parse_macro_input!(item as ItemFn);
310
311 match expand(&args, input_fn) {
312 Ok(tokens) => tokens.into(),
313 Err(e) => e.to_compile_error().into(),
314 }
315}
316
317fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
318 let client_ident = &args.client;
319 let ttl_secs = args.ttl;
320 let namespace = args.namespace.as_str();
321 let operation = args.interop.as_str();
322
323 // The generated body awaits (cache reads, single-flight, SWR refresh
324 // scheduling); a sync function cannot drive any of it. Fail with a clear
325 // decoration-time error instead of a cascade of "await is only allowed
326 // inside async functions" — no silent no-op (the sibling SDK posture).
327 if func.sig.asyncness.is_none() {
328 return Err(syn::Error::new_spanned(
329 func.sig.fn_token,
330 "#[cachekit] requires an `async fn`: the cache read, cold-miss single-flight, \
331 and stale-while-revalidate background refresh all await — annotate the \
332 function `async` (a sync function cannot be cached by this macro)",
333 ));
334 }
335
336 // Extract the Ok type from Result<T, E>
337 let ok_type = extract_ok_type(&func.sig.output)?;
338
339 // Collect non-client parameters (ident + type) for cache key derivation
340 // and SWR refresh capture. Every parameter MUST contribute to the key — a
341 // silently skipped parameter means two different calls share one cache
342 // entry (wrong-data collision), so anything we can't name is a compile
343 // error, never a skip.
344 let mut non_client_params: Vec<(&Ident, &Type)> = Vec::new();
345 for arg in &func.sig.inputs {
346 match arg {
347 syn::FnArg::Receiver(recv) => {
348 return Err(syn::Error::new_spanned(
349 recv,
350 "#[cachekit] does not support methods: a `self` receiver cannot \
351 contribute to the interop/v1 cache key — wrap a free function instead",
352 ));
353 }
354 syn::FnArg::Typed(pat) => match pat.pat.as_ref() {
355 syn::Pat::Ident(pi) => {
356 if pi.ident != *client_ident {
357 non_client_params.push((&pi.ident, pat.ty.as_ref()));
358 }
359 }
360 other => {
361 return Err(syn::Error::new_spanned(
362 other,
363 "#[cachekit] parameters must be plain identifiers: destructuring \
364 patterns cannot contribute to the cache key, and skipping them \
365 would make different calls share one cache entry",
366 ));
367 }
368 },
369 }
370 }
371 let non_client_idents: Vec<&Ident> = non_client_params.iter().map(|(id, _)| *id).collect();
372
373 // Convert each non-client argument to an InteropValue. `.clone()` keeps
374 // the original binding usable by the function body; for Copy types it
375 // compiles to a copy. Types outside the interop data model fail here at
376 // COMPILE time with a missing `From<T> for InteropValue` — that is the
377 // contract, not a bug: interop/v1 keys only hash modelable values.
378 // quote_spanned pins that error to the offending parameter, not the
379 // attribute.
380 let interop_args = non_client_idents.iter().map(|id| {
381 quote_spanned! {id.span()=> cachekit::interop::InteropValue::from(#id.clone()) }
382 });
383
384 // Generate cache get/set calls depending on `secure` flag.
385 let (get_expr, get_swr_expr, set_expr, complete_refresh_expr) = if args.secure {
386 (
387 quote! {
388 {
389 let __ck_sec = #client_ident.secure_cache()?;
390 __ck_sec.interop_get::<#ok_type>(&__ck_key).await
391 }
392 },
393 quote! {
394 {
395 let __ck_sec = #client_ident.secure_cache()?;
396 __ck_sec.interop_get_swr::<#ok_type>(&__ck_key).await
397 }
398 },
399 quote! {
400 let __ck_sec = #client_ident.secure_cache()?;
401 __ck_sec.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await
402 },
403 quote! {
404 let __ck_sec = #client_ident.secure_cache()?;
405 __ck_sec.__complete_swr_refresh(
406 &__ck_key,
407 __ck_val,
408 std::time::Duration::from_secs(#ttl_secs),
409 __ck_swr_token,
410 ).await
411 },
412 )
413 } else {
414 (
415 quote! { #client_ident.interop_get::<#ok_type>(&__ck_key).await },
416 quote! { #client_ident.interop_get_swr::<#ok_type>(&__ck_key).await },
417 quote! {
418 #client_ident.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await
419 },
420 quote! {
421 #client_ident.__complete_swr_refresh(
422 &__ck_key,
423 __ck_val,
424 std::time::Duration::from_secs(#ttl_secs),
425 __ck_swr_token,
426 ).await
427 },
428 )
429 };
430
431 // SWR refresh capture: the background task is `'static`, so every
432 // argument is re-materialised as an owned value before the spawn and
433 // rebound under its original name (and, for references, its original
434 // shape) inside the task — the function body runs unchanged:
435 // - `&str` → captured as `String`, rebound via `.as_str()` (exact)
436 // - other `&T` → captured as `T` via `ToOwned`, rebound as `&owned`
437 // - owned `T` → captured via `Clone`, rebound by value
438 let mut swr_captures = TokenStream2::new();
439 let mut swr_rebinds = TokenStream2::new();
440 for (i, (id, ty)) in non_client_params.iter().enumerate() {
441 let holder = quote::format_ident!("__ck_swr_arg_{i}");
442 match ty {
443 Type::Reference(r) => {
444 // The refresh task re-executes the body from an owned
445 // snapshot rebound as an immutable borrow — mutation through
446 // a `&mut` argument cannot be preserved, so reject it up
447 // front instead of surfacing a confusing downstream error.
448 if r.mutability.is_some() {
449 return Err(syn::Error::new_spanned(
450 ty,
451 "#[cachekit] does not support `&mut` parameters: the SWR background \
452 refresh re-executes the function from an owned snapshot, so mutation \
453 through the reference cannot be preserved — take the argument by \
454 owned value instead",
455 ));
456 }
457 swr_captures.extend(quote_spanned! {id.span()=>
458 let #holder = ::std::borrow::ToOwned::to_owned(#id);
459 });
460 let is_str = matches!(r.elem.as_ref(),
461 Type::Path(p) if p.path.is_ident("str"));
462 if is_str {
463 swr_rebinds.extend(quote! { let #id = #holder.as_str(); });
464 } else {
465 swr_rebinds.extend(quote! { let #id = &#holder; });
466 }
467 }
468 _ => {
469 swr_captures.extend(quote_spanned! {id.span()=>
470 #[allow(clippy::clone_on_copy)]
471 let #holder = #id.clone();
472 });
473 swr_rebinds.extend(quote! { let #id = #holder; });
474 }
475 }
476 }
477
478 // Graceful degradation: on an OUTAGE-class backend failure —
479 // retryable (transient/timeout), a fast-failing open circuit breaker,
480 // or a backpressure shed — the plain path fails OPEN: the
481 // wrapped function runs uncached, so a cache outage costs performance,
482 // not availability. A shed is the same class as CircuitOpen (the call
483 // never reached the backend), and failing open adds no work a cold-miss
484 // storm doesn't already create: the limiter bounds backend cache ops,
485 // not origin executions, and single-flight still dedupes those.
486 // Permanent and authentication errors PROPAGATE even on the plain path:
487 // a wrong API key that silently fell open would run uncached forever
488 // with zero signal while looking healthy. The
489 // `secure` path stays fail-CLOSED on everything: backend and decryption
490 // errors reach the caller, so an encrypted workload never silently
491 // degrades.
492 let fail_open_arm = if args.secure {
493 quote! {}
494 } else {
495 quote! {
496 Err(cachekit::error::CachekitError::Backend(__ck_be))
497 if __ck_be.kind.is_retryable()
498 || matches!(
499 __ck_be.kind,
500 cachekit::error::BackendErrorKind::CircuitOpen
501 | cachekit::error::BackendErrorKind::Backpressure
502 ) => {}
503 }
504 };
505
506 // Capture the original function body.
507 let original_body = &func.block;
508
509 // Extract the raw return type (without `->` arrow) for type annotation.
510 let ret_ty = match &func.sig.output {
511 ReturnType::Type(_, ty) => ty.as_ref(),
512 ReturnType::Default => {
513 return Err(syn::Error::new_spanned(
514 &func.sig,
515 "#[cachekit] function must return Result<T, E> where E: From<CachekitError>",
516 ));
517 }
518 };
519
520 // Build the new function body.
521 let new_body: syn::Block = syn::parse_quote! {
522 {
523 // interop/v1 key: {namespace}:{operation}:{args_hash} — the same
524 // key is computable from any SDK (protocol spec/interop-mode.md).
525 // The generated `.clone()` per argument keeps the binding usable
526 // by the body; for Copy types it is a copy and for `&str` it is
527 // a deliberate reference copy — hence the allows.
528 #[allow(clippy::clone_on_copy, noop_method_call)]
529 let __ck_key = cachekit::interop::interop_key(
530 #namespace,
531 #operation,
532 &[#(#interop_args),*],
533 )?;
534
535 // Try cache hit, classified against the stale-while-revalidate
536 // freshness window. A Serialization error means the stored entry
537 // cannot be decoded as #ok_type (poisoned, foreign-shaped, or a
538 // Python-internal CK frame) — treat it as a miss so the fresh
539 // result OVERWRITES it, instead of hard-failing every call until
540 // TTL expiry. Backend errors fail open on the plain path (run
541 // the function uncached) and fail closed on the secure path.
542 // Other errors (namespaced-client Config, decryption) propagate.
543 match #get_swr_expr {
544 Ok(cachekit::SwrRead::Fresh(__ck_cached)) => return Ok(__ck_cached),
545 // Stale (past the freshness threshold, before hard expiry):
546 // serve the cached value immediately and schedule ONE
547 // background refresh. Dedup takes the same locks as the
548 // cold-miss single-flight but never waits on them: the first
549 // refresh task leads and re-executes the function; a task
550 // that finds the in-process flight held, the distributed
551 // fill lock contested, or the lock call failing, exits at
552 // once without polling or
553 // re-reading (no wait, no billed miss). Refresh
554 // failures are deliberately absorbed (a failed commit is
555 // traced, kind and key hash only) — the stale value keeps
556 // being served, a later stale read retries, and hard expiry
557 // falls through to the blocking path where errors surface.
558 // Completion is version-checked, so a newer explicit set or
559 // delete always wins over this older origin computation.
560 Ok(cachekit::SwrRead::Stale(__ck_cached, __ck_swr_token)) => {
561 let __ck_swr_client = ::std::clone::Clone::clone(#client_ident);
562 let __ck_swr_key = __ck_key.clone();
563 #swr_captures
564 cachekit::__swr_spawn(async move {
565 let #client_ident = &__ck_swr_client;
566 let __ck_key = __ck_swr_key;
567 #swr_rebinds
568 let _: ::std::result::Result<(), cachekit::error::CachekitError> = async {
569 let Some(__ck_flight) = #client_ident.__refresh_flight(&__ck_key).await else {
570 // Another worker is already filling this key.
571 return Ok(());
572 };
573 let __ck_result: #ret_ty = (async #original_body).await;
574 // Drop the body's error here, before the next
575 // await: holding it across one would demand
576 // `E: Send` of the spawned refresh, and
577 // `Box<dyn Error>` is not.
578 let __ck_ok = __ck_result.ok();
579 if let Some(ref __ck_val) = __ck_ok {
580 // Commit only if no newer set/delete replaced
581 // the stale entry while the origin ran. A lost
582 // version check is `Ok(false)`, not a failure;
583 // a failed commit is traced like a failed fill
584 // store.
585 let __ck_commit = { #complete_refresh_expr };
586 if let Err(ref __ck_err) = __ck_commit {
587 cachekit::metrics::__trace_store_error(&__ck_key, __ck_err);
588 }
589 }
590 __ck_flight.release().await;
591 Ok(())
592 }
593 .await;
594 });
595 return Ok(__ck_cached);
596 }
597 Ok(cachekit::SwrRead::Miss) => {}
598 Err(cachekit::error::CachekitError::Serialization(_)) => {}
599 #fail_open_arm
600 Err(__ck_err) => return Err(__ck_err.into()),
601 }
602
603 // Cold-miss single-flight: collapse concurrent fills of this key
604 // to one execution (misses are billable). While another worker is
605 // filling, re-check the cache instead of recomputing. A re-check
606 // still running at a contested flight's deadline counts as a miss.
607 let mut __ck_flight = #client_ident.single_flight(&__ck_key).await;
608 while __ck_flight.wait_for_fill().await {
609 let Some(__ck_read) = __ck_flight.__bounded_poll(async { #get_expr }).await else {
610 continue;
611 };
612 match __ck_read {
613 Ok(Some(__ck_cached)) => {
614 __ck_flight.release().await;
615 return Ok(__ck_cached);
616 }
617 Ok(None) => {}
618 Err(cachekit::error::CachekitError::Serialization(_)) => {}
619 #fail_open_arm
620 Err(__ck_err) => {
621 __ck_flight.release().await;
622 return Err(__ck_err.into());
623 }
624 }
625 }
626
627 // Execute original function body
628 let __ck_result: #ret_ty = (async #original_body).await;
629
630 // Cache on success. A failed store still returns the result, so
631 // it is traced (kind and key hash only). A stored fill releases
632 // the distributed lock without waiting for the unlock; after an
633 // error or a failed store a re-call would miss again, so the
634 // unlock stays inline and that re-call never contests this
635 // call's own lock.
636 let __ck_stored = if let Ok(ref __ck_val) = __ck_result {
637 let __ck_store = { #set_expr };
638 if let Err(ref __ck_err) = __ck_store {
639 cachekit::metrics::__trace_store_error(&__ck_key, __ck_err);
640 }
641 __ck_store.is_ok()
642 } else {
643 false
644 };
645 if __ck_stored {
646 __ck_flight.__release_detached().await;
647 } else {
648 __ck_flight.release().await;
649 }
650 __ck_result
651 }
652 };
653
654 *func.block = new_body;
655 Ok(quote! { #func })
656}
657
658#[cfg(test)]
659mod tests {
660 use super::{expand, segment_is_valid, MacroArgs};
661
662 /// A sync fn must fail at decoration time with a clear, actionable error
663 /// — never a cascade of "await is only allowed inside async" diagnostics,
664 /// and never a silent no-op.
665 #[test]
666 fn sync_fn_is_a_clear_decoration_time_error() {
667 let args: MacroArgs = syn::parse_quote!(
668 client = cache,
669 ttl = 60,
670 interop = "get_user",
671 namespace = "users"
672 );
673 let func: syn::ItemFn = syn::parse_quote! {
674 fn get_user(cache: &CacheKit, id: u64) -> Result<String, CachekitError> {
675 Ok(format!("User {id}"))
676 }
677 };
678 let err = expand(&args, func).expect_err("sync fn must be rejected");
679 let msg = err.to_string();
680 assert!(
681 msg.contains("requires an `async fn`"),
682 "error must name the fix, got: {msg}"
683 );
684 }
685
686 /// `&mut` parameters are rejected at decoration time: the SWR refresh
687 /// task rebinds references as immutable borrows of an owned snapshot,
688 /// so mutation through the argument cannot be modelled.
689 #[test]
690 fn mut_ref_param_is_a_clear_decoration_time_error() {
691 let args: MacroArgs = syn::parse_quote!(
692 client = cache,
693 ttl = 60,
694 interop = "get_user",
695 namespace = "users"
696 );
697 let func: syn::ItemFn = syn::parse_quote! {
698 async fn get_user(cache: &CacheKit, id: &mut u64) -> Result<String, CachekitError> {
699 Ok(format!("User {id}"))
700 }
701 };
702 let err = expand(&args, func).expect_err("&mut param must be rejected");
703 assert!(
704 err.to_string().contains("`&mut` parameters"),
705 "error must name the constraint, got: {err}"
706 );
707 }
708
709 /// The same function with `async` added expands cleanly — proving the
710 /// rejection above is precisely about asyncness.
711 #[test]
712 fn async_fn_expands() {
713 let args: MacroArgs = syn::parse_quote!(
714 client = cache,
715 ttl = 60,
716 interop = "get_user",
717 namespace = "users"
718 );
719 let func: syn::ItemFn = syn::parse_quote! {
720 async fn get_user(cache: &CacheKit, id: u64) -> Result<String, CachekitError> {
721 Ok(format!("User {id}"))
722 }
723 };
724 expand(&args, func).expect("async fn must expand");
725 }
726
727 /// Mirror of interop.rs's validate_segment acceptance table — the two
728 /// implementations must not drift (see the NOTE on validate_segment).
729 #[test]
730 fn segment_grammar_table() {
731 for ok in [
732 "a",
733 "0",
734 "users",
735 "users.fetch_by_id",
736 "a-b_c.d",
737 &"a".repeat(64),
738 ] {
739 assert!(segment_is_valid(ok), "{ok:?} should be accepted");
740 }
741 for bad in [
742 "",
743 ".users",
744 "-users",
745 "_users",
746 "Users",
747 "users\n",
748 "users:x",
749 "users/x",
750 "usérs",
751 &"a".repeat(65),
752 ] {
753 assert!(!segment_is_valid(bad), "{bad:?} should be rejected");
754 }
755 }
756
757 /// Mirror of interop.rs's namespace reservation: `ns` / `nsapi` are a
758 /// compile error as `namespace`, but stay valid as the operation.
759 #[test]
760 fn reserved_namespace_is_a_compile_error() {
761 let attr = |interop: &str, namespace: &str| {
762 syn::parse_str::<MacroArgs>(&format!(
763 "client = cache, ttl = 60, interop = {interop:?}, namespace = {namespace:?}"
764 ))
765 };
766 for reserved in ["ns", "nsapi"] {
767 let Err(err) = attr("get_user", reserved) else {
768 panic!("namespace {reserved:?} must be rejected");
769 };
770 assert!(
771 err.to_string().contains("reserved"),
772 "error must name the reservation, got: {err}"
773 );
774 assert!(
775 attr(reserved, "users").is_ok(),
776 "operation {reserved:?} must be accepted"
777 );
778 }
779 assert!(attr("get_user", "nsx").is_ok());
780 assert!(attr("get_user", "nsapi2").is_ok());
781 }
782
783 /// Mirror of interop.rs's `..` ban: a compile error in either segment,
784 /// while lone dots, including a trailing one, stay valid.
785 #[test]
786 fn double_dot_segment_is_a_compile_error() {
787 let attr = |interop: &str, namespace: &str| {
788 syn::parse_str::<MacroArgs>(&format!(
789 "client = cache, ttl = 60, interop = {interop:?}, namespace = {namespace:?}"
790 ))
791 };
792 for (interop, namespace) in [("get_user", "a..b"), ("x..y", "users"), ("x..", "users")] {
793 let Err(err) = attr(interop, namespace) else {
794 panic!("{interop:?}/{namespace:?} must be rejected");
795 };
796 assert!(
797 err.to_string().contains("must not contain"),
798 "error must name the '..' ban, got: {err}"
799 );
800 }
801 assert!(attr("users.fetch.by_id", "app.").is_ok());
802 }
803}