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}