Skip to main content

coil_runtime/
cache.rs

1use super::*;
2use std::sync::Arc;
3
4#[derive(Debug, Error, PartialEq, Eq)]
5pub enum RuntimeCacheError {
6    #[error(transparent)]
7    Cache(#[from] CacheModelError),
8}
9
10#[derive(Debug, Clone)]
11pub struct CacheHost {
12    pub customer_app: String,
13    pub namespace: CacheNamespace,
14    pub shared_backend_namespace: String,
15    pub planner: CachePlanner,
16    runtime: CacheRuntime,
17}
18
19impl CacheHost {
20    pub(crate) fn new(
21        customer_app: String,
22        namespace: CacheNamespace,
23        planner: CachePlanner,
24        shared_runtime: Option<Arc<dyn coil_cache::DistributedCacheRuntime>>,
25        shared_backend_namespace: String,
26    ) -> Self {
27        let runtime = match (
28            planner.topology().supports_shared_invalidation(),
29            shared_runtime,
30        ) {
31            (true, Some(runtime)) => CacheRuntime::with_shared_runtime(planner.topology(), runtime),
32            _ => planner.runtime(),
33        };
34        Self {
35            customer_app,
36            namespace,
37            planner,
38            runtime,
39            shared_backend_namespace,
40        }
41    }
42
43    pub fn lookup_execution(
44        &mut self,
45        execution: &RequestExecution,
46        now: CacheInstant,
47    ) -> Option<CacheLookup> {
48        execution
49            .cache_plan
50            .plan
51            .application()
52            .map(|plan| self.runtime.lookup(plan.key(), now))
53    }
54
55    pub fn begin_fill(
56        &mut self,
57        execution: &RequestExecution,
58        holder: impl Into<String>,
59    ) -> Option<FillDecision> {
60        execution.cache_plan.plan.application().map(|plan| {
61            self.runtime
62                .begin_fill(plan.key(), plan.coalescing(), holder)
63        })
64    }
65
66    pub fn complete_fill(&mut self, decision: &FillDecision) -> Result<(), RuntimeCacheError> {
67        match decision {
68            FillDecision::Start(lease) => Ok(self.runtime.complete_fill(lease)?),
69            FillDecision::Coalesced { .. } | FillDecision::Uncoalesced => Ok(()),
70        }
71    }
72
73    pub fn store_execution(
74        &mut self,
75        execution: &RequestExecution,
76        value: impl Into<String>,
77        now: CacheInstant,
78    ) -> Option<CacheKey> {
79        execution.cache_plan.plan.application().map(|plan| {
80            self.runtime.insert(plan, value, now);
81            plan.key().clone()
82        })
83    }
84
85    pub fn invalidate(&mut self, tags: &InvalidationSet) -> Vec<CacheKey> {
86        self.runtime.invalidate(tags)
87    }
88
89    pub fn metrics(&self) -> CacheMetrics {
90        self.runtime.metrics()
91    }
92}
93
94pub(crate) fn cache_disposition_for_route(
95    method: HttpMethod,
96    auth: &RouteAuthGate,
97    session: &SessionContext,
98) -> CacheDisposition {
99    if method.is_state_changing() {
100        return CacheDisposition::Uncacheable;
101    }
102
103    match auth {
104        RouteAuthGate::Public if session.session_id.is_none() => CacheDisposition::Public,
105        _ => CacheDisposition::Private,
106    }
107}
108
109pub(crate) fn build_execution_cache_plan(
110    runtime: &RuntimePlan,
111    request: &RequestInput,
112    route: &RouteDefinition,
113    resolved: &ResolvedRoute,
114    session: &SessionContext,
115    principal: &PrincipalContext,
116    disposition: CacheDisposition,
117) -> Result<ExecutedCachePlan, CacheModelError> {
118    let scope = cache_scope_for_request(request, resolved, session, principal, disposition)?;
119    let tags = cache_tags_for_request(runtime, route, resolved, request)?;
120    let validators = cache_validators_for_request(request, resolved, session, principal)?;
121    let freshness = cache_freshness_for_request(route, request.method, disposition);
122    let http_policy = HttpCachePolicy::new(scope.clone(), freshness, validators, tags.clone())?;
123    let mut cache_request = CachePlanRequest::new(
124        runtime.cache_namespace()?,
125        request.path.clone(),
126        http_policy,
127    )?;
128
129    if let Some(freshness) = freshness.filter(|_| disposition != CacheDisposition::Uncacheable) {
130        cache_request = cache_request
131            .with_application_policy(ApplicationCachePolicy::new(scope, freshness, tags)?);
132    }
133
134    let plan = runtime.cache_planner.plan(cache_request)?;
135    let headers = cache_headers_from_plan(&plan);
136
137    Ok(ExecutedCachePlan { plan, headers })
138}
139
140fn cache_scope_for_request(
141    request: &RequestInput,
142    resolved: &ResolvedRoute,
143    session: &SessionContext,
144    principal: &PrincipalContext,
145    disposition: CacheDisposition,
146) -> Result<CacheScope, CacheModelError> {
147    let mut scope = match disposition {
148        CacheDisposition::Public => CacheScope::public(),
149        CacheDisposition::Private => CacheScope::private(),
150        CacheDisposition::Uncacheable => CacheScope::no_store(),
151    }
152    .with_site(
153        resolved
154            .site_id
155            .clone()
156            .unwrap_or_else(|| request.host.clone()),
157    )?;
158
159    if let Some(locale) = resolved.locale.as_deref() {
160        scope = scope.with_locale(locale.to_string())?;
161    }
162
163    if disposition == CacheDisposition::Private {
164        if let Some(principal_id) = principal.principal_id.as_deref() {
165            scope = scope.with_user(principal_id.to_string())?;
166        } else if let Some(session_id) = session.session_id.as_deref() {
167            scope = scope.with_session(session_id.to_string())?;
168        }
169    }
170
171    Ok(scope)
172}
173
174fn cache_tags_for_request(
175    runtime: &RuntimePlan,
176    route: &RouteDefinition,
177    resolved: &ResolvedRoute,
178    request: &RequestInput,
179) -> Result<InvalidationSet, CacheModelError> {
180    let mut tags = InvalidationSet::new();
181    tags.insert(InvalidationTag::new(format!(
182        "customer_app:{}",
183        runtime.config.app.name
184    ))?);
185    if let Some(site_id) = resolved.site_id.as_deref() {
186        tags.insert(InvalidationTag::new(format!("site:{site_id}"))?);
187    }
188    tags.insert(InvalidationTag::new(format!(
189        "route:{}",
190        resolved.route_name
191    ))?);
192    tags.insert(InvalidationTag::new(format!("path:{}", request.path))?);
193
194    if let Some(module) = route.module.as_deref() {
195        tags.insert(InvalidationTag::new(format!("module:{module}"))?);
196    }
197
198    if let Some(locale) = resolved.locale.as_deref() {
199        tags.insert(InvalidationTag::new(format!("locale:{locale}"))?);
200    }
201
202    Ok(tags)
203}
204
205fn cache_validators_for_request(
206    request: &RequestInput,
207    resolved: &ResolvedRoute,
208    session: &SessionContext,
209    principal: &PrincipalContext,
210) -> Result<ResponseValidators, CacheModelError> {
211    let mut parts = vec![
212        "etag".to_string(),
213        resolved.route_name.clone(),
214        resolved
215            .site_id
216            .clone()
217            .unwrap_or_else(|| request.host.clone()),
218        request.path.clone(),
219    ];
220
221    if let Some(locale) = resolved.locale.as_deref() {
222        parts.push(format!("locale:{locale}"));
223    }
224    if let Some(principal_id) = principal.principal_id.as_deref() {
225        parts.push(format!("user:{principal_id}"));
226    } else if let Some(session_id) = session.session_id.as_deref() {
227        parts.push(format!("session:{session_id}"));
228    }
229
230    Ok(ResponseValidators {
231        etag: Some(EntityTag::new(parts.join(":"))?),
232        last_modified_unix_seconds: None,
233    })
234}
235
236fn cache_freshness_for_request(
237    route: &RouteDefinition,
238    method: HttpMethod,
239    disposition: CacheDisposition,
240) -> Option<FreshnessPolicy> {
241    if method.is_state_changing() || disposition == CacheDisposition::Uncacheable {
242        return None;
243    }
244
245    match disposition {
246        CacheDisposition::Public => Some(
247            FreshnessPolicy::new(Duration::from_secs(300), Some(Duration::from_secs(30)))
248                .expect("constant public freshness is valid"),
249        ),
250        CacheDisposition::Private if route.area == RouteArea::Account => Some(
251            FreshnessPolicy::new(Duration::from_secs(60), Some(Duration::from_secs(30)))
252                .expect("constant account freshness is valid"),
253        ),
254        CacheDisposition::Private => Some(
255            FreshnessPolicy::new(Duration::from_secs(30), Some(Duration::from_secs(15)))
256                .expect("constant private freshness is valid"),
257        ),
258        CacheDisposition::Uncacheable => None,
259    }
260}
261
262fn cache_headers_from_plan(plan: &CachePlan) -> BTreeMap<String, String> {
263    let mut headers = BTreeMap::new();
264    headers.insert(
265        "Cache-Control".to_string(),
266        plan.http().cache_control().to_string(),
267    );
268
269    if let Some(variation) = plan.http().variation() {
270        headers.insert(
271            "X-Coil-Variation-Key".to_string(),
272            variation.as_str().to_string(),
273        );
274    }
275
276    if let Some(etag) = plan.http().validators().etag.as_ref() {
277        headers.insert("ETag".to_string(), etag.as_str().to_string());
278    }
279
280    if let Some(surrogate_tags) = plan.http().surrogate_tags().header_value() {
281        headers.insert("Surrogate-Key".to_string(), surrogate_tags);
282    }
283
284    headers
285}