Request memoization
#[memoize] shares a function's result across calls in one request. A later call reuses the result when its arguments and observed request context bindings match.
Each request starts with an empty cache. Results are not shared across requests.
Setup
Annotate any function that takes a cx: &Cx parameter:
use hearth::context::{Cx, memoize};
#[memoize]
async fn get_user(cx: &Cx, id: i64) -> User {
db::load_user(id).await
}
The first call to get_user(cx, 42).await computes the user. Later calls with the same ID reuse it if their request context dependencies match. The macro changes the return type from T to &T, borrowed for the lifetime of cx. Use as_ref to borrow the contents of an Option or Result instead.
Sync and async
The attribute works on synchronous and async functions.
#[memoize]
fn parse_config(cx: &Cx, raw: &str) -> Config {
serde_json::from_str(raw).unwrap()
}
#[memoize]
async fn fetch_post(cx: &Cx, slug: &str) -> Post {
db::load_post(slug).await
}
For async functions, concurrent callers that resolve to the same cache entry share a single in-flight future. If two parts of your page render in parallel and both call fetch_post(cx, "hello"), the database is queried once and both callers await the same result.
Recursion
Memoized functions can recurse if and only if recursive calls use different arguments. Recursion with identical arguments panics.
Recursion with different arguments uses a different cache entry for each call:
#[memoize]
fn factorial(cx: &Cx, n: u64) -> u64 {
match n {
0 | 1 => 1,
_ => n * *factorial(cx, n - 1),
}
}
assert_eq!(*factorial(cx, 5), 120);
A nested call with the same arguments panics because it would otherwise deadlock:
use hearth::context::{Cx, memoize};
#[memoize]
fn recurse(cx: &Cx, n: u64) -> u64 {
*recurse(cx, n)
}
fn main() {
recurse(&Cx::default(), 1);
}
What gets cached
Every argument except cx is hashed into the cache key. The function's request context dependencies also determine whether a cached result can be reused.
#[memoize]
fn add(cx: &Cx, x: i32, y: i32) -> i32 {
println!("computing");
x + y
}
add(cx, 1, 2); // prints "computing", returns 3
add(cx, 1, 2); // returns 3 from cache
add(cx, 1, 3); // prints "computing", returns 4 (different args)
Each memoized function has a separate cache.
See request context dependencies for how scoped values affect reuse.
Request context dependencies
The macro tracks request context lookups made while the function runs. A cached result can be reused only when those lookups resolve to the same registered bindings in the caller's scope.
This matters because Cx::with derives a child scope holding additional values, so the same function can see a different value depending on the cx it is called with:
use hearth::context::{Cx, memoize, request_context};
struct Locale(&'static str);
#[memoize]
fn greeting(cx: &Cx) -> String {
format!("hello in {}", request_context::<Locale>(cx).0)
}
greeting(cx); // computes, reading the Locale registered for the request
let scoped = cx.with(Locale("de"));
greeting(&scoped); // a different Locale, so the body runs again
The two calls read different Locale bindings, so each computes a result. Both results stay cached for later calls through their respective scopes.
Only observed lookups affect reuse. Adding an unrelated value does not invalidate the result. A lookup that returned None is also tracked. If a later caller has a value for that type, the function runs again.
Values the body registers itself are not dependencies:
#[memoize]
fn banner(cx: &Cx) -> String {
let scoped = cx.with(Locale("en"));
format!("banner in {}", request_context::<Locale>(&scoped).0)
}
The function creates this binding itself, so it does not depend on the caller's value of that type.
Dependencies also propagate through nested memoized calls:
#[memoize]
fn locale(cx: &Cx) -> String {
request_context::<Locale>(cx).0.to_owned()
}
#[memoize]
fn greeting(cx: &Cx) -> String {
format!("hello in {}", locale(cx))
}
greeting never reads Locale itself, but it depends on it through locale, so a caller that scopes a different Locale recomputes both. This holds whether the nested call computed or hit the cache.
App context is not tracked. It is registered once for the application and cannot be scoped per request, so reading it never constrains reuse.
Borrowing Option and Result contents
By default the macro returns a reference to the cached value itself: a function returning Option<User> hands out &Option<User>. Pass as_ref to the attribute to borrow the cached value's contents instead:
use hearth::context::{Cx, memoize};
#[memoize(as_ref)]
async fn find_user(cx: &Cx, id: i64) -> Option<User> {
db::load_user(id).await
}
let user: Option<&User> = find_user(cx, 42).await;
With as_ref, the macro rewrites the return type through the MemoizeAsRef trait: Option<T> comes back as Option<&T> and Result<T, E> as Result<&T, &E>. Implement the trait for your own return types to use them with as_ref.
Borrowed and owned arguments
Arguments can be passed by value or by reference. The cache never stores a copy of the arguments: an entry is identified by a hash of them, so borrowed arguments are only hashed, never cloned.
#[memoize(as_ref)]
async fn lookup(cx: &Cx, name: &str) -> Result<Record, Error> {
db::find(name).await
}
let record = lookup(cx, "alice").await?; // computes; caches under the hash of "alice"
let record = lookup(cx, "alice").await?; // cache hit, no allocation
Requirements
The macro enforces these at compile time:
- The function must take a parameter literally named
cxof type&Cx. - The function cannot take a
selfreceiver. - Every argument except
cxmust implementHash, whether passed by value or by reference. - The return type
Tmust beSend + Sync + 'static.
The cache identifies arguments by their hash without an equality check. A custom Hash implementation must include every part of the value that affects the result. Omitting such a field can make distinct calls reuse the wrong value. Prefer deriving Hash when possible.
When to reach for it
Use #[memoize] when several callers need the same expensive result during one request. A shared user lookup is a typical example. Add a separate cache in your data access code if results should persist across requests.
Example: shared user lookup
use hearth::{
context::{Cx, memoize},
Result,
router::{Slot, layout, page},
view::{View, view},
};
#[memoize(as_ref)]
async fn current_user(cx: &Cx) -> Option<User> {
auth::resolve(cx).await
}
#[page]
async fn dashboard(cx: &Cx) -> Result<impl View> {
let user = current_user(cx).await; // computes once
Ok(view! { <h1>"Welcome, " (user.unwrap().name.clone())</h1> })
}
#[layout]
async fn root(cx: &Cx, slot: Slot<'_>) -> Result<impl View> {
let user = current_user(cx).await; // cache hit, no extra DB query
Ok(view! {
<header>
match user {
Some(u) => {
"Hello, " (u.name.clone())
},
None => <a href="/login">"Sign in"</a>,
}
</header>
(slot)
})
}
Whichever caller runs first loads the user. Later callers reuse the cached result, so the database is queried at most once per request.