pingora_cache/storage.rs
1// Copyright 2026 Cloudflare, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Cache backend storage abstraction
16
17use super::{CacheKey, CacheMeta};
18use crate::eviction::{CacheEntryId, CacheEntryKey, CacheEntryKeyRef};
19use crate::key::CompactCacheKey;
20use crate::trace::SpanHandle;
21
22use async_trait::async_trait;
23use pingora_error::Result;
24use std::any::Any;
25use std::fmt::{Display, Formatter, Result as FmtResult};
26
27/// The reason a purge() is called
28#[derive(Debug, Clone, Copy)]
29pub enum PurgeType {
30 // For eviction because the cache storage is full
31 Eviction,
32 // For cache invalidation
33 Invalidation,
34}
35
36/// What a purge is asked to do to the entry it targets.
37///
38/// This is separate from [`PurgeType`], which says why the purge happened rather than what it
39/// does to the entry, and from [`PurgeOutcome`], which is what storage actually did.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PurgeAction {
42 /// Remove the entry, so the next read for its key is a miss.
43 Delete,
44 /// Keep the entry but mark it stale, so it revalidates against the origin instead of being
45 /// refetched, reusing the stored body when the origin answers 304.
46 Expire,
47}
48
49/// The entry a [`Storage::purge`] call should remove.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum PurgeTarget<'a> {
52 /// Remove storage's current entry for this logical cache key.
53 ///
54 /// This targets one entry. Storage that retains multiple generations for a logical key does
55 /// not need to remove inactive generations. Storage must not remove an entry with a different
56 /// logical key. When storage retains multiple generations, it may select which identity to
57 /// remove.
58 Active(&'a CompactCacheKey),
59 /// Remove this exact cache entry.
60 ///
61 /// Storage must match the complete identity, including any [`CacheEntryId`].
62 Exact(&'a CacheEntryKey),
63}
64
65impl Display for PurgeTarget<'_> {
66 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
67 match self {
68 Self::Active(key) => write!(f, "active entry for {key}"),
69 Self::Exact(entry) => write!(f, "{entry}"),
70 }
71 }
72}
73
74impl<'a> PurgeTarget<'a> {
75 /// Return the target's logical cache key.
76 pub fn key(self) -> &'a CompactCacheKey {
77 match self {
78 Self::Active(key) => key,
79 Self::Exact(entry) => entry.key(),
80 }
81 }
82
83 /// Return a borrowed identity for the removed entry.
84 ///
85 /// `id` supplies identity discovered while resolving a [`PurgeTarget::Active`] target. It is
86 /// ignored for a [`PurgeTarget::Exact`] target, which already contains the complete identity;
87 /// if supplied, it must match that identity.
88 pub fn removed_entry(self, id: Option<CacheEntryId>) -> CacheEntryKeyRef<'a> {
89 match self {
90 Self::Active(key) => CacheEntryKeyRef::from_entry_id(key, id),
91 Self::Exact(entry) => {
92 debug_assert!(
93 id.is_none() || id == entry.entry_id(),
94 "purge outcome ID {id:?} must match exact target ID {:?}",
95 entry.entry_id()
96 );
97 entry.into()
98 }
99 }
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn exact_removed_entry_accepts_absent_or_matching_id() {
109 let id = CacheEntryId::new(42);
110 let entry = CacheEntryKey::identified(CompactCacheKey::default(), id);
111 let target = PurgeTarget::Exact(&entry);
112
113 assert_eq!(target.removed_entry(None), (&entry).into());
114 assert_eq!(target.removed_entry(Some(id)), (&entry).into());
115 }
116
117 #[cfg(debug_assertions)]
118 #[test]
119 #[should_panic(expected = "must match exact target ID")]
120 fn exact_removed_entry_rejects_mismatched_id() {
121 let entry = CacheEntryKey::identified(CompactCacheKey::default(), CacheEntryId::new(42));
122 PurgeTarget::Exact(&entry).removed_entry(Some(CacheEntryId::new(43)));
123 }
124}
125
126/// Outcome of a successful [`Storage::purge`] or [`Storage::expire`] call.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum PurgeOutcome {
129 /// Storage did not find the target entry.
130 NotFound,
131 /// Storage removed an entry, with the ID selected for an active target, if any.
132 ///
133 /// Exact targets already contain the complete identity and need not repeat their ID here.
134 Purged(Option<CacheEntryId>),
135 /// Storage kept the entry and marked it stale.
136 ///
137 /// Only [`Storage::expire`] returns this. The entry is still stored, so the eviction manager
138 /// keeps tracking it.
139 Expired,
140}
141
142/// Cache storage interface
143#[async_trait]
144pub trait Storage {
145 // TODO: shouldn't have to be static
146
147 /// Lookup the storage for the given [CacheKey].
148 async fn lookup(
149 &'static self,
150 key: &CacheKey,
151 trace: &SpanHandle,
152 ) -> Result<Option<(CacheMeta, HitHandler)>>;
153
154 /// Lookup the storage for the given [CacheKey] using a streaming write tag.
155 ///
156 /// When streaming partial writes is supported, the request that initiates the write will also
157 /// pass an optional `streaming_write_tag` so that the storage may try to find the associated
158 /// [HitHandler], for the same ongoing write.
159 ///
160 /// Therefore, when the write tag is set, the storage implementation should either return a
161 /// [HitHandler] that can be matched to that tag, or none at all. Otherwise when the storage
162 /// supports concurrent streaming writes for the same key, the calling request may receive a
163 /// different body from the one it expected.
164 ///
165 /// By default this defers to the standard `Storage::lookup` implementation.
166 async fn lookup_streaming_write(
167 &'static self,
168 key: &CacheKey,
169 _streaming_write_tag: Option<&[u8]>,
170 trace: &SpanHandle,
171 ) -> Result<Option<(CacheMeta, HitHandler)>> {
172 self.lookup(key, trace).await
173 }
174
175 /// Write the given [CacheMeta] to the storage. Return [MissHandler] to write the body later.
176 async fn get_miss_handler(
177 &'static self,
178 key: &CacheKey,
179 meta: &CacheMeta,
180 trace: &SpanHandle,
181 ) -> Result<MissHandler>;
182
183 /// Delete one cached entry for the given target.
184 ///
185 /// [`PurgeTarget::Active`] asks storage to select one active entry for a logical cache key; it
186 /// does not request removal of every retained generation. [`PurgeTarget::Exact`] identifies the
187 /// exact entry to remove.
188 ///
189 /// When resolving an active target, storage must return the storage-defined ID of the entry it
190 /// actually removed in [`PurgeOutcome::Purged`]. If [`HandleHit::entry_id`] or
191 /// [`HandleMiss::entry_id`] returns `Some`, an active purge outcome must contain that
192 /// [`CacheEntryId`]. Returning `None` would leave the identified entry tracked by the eviction
193 /// manager. Exact targets already contain the complete identity.
194 async fn purge(
195 &'static self,
196 target: PurgeTarget<'_>,
197 purge_type: PurgeType,
198 trace: &SpanHandle,
199 ) -> Result<PurgeOutcome>;
200
201 /// Mark one cached entry stale so it revalidates, keeping the entry stored.
202 ///
203 /// Targets resolve the same way as [`Storage::purge`]. Once this returns
204 /// [`PurgeOutcome::Expired`], the entry that was stored when the call ran must not be served
205 /// as a fresh hit, and its body should stay usable so a revalidation can reuse it on a 304.
206 ///
207 /// Stale is not the same as unusable. Whether a reader is served the stale body while it
208 /// revalidates is up to the entry's serve stale windows, which this does not touch.
209 /// How that is recorded is up to storage: rewriting the stored freshness and applying it on
210 /// read both satisfy the contract.
211 ///
212 /// This is deliberately not [`Storage::update_meta`]. That call needs a full [`CacheKey`] and
213 /// a [`CacheMeta`] the caller already holds from a hit, and a purge has neither. It would also
214 /// pin the recording strategy to rewriting the stored meta, turning this into a read modify
215 /// write that a concurrent revalidation can clobber.
216 ///
217 /// The guarantee covers the stored entry, not a fill already in flight. A miss handler that
218 /// commits after this returns may replace the entry with a fresh one, which is the same race
219 /// [`Storage::purge`] has and is expected to be resolved by whatever serializes writes for
220 /// that key.
221 ///
222 /// The default deletes the entry instead. Storage that cannot mark an entry stale must still
223 /// keep the next read from serving it, and deleting gives that guarantee at the cost of a
224 /// full refetch. It reports [`PurgeOutcome::Purged`] so the caller knows the entry is gone
225 /// and the eviction manager stops tracking it.
226 async fn expire(
227 &'static self,
228 target: PurgeTarget<'_>,
229 trace: &SpanHandle,
230 ) -> Result<PurgeOutcome> {
231 self.purge(target, PurgeType::Invalidation, trace).await
232 }
233
234 /// Update cache header and metadata for the already stored asset.
235 async fn update_meta(
236 &'static self,
237 key: &CacheKey,
238 meta: &CacheMeta,
239 trace: &SpanHandle,
240 ) -> Result<bool>;
241
242 /// Whether this storage backend supports reading partially written data
243 ///
244 /// This is to indicate when cache should unlock readers
245 fn support_streaming_partial_write(&self) -> bool {
246 false
247 }
248
249 /// Helper function to cast the trait object to concrete types
250 fn as_any(&self) -> &(dyn Any + Send + Sync + 'static);
251}
252
253/// Cache hit handling trait
254#[async_trait]
255pub trait HandleHit {
256 /// Read cached body
257 ///
258 /// Return `None` when no more body to read.
259 async fn read_body(&mut self) -> Result<Option<bytes::Bytes>>;
260
261 /// Finish the current cache hit
262 async fn finish(
263 self: Box<Self>, // because self is always used as a trait object
264 storage: &'static (dyn Storage + Sync),
265 key: &CacheKey,
266 trace: &SpanHandle,
267 ) -> Result<()>;
268
269 /// Whether this storage allows seeking to a certain range of body for single ranges.
270 fn can_seek(&self) -> bool {
271 false
272 }
273
274 /// Whether this storage allows seeking to a certain range of body for multipart ranges.
275 ///
276 /// By default uses the `can_seek` implementation.
277 fn can_seek_multipart(&self) -> bool {
278 self.can_seek()
279 }
280
281 /// Try to seek to a certain range of the body for single ranges.
282 ///
283 /// `end: None` means to read to the end of the body.
284 fn seek(&mut self, _start: usize, _end: Option<usize>) -> Result<()> {
285 // to prevent impl can_seek() without impl seek
286 todo!("seek() needs to be implemented")
287 }
288
289 /// Try to seek to a certain range of the body for multipart ranges.
290 ///
291 /// Works in an identical manner to `seek()`.
292 ///
293 /// `end: None` means to read to the end of the body.
294 ///
295 /// By default uses the `seek` implementation, but hit handlers may customize the
296 /// implementation specifically to anticipate multipart requests.
297 fn seek_multipart(&mut self, start: usize, end: Option<usize>) -> Result<()> {
298 // to prevent impl can_seek() without impl seek
299 self.seek(start, end)
300 }
301
302 // TODO: fn is_stream_hit()
303
304 /// Should we count this hit handler instance as an access in the eviction manager.
305 ///
306 /// Defaults to returning true to track all cache hits as accesses. Customize this if certain
307 /// hits should not affect the eviction system's view of the asset.
308 fn should_count_access(&self) -> bool {
309 true
310 }
311
312 /// Returns the weight of the current cache hit asset to report to the eviction manager.
313 ///
314 /// This allows the eviction system to initialize a weight for the asset, in case it is not
315 /// already tracking it (e.g. storage is out of sync with the eviction manager).
316 ///
317 /// Defaults to 0.
318 fn get_eviction_weight(&self) -> usize {
319 0
320 }
321
322 /// Return the identity of this entry to the eviction manager.
323 ///
324 /// Storage that identifies a stored generation beyond its
325 /// [`crate::key::CompactCacheKey`] can return that identity here so access accounting targets
326 /// the exact entry. Storage that keys eviction only on the cache key should leave this at the
327 /// default `None`.
328 ///
329 /// Storage that identifies entries must implement both this method and
330 /// [`HandleMiss::entry_id`] using the same identity scheme.
331 fn entry_id(&self) -> Option<CacheEntryId> {
332 None
333 }
334
335 /// Helper function to cast the trait object to concrete types
336 fn as_any(&self) -> &(dyn Any + Send + Sync);
337
338 /// Helper function to cast the trait object to concrete types
339 fn as_any_mut(&mut self) -> &mut (dyn Any + Send + Sync);
340}
341
342/// Hit Handler
343pub type HitHandler = Box<dyn HandleHit + Sync + Send>;
344
345/// MissFinishType
346pub enum MissFinishType {
347 /// A new asset was created with the given size.
348 Created(usize),
349 /// Appended size to existing asset, with an optional max size param.
350 Appended(usize, Option<usize>),
351}
352
353/// Cache miss handling trait
354#[async_trait]
355pub trait HandleMiss {
356 /// Write the given body to the storage
357 async fn write_body(&mut self, data: bytes::Bytes, eof: bool) -> Result<()>;
358
359 /// Finish the cache admission
360 ///
361 /// When `self` is dropped without calling this function, the storage should consider this write
362 /// failed.
363 async fn finish(
364 self: Box<Self>, // because self is always used as a trait object
365 ) -> Result<MissFinishType>;
366
367 /// Return a streaming write tag recognized by the underlying [`Storage`].
368 ///
369 /// This is an arbitrary data identifier that is used to associate this miss handler's current
370 /// write with a hit handler for the same write. This identifier will be compared by the
371 /// storage during `lookup_streaming_write`.
372 // This write tag is essentially an borrowed data blob of bytes retrieved from the miss handler
373 // and passed to storage, which means it can support strings or small data types, e.g. bytes
374 // represented by a u64.
375 // The downside with the current API is that such a data blob must be owned by the miss handler
376 // and stored in a way that permits retrieval as a byte slice (not computed on the fly).
377 // But most use cases likely only require a simple integer and may not like the overhead of a
378 // Vec/String allocation or even a Cow, though such data types can also be used here.
379 fn streaming_write_tag(&self) -> Option<&[u8]> {
380 None
381 }
382
383 /// Return the identity of the entry produced by this write to the eviction manager.
384 ///
385 /// Storage that identifies a stored generation beyond its
386 /// [`crate::key::CompactCacheKey`] should return that identity here so admission and weight
387 /// updates target the entry that storage will produce. Storage that keys eviction only on the
388 /// cache key should leave this at the default `None`.
389 ///
390 /// The value must identify the committed entry and remain valid after [`Self::finish`]. It
391 /// must not identify only temporary write state. Storage that identifies entries must implement
392 /// both this method and [`HandleHit::entry_id`] using the same identity scheme.
393 fn entry_id(&self) -> Option<CacheEntryId> {
394 None
395 }
396}
397
398/// Miss Handler
399pub type MissHandler = Box<dyn HandleMiss + Sync + Send>;
400
401pub mod streaming_write {
402 /// Portable u64 (sized) write id convenience type for use with streaming writes.
403 ///
404 /// Often an integer value is sufficient for a streaming write tag. This convenience type enables
405 /// storing such a value and functions for consistent conversion between byte sequence data types.
406 #[derive(Debug, Clone, Copy)]
407 pub struct U64WriteId([u8; 8]);
408
409 impl U64WriteId {
410 pub fn as_bytes(&self) -> &[u8] {
411 &self.0[..]
412 }
413 }
414
415 impl From<u64> for U64WriteId {
416 fn from(value: u64) -> U64WriteId {
417 U64WriteId(value.to_be_bytes())
418 }
419 }
420 impl From<U64WriteId> for u64 {
421 fn from(value: U64WriteId) -> u64 {
422 u64::from_be_bytes(value.0)
423 }
424 }
425 impl TryFrom<&[u8]> for U64WriteId {
426 type Error = std::array::TryFromSliceError;
427
428 fn try_from(value: &[u8]) -> std::result::Result<Self, Self::Error> {
429 Ok(U64WriteId(value.try_into()?))
430 }
431 }
432
433 /// Portable u32 (sized) write id convenience type for use with streaming writes.
434 ///
435 /// Often an integer value is sufficient for a streaming write tag. This convenience type enables
436 /// storing such a value and functions for consistent conversion between byte sequence data types.
437 #[derive(Debug, Clone, Copy)]
438 pub struct U32WriteId([u8; 4]);
439
440 impl U32WriteId {
441 pub fn as_bytes(&self) -> &[u8] {
442 &self.0[..]
443 }
444 }
445
446 impl From<u32> for U32WriteId {
447 fn from(value: u32) -> U32WriteId {
448 U32WriteId(value.to_be_bytes())
449 }
450 }
451 impl From<U32WriteId> for u32 {
452 fn from(value: U32WriteId) -> u32 {
453 u32::from_be_bytes(value.0)
454 }
455 }
456 impl TryFrom<&[u8]> for U32WriteId {
457 type Error = std::array::TryFromSliceError;
458
459 fn try_from(value: &[u8]) -> std::result::Result<Self, Self::Error> {
460 Ok(U32WriteId(value.try_into()?))
461 }
462 }
463}