Skip to main content

ocre/runtime/
cache.rs

1use std::{sync::Arc, time::Duration};
2
3use serde::{Serialize, de::DeserializeOwned};
4use worker::{Env, KvError, KvStore, send::SendFuture};
5
6use super::{Ctx, ctx::Memo};
7#[cfg(feature = "html")]
8use crate::cache::{Fragment, fragment_key};
9use crate::{
10    Error, Result,
11    cache::{
12        CACHE_BINDING, LOG_PREFIX, STORE_VAR, Store, binding_error, check_key, decode, encode, log_failure, store_kind,
13        ttl_seconds,
14    },
15};
16
17/// The KV namespace, or `None` when `CACHE_STORE` is `"null"` (caching off).
18fn store(env: &Env) -> Result<Option<KvStore>> {
19    let kind = env.var(STORE_VAR).ok().map(|value| value.to_string());
20    match store_kind(kind.as_deref())? {
21        Store::Null => Ok(None),
22        Store::Kv => env.kv(CACHE_BINDING).map(Some).map_err(|err| binding_error(&err)),
23    }
24}
25
26/// The text under `key`: from this request's memory when it was read or
27/// written already (Rails' local cache), else one KV read.
28async fn get_text(memo: &Memo, kv: &KvStore, key: &str) -> std::result::Result<Option<String>, KvError> {
29    if let Some(text) = memo.kv(key) {
30        return Ok(text);
31    }
32    let text = kv.get(key).text().await?;
33    memo.remember_kv(key, text.clone());
34    Ok(text)
35}
36
37/// One KV write, remembered for the rest of the request.
38async fn put_text(memo: &Memo, kv: &KvStore, key: &str, text: String, ttl: u64) -> std::result::Result<(), KvError> {
39    kv.put(key, text.as_str())?.expiration_ttl(ttl).execute().await?;
40    memo.remember_kv(key, Some(text));
41    Ok(())
42}
43
44fn kv_error(operation: &str, key: &str, err: &KvError) -> Error {
45    Error::internal(format!("{LOG_PREFIX} {operation} `{key}` failed: {err}"))
46}
47
48/// Read-through cache, like Rails' `Rails.cache.fetch`: the value under `key` in KV, or else the result of `compute`.
49///
50/// On a hit, the stored JSON is decoded as `T` and `compute` does not run. On
51/// a miss, `compute` runs and its value is stored for `ttl` (at least
52/// [`MIN_TTL`](crate::cache::MIN_TTL), truncated to whole seconds). KV
53/// failures (over a daily limit, a value from an older deploy that no longer
54/// decodes as `T`) are logged with [`LOG_PREFIX`](crate::cache::LOG_PREFIX)
55/// and the value is computed, so the handler keeps working.
56///
57/// Stored values are the JSON of `T`; put a version in the key (`:v1`) and
58/// bump it when `T` changes. The returned future is `Send`, so it can be
59/// awaited in axum handlers. A key already read or written during the same
60/// request is answered from memory (Rails' local cache). With
61/// [`STORE_VAR`](crate::cache::STORE_VAR) set to `"null"`, `compute` always
62/// runs and nothing is stored.
63///
64/// Free plan (September 2026): one KV read per call (100,000 a day), plus one
65/// KV write on a miss (**1,000 a day**; one per second per key). A key
66/// refreshed every `ttl` seconds costs up to `86,400 / ttl` writes a day.
67///
68/// # Errors
69///
70/// - [`Error::Internal`] (500) when the `CACHE` binding is missing, naming the
71///   fix: `ocre g cache` adds `CACHE: bindings.kv(),` to cloudflare.config.ts.
72/// - [`Error::Internal`] when `key` is empty or longer than 512 bytes, or
73///   `ttl` is below 60 seconds.
74/// - [`Error::Internal`] when the computed value does not serialize to JSON.
75/// - Any error returned by `compute`, unchanged (nothing is stored).
76///
77/// KV read and write failures are not errors: they are logged.
78///
79/// # Examples
80///
81/// ```rust,no_run
82/// use std::time::Duration;
83///
84/// use axum::{Json, extract::State};
85/// use ocre::{Ctx, Result};
86/// # #[derive(serde::Serialize, serde::Deserialize)]
87/// # struct Post { title: String }
88/// # async fn recent(_ctx: &Ctx) -> Result<Vec<Post>> { Ok(vec![]) }
89///
90/// async fn index(State(ctx): State<Ctx>) -> Result<Json<Vec<Post>>> {
91///     let posts: Vec<Post> = ocre::cache::fetch(&ctx, "posts:recent:v1", Duration::from_secs(600), || async {
92///         recent(&ctx).await // e.g. a D1 query
93///     })
94///     .await?;
95///     Ok(Json(posts))
96/// }
97/// ```
98pub fn fetch<'a, T, F, Fut>(
99    ctx: &Ctx,
100    key: &'a str,
101    ttl: Duration,
102    compute: F,
103) -> impl Future<Output = Result<T>> + Send + use<'a, T, F, Fut>
104where
105    T: Serialize + DeserializeOwned,
106    F: FnOnce() -> Fut,
107    Fut: Future<Output = Result<T>>,
108{
109    let env = ctx.env().clone();
110    let memo = Arc::clone(ctx.memo());
111    SendFuture::new(async move {
112        check_key(key)?;
113        let ttl = ttl_seconds(ttl)?;
114        let Some(kv) = store(&env)? else {
115            return compute().await;
116        };
117        match get_text(&memo, &kv, key).await {
118            Ok(Some(text)) => {
119                if let Some(value) = decode(key, &text) {
120                    return Ok(value);
121                }
122            }
123            Ok(None) => {}
124            Err(err) => log_failure("read", key, &err),
125        }
126        let value = compute().await?;
127        let json = encode(key, &value)?;
128        if let Err(err) = put_text(&memo, &kv, key, json, ttl).await {
129            log_failure("write", key, &err);
130        }
131        Ok(value)
132    })
133}
134
135/// The value under `key`, or `None` when it is absent, expired, unreadable as `T`, or when KV fails.
136///
137/// Never computes nor stores anything; pair it with [`write`](crate::cache::write)
138/// when [`fetch`](crate::cache::fetch)'s closure form does not fit. KV
139/// failures and values that no longer decode as `T` are logged with
140/// [`LOG_PREFIX`](crate::cache::LOG_PREFIX) and read as `None`.
141///
142/// Free plan: one KV read (100,000 a day), none when the request already
143/// read or wrote `key`, and none with [`STORE_VAR`](crate::cache::STORE_VAR)
144/// set to `"null"` (always `None`).
145///
146/// # Errors
147///
148/// - [`Error::Internal`] (500) when the `CACHE` binding is missing, naming the
149///   fix: `ocre g cache` adds `CACHE: bindings.kv(),` to cloudflare.config.ts.
150/// - [`Error::Internal`] when `key` is empty or longer than 512 bytes.
151///
152/// # Examples
153///
154/// ```rust,no_run
155/// use axum::extract::State;
156/// use ocre::{Ctx, Result};
157/// # #[derive(serde::Deserialize)]
158/// # struct Rates { eur: f64 }
159///
160/// async fn rate(State(ctx): State<Ctx>) -> Result<String> {
161///     let rates: Option<Rates> = ocre::cache::read(&ctx, "rates:v1").await?;
162///     Ok(rates.map_or_else(|| "unknown".to_owned(), |rates| rates.eur.to_string()))
163/// }
164/// ```
165pub fn read<'a, T: DeserializeOwned>(
166    ctx: &Ctx,
167    key: &'a str,
168) -> impl Future<Output = Result<Option<T>>> + Send + use<'a, T> {
169    let env = ctx.env().clone();
170    let memo = Arc::clone(ctx.memo());
171    SendFuture::new(async move {
172        check_key(key)?;
173        let Some(kv) = store(&env)? else {
174            return Ok(None);
175        };
176        match get_text(&memo, &kv, key).await {
177            Ok(text) => Ok(text.and_then(|text| decode(key, &text))),
178            Err(err) => {
179                log_failure("read", key, &err);
180                Ok(None)
181            }
182        }
183    })
184}
185
186/// Stores `value` as JSON under `key` for `ttl`, replacing any previous value.
187///
188/// `ttl` must be at least [`MIN_TTL`](crate::cache::MIN_TTL) (60 seconds)
189/// and is truncated to whole seconds. Other locations may still read the old
190/// value for up to 60 seconds. Unlike [`fetch`](crate::cache::fetch), a KV
191/// failure is an error, not a log line.
192///
193/// Free plan: one KV write (**1,000 a day**; one per second per key); none
194/// with [`STORE_VAR`](crate::cache::STORE_VAR) set to `"null"`.
195///
196/// # Errors
197///
198/// - [`Error::Internal`] (500) when the `CACHE` binding is missing, naming the
199///   fix: `ocre g cache` adds `CACHE: bindings.kv(),` to cloudflare.config.ts.
200/// - [`Error::Internal`] when `key` is empty or longer than 512 bytes, `ttl`
201///   is below 60 seconds, or `value` does not serialize to JSON.
202/// - [`Error::Internal`] when KV rejects the write, e.g. past the daily limit
203///   (message prefixed with [`LOG_PREFIX`](crate::cache::LOG_PREFIX)).
204///
205/// # Examples
206///
207/// ```rust,no_run
208/// use std::time::Duration;
209///
210/// use axum::extract::State;
211/// use ocre::{Ctx, Result};
212/// # #[derive(serde::Serialize)]
213/// # struct Rates { eur: f64 }
214///
215/// async fn refresh(State(ctx): State<Ctx>) -> Result<()> {
216///     let rates = Rates { eur: 0.92 }; // e.g. from a third-party API
217///     ocre::cache::write(&ctx, "rates:v1", &rates, Duration::from_secs(3600)).await
218/// }
219/// ```
220pub fn write<'a, T: Serialize + ?Sized>(
221    ctx: &Ctx,
222    key: &'a str,
223    value: &T,
224    ttl: Duration,
225) -> impl Future<Output = Result<()>> + Send + use<'a, T> {
226    let env = ctx.env().clone();
227    let memo = Arc::clone(ctx.memo());
228    let json = encode(key, value);
229    SendFuture::new(async move {
230        check_key(key)?;
231        let ttl = ttl_seconds(ttl)?;
232        let json = json?;
233        let Some(kv) = store(&env)? else {
234            return Ok(());
235        };
236        put_text(&memo, &kv, key, json, ttl).await.map_err(|err| kv_error("write", key, &err))
237    })
238}
239
240/// Removes `key`, e.g. after the data behind it changed (Rails' `Rails.cache.delete`).
241///
242/// Deleting an absent key succeeds. Other locations may still read the old
243/// value for up to 60 seconds.
244///
245/// Free plan: counts as one KV write (**1,000 a day**); none with
246/// [`STORE_VAR`](crate::cache::STORE_VAR) set to `"null"`.
247///
248/// # Errors
249///
250/// - [`Error::Internal`] (500) when the `CACHE` binding is missing, naming the
251///   fix: `ocre g cache` adds `CACHE: bindings.kv(),` to cloudflare.config.ts.
252/// - [`Error::Internal`] when `key` is empty or longer than 512 bytes.
253/// - [`Error::Internal`] when KV rejects the delete, e.g. past the daily limit
254///   (message prefixed with [`LOG_PREFIX`](crate::cache::LOG_PREFIX)).
255///
256/// # Examples
257///
258/// ```rust,no_run
259/// use axum::extract::{Path, State};
260/// use ocre::{Ctx, Result};
261///
262/// async fn update(State(ctx): State<Ctx>, Path(id): Path<i64>) -> Result<()> {
263///     # let _ = id;
264///     // post::update(&ctx, id, changes).await?;
265///     ocre::cache::delete(&ctx, "posts:recent:v1").await
266/// }
267/// ```
268pub fn delete<'a>(ctx: &Ctx, key: &'a str) -> impl Future<Output = Result<()>> + Send + use<'a> {
269    let env = ctx.env().clone();
270    let memo = Arc::clone(ctx.memo());
271    SendFuture::new(async move {
272        check_key(key)?;
273        let Some(kv) = store(&env)? else {
274            return Ok(());
275        };
276        kv.delete(key).await.map_err(|err| kv_error("delete", key, &err))?;
277        memo.remember_kv(key, None);
278        Ok(())
279    })
280}
281
282/// Deletes up to `limit` cached values whose key starts with `prefix` (Rails'
283/// `Rails.cache.clear`, bounded): `""` for everything, `"views/"` for
284/// fragments, `"posts/"` for one family of keys. Call it again while
285/// [`Cleared::more`](crate::cache::Cleared::more) is true, e.g. from a
286/// scheduled task after a deploy that changed the cached data's shape.
287///
288/// Free plan: one KV list (of 1,000 a day) plus one KV write per deleted
289/// key (**1,000 writes a day**), so keep `limit` small; with
290/// [`STORE_VAR`](crate::cache::STORE_VAR) set to `"null"` nothing is deleted.
291///
292/// # Errors
293///
294/// - [`Error::Internal`] (500) when the `CACHE` binding is missing (`ocre g cache` adds it).
295/// - [`Error::Internal`] when KV rejects the list or a delete, e.g. past the daily limit.
296///
297/// # Examples
298///
299/// ```rust,no_run
300/// use ocre::{Ctx, Result};
301///
302/// async fn nightly(ctx: Ctx) -> Result<()> {
303///     let cleared = ocre::cache::clear(&ctx, "views/", 200).await?;
304///     ctx.log().info(format_args!("cleared {} fragments, more: {}", cleared.deleted, cleared.more));
305///     Ok(())
306/// }
307/// # let _ = nightly;
308/// ```
309pub fn clear<'a>(
310    ctx: &Ctx,
311    prefix: &'a str,
312    limit: usize,
313) -> impl Future<Output = Result<crate::cache::Cleared>> + Send + use<'a> {
314    let env = ctx.env().clone();
315    let memo = Arc::clone(ctx.memo());
316    SendFuture::new(async move {
317        let Some(kv) = store(&env)? else {
318            return Ok(crate::cache::Cleared::default());
319        };
320        let page = kv.list().prefix(prefix.to_owned()).limit(limit.clamp(1, 1000) as u64).execute().await;
321        let page = page.map_err(|err| kv_error("list", prefix, &err))?;
322        for key in &page.keys {
323            kv.delete(&key.name).await.map_err(|err| kv_error("delete", &key.name, &err))?;
324            memo.remember_kv(&key.name, None);
325        }
326        Ok(crate::cache::Cleared { deleted: page.keys.len(), more: !page.list_complete })
327    })
328}
329
330/// Fragment caching, like Rails' `<% cache post do %>`: the HTML stored under `key`, or else the template `build` returns, rendered and stored.
331///
332/// askama templates cannot wait for KV, so the handler caches the costly
333/// part of the page and passes the [`Fragment`] to the page template, which
334/// writes it with `{{ fragment }}` (no `|safe` needed). `key` comes from
335/// [`cache::key`](crate::cache::key) with the records' ids and
336/// `updated_at`, the locale if translated, and a version to bump when the
337/// template changes (Rails derives it from a template digest; Ocre keeps it
338/// explicit, so a deploy does not rewrite every fragment against the daily
339/// write quota). The KV key is `key` prefixed with
340/// [`FRAGMENT_PREFIX`](crate::cache::FRAGMENT_PREFIX). Conditional caching
341/// (`cache_if`) is an `if` around the call, rendering the template directly
342/// otherwise.
343///
344/// Failures behave like [`fetch`](crate::cache::fetch): KV errors are
345/// logged and the template is rendered; with
346/// [`STORE_VAR`](crate::cache::STORE_VAR) set to `"null"` it always renders.
347/// The HTML is stored as is (no JSON), and remembered for the rest of the
348/// request.
349///
350/// Free plan: one KV read (100,000 a day), plus one KV write on a miss
351/// (**1,000 a day**). Worth it for fragments that take milliseconds of CPU
352/// to render (long lists, Markdown), read far more often than their records
353/// change; a cheap fragment costs more quota than the CPU it saves.
354///
355/// # Errors
356///
357/// - [`Error::Internal`] (500) when the `CACHE` binding is missing, naming the
358///   fix: `ocre g cache` adds `CACHE: bindings.kv(),` to cloudflare.config.ts.
359/// - [`Error::Internal`] when the KV key is longer than 512 bytes or `ttl` is
360///   below 60 seconds.
361/// - [`Error::Internal`] when the template fails to render.
362///
363/// # Examples
364///
365/// ```rust,no_run
366/// use std::time::Duration;
367///
368/// use askama::Template;
369/// use axum::{extract::{Path, State}, response::Html};
370/// use ocre::{Ctx, Result, cache::{self, Fragment}, render};
371///
372/// struct Post { id: i64, title: String, updated_at: String }
373///
374/// #[derive(Template)]
375/// #[template(source = "<article><h2>{{ post.title }}</h2></article>", ext = "html")]
376/// struct Card<'a> { post: &'a Post }
377///
378/// #[derive(Template)]
379/// #[template(source = "<main>{{ card }}</main>", ext = "html")]
380/// struct Show { card: Fragment }
381///
382/// async fn show(State(ctx): State<Ctx>, Path(id): Path<i64>) -> Result<Html<String>> {
383///     let post = Post { id, title: "Hello".into(), updated_at: "2026-09-29 14:05:00".into() }; // from D1
384///     let key = cache::key(&[&"posts", &post.id, &post.updated_at, &"card-v1"]);
385///     let card = cache::fragment(&ctx, &key, Duration::from_secs(86_400), || Card { post: &post }).await?;
386///     render(&Show { card })
387/// }
388/// ```
389#[cfg(feature = "html")]
390pub fn fragment<T, F>(
391    ctx: &Ctx,
392    key: &str,
393    ttl: Duration,
394    build: F,
395) -> impl Future<Output = Result<Fragment>> + Send + use<T, F>
396where
397    T: askama::Template,
398    F: FnOnce() -> T,
399{
400    let env = ctx.env().clone();
401    let memo = Arc::clone(ctx.memo());
402    let key = fragment_key(key);
403    SendFuture::new(async move {
404        let key = key?;
405        let ttl = ttl_seconds(ttl)?;
406        let Some(kv) = store(&env)? else {
407            return Ok(Fragment::new(build().render()?));
408        };
409        match get_text(&memo, &kv, &key).await {
410            Ok(Some(html)) => return Ok(Fragment::new(html)),
411            Ok(None) => {}
412            Err(err) => log_failure("read", &key, &err),
413        }
414        let html = build().render()?;
415        if let Err(err) = put_text(&memo, &kv, &key, html.clone(), ttl).await {
416            log_failure("write", &key, &err);
417        }
418        Ok(Fragment::new(html))
419    })
420}
421
422/// Collection caching, like Rails' `render collection:, cached: true`: one [`Fragment`] per item, read from KV in bulk.
423///
424/// `key` gives each item's cache key (see [`cache::key`](crate::cache::key));
425/// all keys are read with KV bulk reads (up to 100 keys per read, one
426/// subrequest each), then only the missing items are rendered with `build`
427/// and stored, one KV write each. The fragments come back in the order of
428/// `items`. Everything else works as in [`fragment`](crate::cache::fragment).
429///
430/// Free plan: KV bills a bulk read as one read per key (100,000 a day) and
431/// each miss as one write (**1,000 a day**): a list of 20 posts costs 20
432/// reads per view, and up to 20 writes after the posts change.
433///
434/// # Errors
435///
436/// - [`Error::Internal`] (500) when the `CACHE` binding is missing, naming the
437///   fix: `ocre g cache` adds `CACHE: bindings.kv(),` to cloudflare.config.ts.
438/// - [`Error::Internal`] when a KV key is longer than 512 bytes or `ttl` is
439///   below 60 seconds.
440/// - [`Error::Internal`] when a template fails to render.
441///
442/// # Examples
443///
444/// ```rust,no_run
445/// use std::time::Duration;
446///
447/// use askama::Template;
448/// use axum::{extract::State, response::Html};
449/// use ocre::{Ctx, Result, cache::{self, Fragment}, render};
450///
451/// struct Post { id: i64, title: String, updated_at: String }
452///
453/// #[derive(Template)]
454/// #[template(source = "<li>{{ post.title }}</li>", ext = "html")]
455/// struct Row<'a> { post: &'a Post }
456///
457/// #[derive(Template)]
458/// #[template(source = "<ul>{% for row in rows %}{{ row }}{% endfor %}</ul>", ext = "html")]
459/// struct Index { rows: Vec<Fragment> }
460///
461/// async fn index(State(ctx): State<Ctx>) -> Result<Html<String>> {
462///     let posts: Vec<Post> = Vec::new(); // e.g. post::all(&ctx, page).await?
463///     let rows = cache::fragments(
464///         &ctx,
465///         &posts,
466///         Duration::from_secs(86_400),
467///         |post| cache::key(&[&"posts", &post.id, &post.updated_at, &"row-v1"]),
468///         |post| Row { post },
469///     )
470///     .await?;
471///     render(&Index { rows })
472/// }
473/// ```
474#[cfg(feature = "html")]
475pub fn fragments<'a, I, T, K, F>(
476    ctx: &Ctx,
477    items: &'a [I],
478    ttl: Duration,
479    key: K,
480    build: F,
481) -> impl Future<Output = Result<Vec<Fragment>>> + Send + use<'a, I, T, K, F>
482where
483    T: askama::Template,
484    K: Fn(&I) -> String,
485    F: Fn(&'a I) -> T,
486{
487    let env = ctx.env().clone();
488    let memo = Arc::clone(ctx.memo());
489    let keys: Result<Vec<String>> = items.iter().map(|item| fragment_key(&key(item))).collect();
490    SendFuture::new(async move {
491        let keys = keys?;
492        let ttl = ttl_seconds(ttl)?;
493        let Some(kv) = store(&env)? else {
494            return items.iter().map(|item| Ok(Fragment::new(build(item).render()?))).collect();
495        };
496        let mut unknown: Vec<&String> = keys.iter().filter(|key| memo.kv(key).is_none()).collect();
497        unknown.sort_unstable();
498        unknown.dedup();
499        for chunk in unknown.chunks(100) {
500            match kv.get_bulk(chunk).text().await {
501                Ok(found) => {
502                    for (key, text) in found {
503                        memo.remember_kv(&key, text);
504                    }
505                }
506                Err(err) => log_failure("read", chunk[0], &err),
507            }
508        }
509        let mut fragments = Vec::with_capacity(items.len());
510        for (item, key) in items.iter().zip(&keys) {
511            if let Some(Some(html)) = memo.kv(key) {
512                fragments.push(Fragment::new(html));
513                continue;
514            }
515            let html = build(item).render()?;
516            if let Err(err) = put_text(&memo, &kv, key, html.clone(), ttl).await {
517                log_failure("write", key, &err);
518            }
519            fragments.push(Fragment::new(html));
520        }
521        Ok(fragments)
522    })
523}