Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,12 +333,17 @@ before it disappears: *fresh* until `swr_threshold_ratio` of its TTL has
elapsed, then *stale* until hard expiry. A `#[cachekit]`-wrapped call that
hits a stale entry returns it **immediately** — no caller ever blocks on a
merely-stale value — while exactly one background task re-executes the
function and rewrites both cache layers with a full TTL. Refresh dedup rides
the same single-flight as the cold-miss path (in-process, plus distributed
fill locks on lock-capable backends), so N concurrent stale readers cost one
origin execution — misses are billable; stampedes are not acceptable. A
hard-expired entry always takes the normal blocking miss path: SWR never
serves past hard expiry.
function. If the same-key mutation token is still current, the task rewrites
both cache layers and renews L1 hard expiry with the full write-path TTL; if a
newer `set()` or `delete()` landed through the same client (or one of its
clones) while the origin ran, that explicit mutation wins and the older
refresh result is discarded before it can touch L2. Entry expiry and capacity
eviction do not invalidate the token, so a valid slow refresh can still
repopulate both layers. Refresh dedup rides the same single-flight as the cold-miss path
(in-process, plus distributed fill locks on lock-capable backends), so N
concurrent stale readers cost one origin execution — misses are billable;
stampedes are not acceptable. A hard-expired entry always takes the normal
blocking miss path: SWR never serves past hard expiry.

```rust,ignore
let cache = CacheKit::builder()
Expand All @@ -356,6 +361,15 @@ fraction; enabled by default) and cachekit-ts (`getWithSwr`). Worth knowing:
bounds staleness of L2-derived data, but SWR replaces its expiry cliff with
a background refresh that restores the full write-path TTL. The configured
ratio is never silently clamped; the window follows the entry.
- **Refresh completion is version-guarded and same-key ordered.** Each L1
stale read receives a mutation token. A concurrent explicit write or delete
through that client or a clone invalidates the token, so an older origin
result cannot clobber the new value or resurrect the deletion in either
layer. The guard is intentionally process-local; cross-instance invalidation
is outside SWR's serving-policy scope.
- **Jitter is fixed per entry.** The ±10% threshold jitter is drawn when an
entry is inserted, not on every hit; hot L1 reads do not perform entropy
work and the entry's freshness boundary stays stable for its lifetime.
- **The background refresh needs a tokio runtime** (`Handle::try_current`).
On other executors the stale value is still served and the refresh is
skipped — behaviourally SWR-off, never a panic.
Expand Down
33 changes: 28 additions & 5 deletions crates/cachekit-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,10 @@ fn extract_ok_type(ret: &ReturnType) -> syn::Result<Type> {
/// `l1` feature on native targets): an L1 hit past the client's freshness
/// threshold (`swr_threshold_ratio` × entry TTL, ±10% jitter) but before
/// hard expiry is returned **immediately** — the caller never blocks on a
/// merely-stale entry — while one background task re-executes the function
/// and rewrites both cache layers. Refresh dedup rides
/// merely-stale entry — while one background task re-executes the function.
/// Its version-checked commit rewrites both layers only if no newer set or
/// delete replaced the stale entry; a successful commit renews the L1 hard
/// expiry with the full write-path TTL. Refresh dedup rides
/// [`CacheKit::single_flight`]: N concurrent stale readers trigger exactly
/// one re-execution per process (and, on lock-capable backends, per
/// fleet). Hard-expired entries always take the normal blocking miss path.
Expand Down Expand Up @@ -326,7 +328,7 @@ fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
});

// Generate cache get/set calls depending on `secure` flag.
let (get_expr, get_swr_expr, set_expr) = if args.secure {
let (get_expr, get_swr_expr, set_expr, complete_refresh_expr) = if args.secure {
(
quote! {
{
Expand All @@ -344,6 +346,15 @@ fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
let __ck_sec = #client_ident.secure()?;
let _ = __ck_sec.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await;
},
quote! {
let __ck_sec = #client_ident.secure()?;
let _ = __ck_sec.__complete_swr_refresh(
&__ck_key,
__ck_val,
std::time::Duration::from_secs(#ttl_secs),
__ck_swr_token,
).await;
},
)
} else {
(
Expand All @@ -352,6 +363,14 @@ fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
quote! {
let _ = #client_ident.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await;
},
quote! {
let _ = #client_ident.__complete_swr_refresh(
&__ck_key,
__ck_val,
std::time::Duration::from_secs(#ttl_secs),
__ck_swr_token,
).await;
},
)
};

Expand Down Expand Up @@ -472,7 +491,9 @@ fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
// failures are deliberately absorbed — the stale value keeps
// being served, a later stale read retries, and hard expiry
// falls through to the blocking path where errors surface.
Ok(cachekit::SwrRead::Stale(__ck_cached)) => {
// Completion is version-checked, so a newer explicit set or
// delete always wins over this older origin computation.
Ok(cachekit::SwrRead::Stale(__ck_cached, __ck_swr_token)) => {
let __ck_swr_client = ::std::clone::Clone::clone(#client_ident);
let __ck_swr_key = __ck_key.clone();
#swr_captures
Expand All @@ -493,7 +514,9 @@ fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
}
let __ck_result: #ret_ty = (async #original_body).await;
if let Ok(ref __ck_val) = __ck_result {
#set_expr
// Commit only if no newer set/delete replaced
// the stale entry while the origin ran.
#complete_refresh_expr
}
__ck_flight.release().await;
__ck_result.map(|_| ())
Expand Down
Loading
Loading