Skip to main content

ocre/
replicas.rs

1//! D1 read replicas: reads go to a nearby copy of the database, writes to
2//! the primary, and each visitor still reads what they wrote (Rails'
3//! automatic role switching, without a second database to configure).
4//!
5//! Turn it on in two places:
6//!
7//! 1. enable read replication on the database (Cloudflare dashboard, D1,
8//!    Settings), free on every plan;
9//! 2. set the Worker variable `D1_REPLICAS` to `on`
10//!    (`D1_REPLICAS: bindings.text("on")` in cloudflare.config.ts).
11//!
12//! Each request handled by [`serve`](crate::serve) then queries its
13//! databases through one D1 session: a `GET` or `HEAD` may start on any
14//! replica, other methods start on the primary, and later statements of the
15//! request see the earlier ones. After a request that wrote, the response
16//! sets a short-lived cookie holding the session's bookmark
17//! (`ocre_d1_<binding>`, 5 minutes); the visitor's next requests resume
18//! from it, so a page shown after a form never misses the new row.
19//! Responses of read-only requests set no cookie and stay cacheable.
20//!
21//! Jobs, cron runs and email handlers query the primary. Without the
22//! variable, or with replication off on the database, queries behave as
23//! before: sessions reach the primary.
24//!
25//! # Free plan
26//!
27//! No extra cost: replication and sessions are free, and a replica answers
28//! reads that would otherwise cross the world to the primary.
29//!
30//! # Examples
31//!
32//! ```
33//! assert_eq!(ocre::replicas::REPLICAS_VAR, "D1_REPLICAS");
34//! ```
35
36use axum::http::{HeaderMap, HeaderValue, Method, header};
37use cookie::Cookie;
38
39/// Worker variable turning replicas on when set to `on`: `D1_REPLICAS`.
40///
41/// # Examples
42///
43/// ```
44/// assert_eq!(ocre::replicas::REPLICAS_VAR, "D1_REPLICAS");
45/// ```
46pub const REPLICAS_VAR: &str = "D1_REPLICAS";
47
48/// Seconds the bookmark cookie lives: long enough for the replicas to catch up.
49pub(crate) const BOOKMARK_MAX_AGE: i64 = 300;
50
51/// Whether the value of [`REPLICAS_VAR`] turns replicas on.
52pub(crate) fn enabled(value: Option<&str>) -> bool {
53    value.is_some_and(|value| value.trim().eq_ignore_ascii_case("on"))
54}
55
56/// The cookie holding the bookmark of the D1 binding `binding`: `ocre_d1_db` for `DB`.
57pub(crate) fn cookie_name(binding: &str) -> String {
58    format!("ocre_d1_{}", binding.to_ascii_lowercase())
59}
60
61/// Where a request's session starts: the visitor's bookmark, else any
62/// replica for reads (`GET`, `HEAD`) and the primary for the rest.
63pub(crate) fn session_start(headers: &HeaderMap, method: &Method, binding: &str) -> String {
64    let name = cookie_name(binding);
65    let bookmark = headers
66        .get_all(header::COOKIE)
67        .iter()
68        .filter_map(|value| value.to_str().ok())
69        .flat_map(Cookie::split_parse_encoded)
70        .filter_map(Result::ok)
71        .find(|cookie| cookie.name() == name)
72        .map(|cookie| cookie.value().to_owned())
73        .filter(|bookmark| !bookmark.is_empty());
74    bookmark.unwrap_or_else(|| {
75        let read = *method == Method::GET || *method == Method::HEAD;
76        (if read { "first-unconstrained" } else { "first-primary" }).to_owned()
77    })
78}
79
80/// `Set-Cookie` value keeping `bookmark` for the visitor's next requests.
81pub(crate) fn bookmark_cookie(binding: &str, bookmark: &str) -> Option<HeaderValue> {
82    let cookie = Cookie::build((cookie_name(binding), bookmark.to_owned()))
83        .path("/")
84        .http_only(true)
85        .secure(true)
86        .same_site(cookie::SameSite::Lax)
87        .max_age(cookie::time::Duration::seconds(BOOKMARK_MAX_AGE));
88    HeaderValue::from_str(&cookie.build().encoded().to_string()).ok()
89}
90
91#[cfg(test)]
92#[path = "../tests/replicas.rs"]
93mod tests;