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}