Skip to main content

amalgam/
entry.rs

1//! The internal cache entry envelope: value plus the metadata that drives
2//! freshness, fail-safe and eager-refresh decisions.
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use crate::options::EntryOptions;
8use crate::tags::Tag;
9use crate::time::Timestamp;
10
11/// Whether an entry is still within its logical freshness window.
12///
13/// An entry physically present in the cache is always one of these two states:
14/// beyond the *physical* expiration the backend has already evicted it, so a
15/// third "gone" state is unrepresentable here.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Freshness {
18    /// `now` is before the logical expiration — safe to return directly.
19    Fresh,
20    /// `now` is at or after the logical expiration — only usable as a fail-safe
21    /// fallback.
22    Stale,
23}
24
25impl Freshness {
26    /// `true` if the entry is fresh.
27    #[must_use]
28    pub fn is_fresh(self) -> bool {
29        matches!(self, Freshness::Fresh)
30    }
31}
32
33/// Metadata stored alongside a cached value.
34#[derive(Debug, Clone)]
35pub struct Metadata {
36    /// When the underlying value was produced (used for tag-marker comparison).
37    created: Timestamp,
38    /// The freshness boundary.
39    logical_expiration: Timestamp,
40    /// The physical boundary (the value is gone after this).
41    physical_expiration: Timestamp,
42    /// The TTL handed to the backend at insert time (`physical_expiration` minus
43    /// the insertion instant). Stored so the backend's expiry policy can read it
44    /// back without consulting the wall clock.
45    backend_ttl: Duration,
46    /// `true` if this entry's value was itself produced by a fail-safe
47    /// activation (it must not be jittered or eagerly refreshed again).
48    is_from_fail_safe: bool,
49    /// When to start a proactive background refresh, if eager refresh is enabled.
50    eager_refresh_at: Option<Timestamp>,
51    /// HTTP-style entity tag, for conditional refresh.
52    etag: Option<String>,
53    /// HTTP-style last-modified time, for conditional refresh.
54    last_modified: Option<Timestamp>,
55    /// The tags attached to this entry.
56    tags: Box<[Tag]>,
57}
58
59impl Metadata {
60    /// The creation timestamp.
61    #[must_use]
62    pub fn created(&self) -> Timestamp {
63        self.created
64    }
65
66    /// The logical-expiration (freshness) boundary.
67    #[must_use]
68    pub fn logical_expiration(&self) -> Timestamp {
69        self.logical_expiration
70    }
71
72    /// The physical-expiration (fail-safe) boundary.
73    #[must_use]
74    pub fn physical_expiration(&self) -> Timestamp {
75        self.physical_expiration
76    }
77
78    /// The tags attached to this entry.
79    #[must_use]
80    pub fn tags(&self) -> &[Tag] {
81        &self.tags
82    }
83
84    /// The entity tag, if any.
85    #[must_use]
86    pub fn etag(&self) -> Option<&str> {
87        self.etag.as_deref()
88    }
89
90    /// The last-modified timestamp, if any.
91    #[must_use]
92    pub fn last_modified(&self) -> Option<Timestamp> {
93        self.last_modified
94    }
95
96    /// `true` if this value came from a fail-safe activation.
97    #[must_use]
98    pub fn is_from_fail_safe(&self) -> bool {
99        self.is_from_fail_safe
100    }
101}
102
103/// A cheaply-cloneable handle to a cached value and its metadata.
104///
105/// Entries are immutable; "mutating" an entry (throttling a stale value,
106/// refreshing it) produces a new `Entry` that replaces the old one. This avoids
107/// interior mutability and makes every stored value safe to share across tasks.
108#[derive(Debug)]
109pub struct Entry<V> {
110    inner: Arc<EntryInner<V>>,
111}
112
113#[derive(Debug)]
114struct EntryInner<V> {
115    value: V,
116    meta: Metadata,
117}
118
119impl<V> Clone for Entry<V> {
120    fn clone(&self) -> Self {
121        Self {
122            inner: Arc::clone(&self.inner),
123        }
124    }
125}
126
127impl<V> Entry<V> {
128    /// Borrows the cached value.
129    #[must_use]
130    pub fn value(&self) -> &V {
131        &self.inner.value
132    }
133
134    /// The entry's metadata.
135    #[must_use]
136    pub fn meta(&self) -> &Metadata {
137        &self.inner.meta
138    }
139
140    /// The TTL to hand the backend's expiry policy.
141    #[must_use]
142    pub fn backend_ttl(&self) -> Duration {
143        self.inner.meta.backend_ttl
144    }
145
146    /// Computes freshness relative to `now`.
147    #[must_use]
148    pub fn freshness(&self, now: Timestamp) -> Freshness {
149        if now.is_before(self.inner.meta.logical_expiration) {
150            Freshness::Fresh
151        } else {
152            Freshness::Stale
153        }
154    }
155
156    /// `true` if the entry is logically expired at `now`.
157    #[must_use]
158    pub fn is_logically_expired(&self, now: Timestamp) -> bool {
159        !now.is_before(self.inner.meta.logical_expiration)
160    }
161
162    /// `true` if `now` is at/after the physical boundary (the entry is dead and
163    /// no longer usable even as a fail-safe fallback).
164    #[must_use]
165    pub fn is_physically_expired(&self, now: Timestamp) -> bool {
166        !now.is_before(self.inner.meta.physical_expiration)
167    }
168
169    /// `true` if a proactive background refresh should start now.
170    #[must_use]
171    pub fn should_eager_refresh(&self, now: Timestamp) -> bool {
172        if self.inner.meta.is_from_fail_safe {
173            return false;
174        }
175        match self.inner.meta.eager_refresh_at {
176            Some(at) => !now.is_before(at) && now.is_before(self.inner.meta.logical_expiration),
177            None => false,
178        }
179    }
180}
181
182impl<V: Clone> Entry<V> {
183    /// Clones out the cached value.
184    #[must_use]
185    pub fn value_cloned(&self) -> V {
186        self.inner.value.clone()
187    }
188}
189
190impl<V> Entry<V> {
191    /// Builds a fresh entry from a freshly-produced value.
192    #[must_use]
193    pub fn fresh(
194        value: V,
195        options: &EntryOptions,
196        created: Timestamp,
197        tags: Box<[Tag]>,
198        etag: Option<String>,
199        last_modified: Option<Timestamp>,
200    ) -> Self {
201        let physical_ttl = options.physical_ttl();
202        let physical_expiration = created.saturating_add(physical_ttl);
203        let meta = Metadata {
204            created,
205            logical_expiration: options.logical_expiration(created),
206            physical_expiration,
207            backend_ttl: physical_ttl,
208            is_from_fail_safe: false,
209            eager_refresh_at: options.eager_refresh_at(created),
210            etag,
211            last_modified,
212            tags,
213        };
214        Self {
215            inner: Arc::new(EntryInner { value, meta }),
216        }
217    }
218
219    /// Builds a throttled fail-safe entry that re-serves an existing value for
220    /// the throttle window, keeping the original physical boundary.
221    ///
222    /// Returns `None` when the source value is already physically expired and so
223    /// cannot be reused.
224    #[must_use]
225    pub fn throttled(source: &Entry<V>, options: &EntryOptions, now: Timestamp) -> Option<Self>
226    where
227        V: Clone,
228    {
229        if source.is_physically_expired(now) {
230            return None;
231        }
232        let physical_expiration = source.inner.meta.physical_expiration;
233        let backend_ttl = physical_expiration.saturating_duration_since(now);
234        let logical_expiration = now.saturating_add(options.fail_safe_throttle_duration());
235        let meta = Metadata {
236            created: source.inner.meta.created,
237            logical_expiration,
238            physical_expiration,
239            backend_ttl,
240            is_from_fail_safe: true,
241            eager_refresh_at: None,
242            etag: source.inner.meta.etag.clone(),
243            last_modified: source.inner.meta.last_modified,
244            tags: source.inner.meta.tags.clone(),
245        };
246        Some(Self {
247            inner: Arc::new(EntryInner {
248                value: source.inner.value.clone(),
249                meta,
250            }),
251        })
252    }
253
254    /// Produces a copy of this entry that is logically expired as of `at`, while
255    /// keeping the original physical boundary so fail-safe can still serve it.
256    /// Used by [`Cache::expire`](crate::Cache::expire).
257    #[must_use]
258    pub fn with_logical_expiration(&self, at: Timestamp) -> Self
259    where
260        V: Clone,
261    {
262        let mut meta = self.inner.meta.clone();
263        meta.logical_expiration = at;
264        meta.eager_refresh_at = None;
265        meta.backend_ttl = meta.physical_expiration.saturating_duration_since(at);
266        Self {
267            inner: Arc::new(EntryInner {
268                value: self.inner.value.clone(),
269                meta,
270            }),
271        }
272    }
273
274    /// Rebuilds an in-memory entry from data read out of the L2 distributed
275    /// cache, recomputing the backend TTL from the (absolute) physical boundary.
276    #[allow(clippy::too_many_arguments)]
277    #[must_use]
278    pub fn rehydrate(
279        value: V,
280        created: Timestamp,
281        logical_expiration: Timestamp,
282        physical_expiration: Timestamp,
283        is_from_fail_safe: bool,
284        etag: Option<String>,
285        last_modified: Option<Timestamp>,
286        tags: Box<[Tag]>,
287        now: Timestamp,
288    ) -> Self {
289        let meta = Metadata {
290            created,
291            logical_expiration,
292            physical_expiration,
293            backend_ttl: physical_expiration.saturating_duration_since(now),
294            is_from_fail_safe,
295            eager_refresh_at: None,
296            etag,
297            last_modified,
298            tags,
299        };
300        Self {
301            inner: Arc::new(EntryInner { value, meta }),
302        }
303    }
304
305    /// Builds a fail-safe entry from a default value (the `fail_safe_default`),
306    /// throttled like [`throttled`](Self::throttled).
307    #[must_use]
308    pub fn from_fail_safe_default(value: V, options: &EntryOptions, now: Timestamp) -> Self {
309        let throttle = options.fail_safe_throttle_duration();
310        let physical_expiration = now.saturating_add(options.physical_ttl());
311        let meta = Metadata {
312            created: now,
313            logical_expiration: now.saturating_add(throttle),
314            physical_expiration,
315            backend_ttl: options.physical_ttl(),
316            is_from_fail_safe: true,
317            eager_refresh_at: None,
318            etag: None,
319            last_modified: None,
320            tags: Box::from([]),
321        };
322        Self {
323            inner: Arc::new(EntryInner { value, meta }),
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::time::Timestamp;
332
333    fn opts() -> EntryOptions {
334        EntryOptions::new(Duration::from_secs(10)).with_fail_safe(
335            true,
336            Some(Duration::from_secs(100)),
337            Some(Duration::from_secs(5)),
338        )
339    }
340
341    #[test]
342    fn fresh_entry_is_fresh_then_stale() {
343        let created = Timestamp::from_ticks(0);
344        let e = Entry::fresh(7i32, &opts(), created, Box::from([]), None, None);
345        let before = created.saturating_add(Duration::from_secs(5));
346        let after = created.saturating_add(Duration::from_secs(15));
347        assert_eq!(e.freshness(before), Freshness::Fresh);
348        assert_eq!(e.freshness(after), Freshness::Stale);
349        // physical boundary = max(10, 100) = 100s
350        assert!(!e.is_physically_expired(after));
351        assert!(e.is_physically_expired(created.saturating_add(Duration::from_secs(101))));
352    }
353
354    #[test]
355    fn throttled_keeps_physical_boundary_and_resets_logical() {
356        let created = Timestamp::from_ticks(0);
357        let e = Entry::fresh(7i32, &opts(), created, Box::from([]), None, None);
358        let now = created.saturating_add(Duration::from_secs(20)); // stale, still physical
359        let t = Entry::throttled(&e, &opts(), now).expect("still physically alive");
360        assert!(t.meta().is_from_fail_safe());
361        // logical = now + throttle(5s); fresh again for 5s.
362        assert_eq!(
363            t.freshness(now.saturating_add(Duration::from_secs(2))),
364            Freshness::Fresh
365        );
366        assert_eq!(
367            t.freshness(now.saturating_add(Duration::from_secs(6))),
368            Freshness::Stale
369        );
370    }
371
372    #[test]
373    fn throttled_none_when_physically_dead() {
374        let created = Timestamp::from_ticks(0);
375        let e = Entry::fresh(7i32, &opts(), created, Box::from([]), None, None);
376        let now = created.saturating_add(Duration::from_secs(200));
377        assert!(Entry::throttled(&e, &opts(), now).is_none());
378    }
379}