Skip to main content

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            // `secure_cache()?` fails a client without encryption; on one with
388            // it, the client's reads already decrypt.
389            quote! {
390                {
391                    #client_ident.secure_cache()?;
392                    #client_ident.__interop_read::<#ok_type>(&__ck_key).await
393                }
394            },
395            quote! {
396                {
397                    #client_ident.secure_cache()?;
398                    #client_ident.__interop_read_swr::<#ok_type>(&__ck_key).await
399                }
400            },
401            quote! {
402                let __ck_sec = #client_ident.secure_cache()?;
403                __ck_sec.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await
404            },
405            quote! {
406                let __ck_sec = #client_ident.secure_cache()?;
407                __ck_sec.__complete_swr_refresh(
408                    &__ck_key,
409                    __ck_val,
410                    std::time::Duration::from_secs(#ttl_secs),
411                    __ck_swr_token,
412                ).await
413            },
414        )
415    } else {
416        (
417            quote! { #client_ident.__interop_read::<#ok_type>(&__ck_key).await },
418            quote! { #client_ident.__interop_read_swr::<#ok_type>(&__ck_key).await },
419            quote! {
420                #client_ident.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await
421            },
422            quote! {
423                #client_ident.__complete_swr_refresh(
424                    &__ck_key,
425                    __ck_val,
426                    std::time::Duration::from_secs(#ttl_secs),
427                    __ck_swr_token,
428                ).await
429            },
430        )
431    };
432
433    // SWR refresh capture: the background task is `'static`, so every
434    // argument is re-materialised as an owned value before the spawn and
435    // rebound under its original name (and, for references, its original
436    // shape) inside the task — the function body runs unchanged:
437    //   - `&str`     → captured as `String`, rebound via `.as_str()` (exact)
438    //   - other `&T` → captured as `T` via `ToOwned`, rebound as `&owned`
439    //   - owned `T`  → captured via `Clone`, rebound by value
440    let mut swr_captures = TokenStream2::new();
441    let mut swr_rebinds = TokenStream2::new();
442    for (i, (id, ty)) in non_client_params.iter().enumerate() {
443        let holder = quote::format_ident!("__ck_swr_arg_{i}");
444        match ty {
445            Type::Reference(r) => {
446                // The refresh task re-executes the body from an owned
447                // snapshot rebound as an immutable borrow — mutation through
448                // a `&mut` argument cannot be preserved, so reject it up
449                // front instead of surfacing a confusing downstream error.
450                if r.mutability.is_some() {
451                    return Err(syn::Error::new_spanned(
452                        ty,
453                        "#[cachekit] does not support `&mut` parameters: the SWR background \
454                         refresh re-executes the function from an owned snapshot, so mutation \
455                         through the reference cannot be preserved — take the argument by \
456                         owned value instead",
457                    ));
458                }
459                swr_captures.extend(quote_spanned! {id.span()=>
460                    let #holder = ::std::borrow::ToOwned::to_owned(#id);
461                });
462                let is_str = matches!(r.elem.as_ref(),
463                    Type::Path(p) if p.path.is_ident("str"));
464                if is_str {
465                    swr_rebinds.extend(quote! { let #id = #holder.as_str(); });
466                } else {
467                    swr_rebinds.extend(quote! { let #id = &#holder; });
468                }
469            }
470            _ => {
471                swr_captures.extend(quote_spanned! {id.span()=>
472                    #[allow(clippy::clone_on_copy)]
473                    let #holder = #id.clone();
474                });
475                swr_rebinds.extend(quote! { let #id = #holder; });
476            }
477        }
478    }
479
480    // Graceful degradation: on an OUTAGE-class backend failure —
481    // retryable (transient/timeout), a fast-failing open circuit breaker,
482    // or a backpressure shed — the plain path fails OPEN: the
483    // wrapped function runs uncached, so a cache outage costs performance,
484    // not availability. A shed is the same class as CircuitOpen (the call
485    // never reached the backend), and failing open adds no work a cold-miss
486    // storm doesn't already create: the limiter bounds backend cache ops,
487    // not origin executions, and single-flight still dedupes those.
488    // Permanent and authentication errors PROPAGATE even on the plain path:
489    // a wrong API key that silently fell open would run uncached forever
490    // with zero signal while looking healthy. The
491    // `secure` path stays fail-CLOSED on everything: backend and decryption
492    // errors reach the caller, so an encrypted workload never silently
493    // degrades.
494    let fail_open_arm = if args.secure {
495        quote! {}
496    } else {
497        quote! {
498            Err(cachekit::error::CachekitError::Backend(__ck_be))
499                if __ck_be.kind.is_retryable()
500                    || matches!(
501                        __ck_be.kind,
502                        cachekit::error::BackendErrorKind::CircuitOpen
503                            | cachekit::error::BackendErrorKind::Backpressure
504                    ) => {}
505        }
506    };
507
508    // Capture the original function body.
509    let original_body = &func.block;
510
511    // Extract the raw return type (without `->` arrow) for type annotation.
512    let ret_ty = match &func.sig.output {
513        ReturnType::Type(_, ty) => ty.as_ref(),
514        ReturnType::Default => {
515            return Err(syn::Error::new_spanned(
516                &func.sig,
517                "#[cachekit] function must return Result<T, E> where E: From<CachekitError>",
518            ));
519        }
520    };
521
522    // Build the new function body.
523    let new_body: syn::Block = syn::parse_quote! {
524        {
525            // interop/v1 key: {namespace}:{operation}:{args_hash} — the same
526            // key is computable from any SDK (protocol spec/interop-mode.md).
527            // The generated `.clone()` per argument keeps the binding usable
528            // by the body; for Copy types it is a copy and for `&str` it is
529            // a deliberate reference copy — hence the allows.
530            #[allow(clippy::clone_on_copy, noop_method_call)]
531            let __ck_key = cachekit::interop::interop_key(
532                #namespace,
533                #operation,
534                &[#(#interop_args),*],
535            )?;
536
537            // Try cache hit, classified against the stale-while-revalidate
538            // freshness window. A Serialization error means the stored entry
539            // cannot be decoded as #ok_type (poisoned, foreign-shaped, or a
540            // Python-internal CK frame) — treat it as a miss so the fresh
541            // result OVERWRITES it, instead of hard-failing every call until
542            // TTL expiry. Backend errors fail open on the plain path (run
543            // the function uncached) and fail closed on the secure path.
544            // Other errors (namespaced-client Config, decryption) propagate.
545            //
546            // The call counts ONE outcome in the client's stats and telemetry
547            // headers: a hit by the layer that served its value, or a miss
548            // when it runs the function. Its reads (this one, the
549            // single-flight re-polls below) count nothing themselves.
550            match #get_swr_expr {
551                Ok(cachekit::SwrRead::Fresh((__ck_cached, __ck_by))) => {
552                    #client_ident.__count(&__ck_key, __ck_by);
553                    return Ok(__ck_cached);
554                }
555                // Stale (past the freshness threshold, before hard expiry):
556                // serve the cached value immediately and schedule ONE
557                // background refresh. Dedup takes the same locks as the
558                // cold-miss single-flight but never waits on them: the first
559                // refresh task leads and re-executes the function; a task
560                // that finds the in-process flight held, the distributed
561                // fill lock contested, or the lock call failing, exits at
562                // once without polling or
563                // re-reading (no wait, no billed miss). Refresh
564                // failures are deliberately absorbed (a failed commit is
565                // traced, kind and key hash only) — the stale value keeps
566                // being served, a later stale read retries, and hard expiry
567                // falls through to the blocking path where errors surface.
568                // Completion is version-checked, so a newer explicit set or
569                // delete always wins over this older origin computation.
570                Ok(cachekit::SwrRead::Stale((__ck_cached, __ck_by), __ck_swr_token)) => {
571                    #client_ident.__count(&__ck_key, __ck_by);
572                    let __ck_swr_client = ::std::clone::Clone::clone(#client_ident);
573                    let __ck_swr_key = __ck_key.clone();
574                    #swr_captures
575                    cachekit::__swr_spawn(async move {
576                        let #client_ident = &__ck_swr_client;
577                        let __ck_key = __ck_swr_key;
578                        #swr_rebinds
579                        let _: ::std::result::Result<(), cachekit::error::CachekitError> = async {
580                            let Some(__ck_flight) = #client_ident.__refresh_flight(&__ck_key).await else {
581                                // Another worker is already filling this key.
582                                return Ok(());
583                            };
584                            let __ck_result: #ret_ty = (async #original_body).await;
585                            // Drop the body's error here, before the next
586                            // await: holding it across one would demand
587                            // `E: Send` of the spawned refresh, and
588                            // `Box<dyn Error>` is not.
589                            let __ck_ok = __ck_result.ok();
590                            if let Some(ref __ck_val) = __ck_ok {
591                                // Commit only if no newer set/delete replaced
592                                // the stale entry while the origin ran. A lost
593                                // version check is `Ok(false)`, not a failure;
594                                // a failed commit is traced like a failed fill
595                                // store.
596                                let __ck_commit = { #complete_refresh_expr };
597                                if let Err(ref __ck_err) = __ck_commit {
598                                    cachekit::metrics::__trace_store_error(&__ck_key, __ck_err);
599                                }
600                            }
601                            __ck_flight.release().await;
602                            Ok(())
603                        }
604                        .await;
605                    });
606                    return Ok(__ck_cached);
607                }
608                Ok(cachekit::SwrRead::Miss) => {}
609                Err(cachekit::error::CachekitError::Serialization(_)) => {}
610                #fail_open_arm
611                Err(__ck_err) => return Err(__ck_err.into()),
612            }
613
614            // Cold-miss single-flight: collapse concurrent fills of this key
615            // to one execution (misses are billable). While another worker is
616            // filling, re-check the cache instead of recomputing. A re-check
617            // still running at a contested flight's deadline counts as a miss.
618            let mut __ck_flight = #client_ident.single_flight(&__ck_key).await;
619            while __ck_flight.wait_for_fill().await {
620                let Some(__ck_read) = __ck_flight.__bounded_poll(async { #get_expr }).await else {
621                    continue;
622                };
623                match __ck_read {
624                    Ok(Some((__ck_cached, __ck_by))) => {
625                        #client_ident.__count(&__ck_key, __ck_by);
626                        __ck_flight.release().await;
627                        return Ok(__ck_cached);
628                    }
629                    Ok(None) => {}
630                    Err(cachekit::error::CachekitError::Serialization(_)) => {}
631                    #fail_open_arm
632                    Err(__ck_err) => {
633                        __ck_flight.release().await;
634                        return Err(__ck_err.into());
635                    }
636                }
637            }
638
639            // The call runs the function: its one miss, counted before the
640            // write-after-miss so that write's telemetry headers carry it.
641            #client_ident.__count(&__ck_key, cachekit::metrics::ReadOutcome::Miss);
642
643            // Execute original function body
644            let __ck_result: #ret_ty = (async #original_body).await;
645
646            // Cache on success. A failed store still returns the result, so
647            // it is traced (kind and key hash only). A stored fill releases
648            // the distributed lock without waiting for the unlock; after an
649            // error or a failed store a re-call would miss again, so the
650            // unlock stays inline and that re-call never contests this
651            // call's own lock.
652            let __ck_stored = if let Ok(ref __ck_val) = __ck_result {
653                let __ck_store = { #set_expr };
654                if let Err(ref __ck_err) = __ck_store {
655                    cachekit::metrics::__trace_store_error(&__ck_key, __ck_err);
656                }
657                __ck_store.is_ok()
658            } else {
659                false
660            };
661            if __ck_stored {
662                __ck_flight.__release_detached().await;
663            } else {
664                __ck_flight.release().await;
665            }
666            __ck_result
667        }
668    };
669
670    *func.block = new_body;
671    Ok(quote! { #func })
672}
673
674#[cfg(test)]
675mod tests {
676    use super::{expand, segment_is_valid, MacroArgs};
677
678    /// A sync fn must fail at decoration time with a clear, actionable error
679    /// — never a cascade of "await is only allowed inside async" diagnostics,
680    /// and never a silent no-op.
681    #[test]
682    fn sync_fn_is_a_clear_decoration_time_error() {
683        let args: MacroArgs = syn::parse_quote!(
684            client = cache,
685            ttl = 60,
686            interop = "get_user",
687            namespace = "users"
688        );
689        let func: syn::ItemFn = syn::parse_quote! {
690            fn get_user(cache: &CacheKit, id: u64) -> Result<String, CachekitError> {
691                Ok(format!("User {id}"))
692            }
693        };
694        let err = expand(&args, func).expect_err("sync fn must be rejected");
695        let msg = err.to_string();
696        assert!(
697            msg.contains("requires an `async fn`"),
698            "error must name the fix, got: {msg}"
699        );
700    }
701
702    /// `&mut` parameters are rejected at decoration time: the SWR refresh
703    /// task rebinds references as immutable borrows of an owned snapshot,
704    /// so mutation through the argument cannot be modelled.
705    #[test]
706    fn mut_ref_param_is_a_clear_decoration_time_error() {
707        let args: MacroArgs = syn::parse_quote!(
708            client = cache,
709            ttl = 60,
710            interop = "get_user",
711            namespace = "users"
712        );
713        let func: syn::ItemFn = syn::parse_quote! {
714            async fn get_user(cache: &CacheKit, id: &mut u64) -> Result<String, CachekitError> {
715                Ok(format!("User {id}"))
716            }
717        };
718        let err = expand(&args, func).expect_err("&mut param must be rejected");
719        assert!(
720            err.to_string().contains("`&mut` parameters"),
721            "error must name the constraint, got: {err}"
722        );
723    }
724
725    /// The same function with `async` added expands cleanly — proving the
726    /// rejection above is precisely about asyncness.
727    #[test]
728    fn async_fn_expands() {
729        let args: MacroArgs = syn::parse_quote!(
730            client = cache,
731            ttl = 60,
732            interop = "get_user",
733            namespace = "users"
734        );
735        let func: syn::ItemFn = syn::parse_quote! {
736            async fn get_user(cache: &CacheKit, id: u64) -> Result<String, CachekitError> {
737                Ok(format!("User {id}"))
738            }
739        };
740        expand(&args, func).expect("async fn must expand");
741    }
742
743    /// Mirror of interop.rs's validate_segment acceptance table — the two
744    /// implementations must not drift (see the NOTE on validate_segment).
745    #[test]
746    fn segment_grammar_table() {
747        for ok in [
748            "a",
749            "0",
750            "users",
751            "users.fetch_by_id",
752            "a-b_c.d",
753            &"a".repeat(64),
754        ] {
755            assert!(segment_is_valid(ok), "{ok:?} should be accepted");
756        }
757        for bad in [
758            "",
759            ".users",
760            "-users",
761            "_users",
762            "Users",
763            "users\n",
764            "users:x",
765            "users/x",
766            "usérs",
767            &"a".repeat(65),
768        ] {
769            assert!(!segment_is_valid(bad), "{bad:?} should be rejected");
770        }
771    }
772
773    /// Mirror of interop.rs's namespace reservation: `ns` / `nsapi` are a
774    /// compile error as `namespace`, but stay valid as the operation.
775    #[test]
776    fn reserved_namespace_is_a_compile_error() {
777        let attr = |interop: &str, namespace: &str| {
778            syn::parse_str::<MacroArgs>(&format!(
779                "client = cache, ttl = 60, interop = {interop:?}, namespace = {namespace:?}"
780            ))
781        };
782        for reserved in ["ns", "nsapi"] {
783            let Err(err) = attr("get_user", reserved) else {
784                panic!("namespace {reserved:?} must be rejected");
785            };
786            assert!(
787                err.to_string().contains("reserved"),
788                "error must name the reservation, got: {err}"
789            );
790            assert!(
791                attr(reserved, "users").is_ok(),
792                "operation {reserved:?} must be accepted"
793            );
794        }
795        assert!(attr("get_user", "nsx").is_ok());
796        assert!(attr("get_user", "nsapi2").is_ok());
797    }
798
799    /// Mirror of interop.rs's `..` ban: a compile error in either segment,
800    /// while lone dots, including a trailing one, stay valid.
801    #[test]
802    fn double_dot_segment_is_a_compile_error() {
803        let attr = |interop: &str, namespace: &str| {
804            syn::parse_str::<MacroArgs>(&format!(
805                "client = cache, ttl = 60, interop = {interop:?}, namespace = {namespace:?}"
806            ))
807        };
808        for (interop, namespace) in [("get_user", "a..b"), ("x..y", "users"), ("x..", "users")] {
809            let Err(err) = attr(interop, namespace) else {
810                panic!("{interop:?}/{namespace:?} must be rejected");
811            };
812            assert!(
813                err.to_string().contains("must not contain"),
814                "error must name the '..' ban, got: {err}"
815            );
816        }
817        assert!(attr("users.fetch.by_id", "app.").is_ok());
818    }
819}