Skip to main content

amalgam/
options.rs

1//! Per-entry options ([`EntryOptions`]) and their supporting value types.
2//!
3//! This is the Rust counterpart of FusionCache's `FusionCacheEntryOptions`.
4//! Every field carries the same default as FusionCache (see `docs/PARITY.md`),
5//! with two deliberate, type-driven improvements:
6//!
7//! * timeouts are a [`Timeout`] enum rather than a `-1ms` negative sentinel;
8//! * the eager-refresh threshold is an [`EagerThreshold`] newtype that can only
9//!   hold a value in the open interval `(0, 1)`.
10
11use std::time::Duration;
12
13use crate::time::{Timeout, Timestamp, duration_to_ticks};
14
15/// Memory-eviction priority hint, mirroring `CacheItemPriority`.
16///
17/// The in-memory backend (moka) uses a TinyLFU policy and does not honour an
18/// explicit priority; this is retained for API parity and forwarded where a
19/// backend can use it. `NeverRemove` entries (e.g. internal tag markers) are
20/// kept in a dedicated never-evicting structure instead.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum Priority {
23    /// Evicted first under memory pressure.
24    Low,
25    /// The default priority.
26    #[default]
27    Normal,
28    /// Evicted last under memory pressure.
29    High,
30    /// Never evicted by the size policy.
31    NeverRemove,
32}
33
34/// What [`Cache::remove_by_tag`](crate::Cache::remove_by_tag) does to matched
35/// entries.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum RemoveByTagBehavior {
38    /// Logically expire matched entries (fail-safe can still serve them).
39    /// This is the FusionCache default.
40    #[default]
41    Expire,
42    /// Hard-remove matched entries.
43    Remove,
44}
45
46/// How the L2 wire-format version is combined with a cache key, mirroring
47/// FusionCache's `CacheKeyModifierMode`.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum KeyModifierMode {
50    /// Prepend the version: `v1:key` (the default — different cache versions can
51    /// share one L2 without colliding).
52    #[default]
53    Prefix,
54    /// Append the version: `key:v1`.
55    Suffix,
56    /// Leave the key unmodified.
57    None,
58}
59
60/// A validated eager-refresh threshold: a fraction strictly between 0 and 1.
61///
62/// A threshold of `0.8` means "once 80% of the entry's duration has elapsed,
63/// kick off a non-blocking background refresh". Values outside the open
64/// interval `(0, 1)` are rejected (returning `None`) rather than silently
65/// clamped, matching FusionCache's coercion of out-of-range values to "disabled".
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct EagerThreshold(f32);
68
69impl EagerThreshold {
70    /// Creates a threshold, or `None` if `fraction` is not in `(0, 1)`.
71    #[must_use]
72    pub fn new(fraction: f32) -> Option<Self> {
73        if fraction > 0.0 && fraction < 1.0 {
74            Some(Self(fraction))
75        } else {
76            None
77        }
78    }
79
80    /// The underlying fraction, guaranteed to be in `(0, 1)`.
81    #[must_use]
82    pub fn fraction(self) -> f32 {
83        self.0
84    }
85}
86
87/// Options controlling how a single entry is cached.
88///
89/// Build a baseline once (often via [`Cache::entry_options`](crate::Cache::entry_options),
90/// which clones the cache's defaults) and tweak per call with the chainable
91/// `with_*` methods.
92#[derive(Debug, Clone)]
93pub struct EntryOptions {
94    // ---- expiration ----
95    duration: Duration,
96    memory_duration: Option<Duration>,
97    distributed_duration: Option<Duration>,
98    eager_refresh_threshold: Option<EagerThreshold>,
99    jitter_max: Duration,
100
101    // ---- locking ----
102    lock_timeout: Timeout,
103    // Specific overrides; each falls back to `lock_timeout` when `None`
104    // (FusionCache `MemoryLockTimeout` / `DistributedLockTimeout`).
105    memory_lock_timeout: Option<Timeout>,
106    distributed_lock_timeout: Option<Timeout>,
107
108    // ---- fail-safe ----
109    is_fail_safe_enabled: bool,
110    fail_safe_max_duration: Duration,
111    fail_safe_throttle_duration: Duration,
112    distributed_fail_safe_max_duration: Option<Duration>,
113
114    // ---- factory timeouts ----
115    factory_soft_timeout: Timeout,
116    factory_hard_timeout: Timeout,
117    allow_timed_out_factory_background_completion: bool,
118
119    // ---- memory (L1) ----
120    skip_memory_read: bool,
121    skip_memory_write: bool,
122    priority: Priority,
123    size: Option<i64>,
124    allow_stale_on_read_only: bool,
125
126    // ---- distributed (L2) ----
127    distributed_soft_timeout: Timeout,
128    distributed_hard_timeout: Timeout,
129    allow_background_distributed_operations: bool,
130    rethrow_distributed_exceptions: bool,
131    rethrow_serialization_exceptions: bool,
132    skip_distributed_read: bool,
133    skip_distributed_write: bool,
134    skip_distributed_read_when_stale: bool,
135    skip_distributed_locker: bool,
136    enable_auto_clone: bool,
137
138    // ---- backplane ----
139    skip_backplane_notifications: bool,
140    allow_background_backplane_operations: bool,
141    rethrow_backplane_exceptions: bool,
142}
143
144impl Default for EntryOptions {
145    fn default() -> Self {
146        Self {
147            duration: Duration::from_secs(30),
148            memory_duration: None,
149            distributed_duration: None,
150            eager_refresh_threshold: None,
151            jitter_max: Duration::ZERO,
152
153            lock_timeout: Timeout::Infinite,
154            memory_lock_timeout: None,
155            distributed_lock_timeout: None,
156
157            is_fail_safe_enabled: false,
158            fail_safe_max_duration: Duration::from_secs(60 * 60 * 24), // 1 day
159            fail_safe_throttle_duration: Duration::from_secs(30),
160            distributed_fail_safe_max_duration: None,
161
162            factory_soft_timeout: Timeout::Infinite,
163            factory_hard_timeout: Timeout::Infinite,
164            allow_timed_out_factory_background_completion: true,
165
166            skip_memory_read: false,
167            skip_memory_write: false,
168            priority: Priority::Normal,
169            size: None,
170            allow_stale_on_read_only: false,
171
172            distributed_soft_timeout: Timeout::Infinite,
173            distributed_hard_timeout: Timeout::Infinite,
174            allow_background_distributed_operations: false,
175            rethrow_distributed_exceptions: false,
176            rethrow_serialization_exceptions: true,
177            skip_distributed_read: false,
178            skip_distributed_write: false,
179            skip_distributed_read_when_stale: false,
180            skip_distributed_locker: false,
181            enable_auto_clone: false,
182
183            skip_backplane_notifications: false,
184            allow_background_backplane_operations: true,
185            rethrow_backplane_exceptions: false,
186        }
187    }
188}
189
190impl EntryOptions {
191    /// Creates options with the given logical duration and all other fields at
192    /// their defaults.
193    #[must_use]
194    pub fn new(duration: Duration) -> Self {
195        Self {
196            duration,
197            ..Self::default()
198        }
199    }
200
201    // ----------------------------------------------------------------- builders
202
203    /// Sets the logical duration (the freshness window).
204    #[must_use]
205    pub fn with_duration(mut self, duration: Duration) -> Self {
206        self.duration = duration;
207        self
208    }
209
210    /// Overrides the L1-only logical duration (defaults to [`duration`](Self::duration)).
211    #[must_use]
212    pub fn with_memory_duration(mut self, duration: Duration) -> Self {
213        self.memory_duration = Some(duration);
214        self
215    }
216
217    /// Overrides the L2-only logical duration (defaults to [`duration`](Self::duration)).
218    #[must_use]
219    pub fn with_distributed_duration(mut self, duration: Duration) -> Self {
220        self.distributed_duration = Some(duration);
221        self
222    }
223
224    /// Enables eager (proactive, background) refresh at the given threshold.
225    /// A `None` threshold disables eager refresh.
226    #[must_use]
227    pub fn with_eager_refresh(mut self, threshold: Option<EagerThreshold>) -> Self {
228        self.eager_refresh_threshold = threshold;
229        self
230    }
231
232    /// Sets the maximum random jitter added to a *fresh* entry's logical
233    /// expiration (anti-stampede across nodes).
234    #[must_use]
235    pub fn with_jitter_max(mut self, jitter_max: Duration) -> Self {
236        self.jitter_max = jitter_max;
237        self
238    }
239
240    /// Sets the general maximum wait for the per-key single-flight lock — the
241    /// fallback for the memory- and distributed-specific lock timeouts.
242    #[must_use]
243    pub fn with_lock_timeout(mut self, timeout: Timeout) -> Self {
244        self.lock_timeout = timeout;
245        self
246    }
247
248    /// Overrides the wait for the in-memory single-flight lock (defaults to
249    /// [`with_lock_timeout`](Self::with_lock_timeout)). FusionCache `MemoryLockTimeout`.
250    #[must_use]
251    pub fn with_memory_lock_timeout(mut self, timeout: Timeout) -> Self {
252        self.memory_lock_timeout = Some(timeout);
253        self
254    }
255
256    /// Overrides the wait for the cross-node distributed lock (defaults to
257    /// [`with_lock_timeout`](Self::with_lock_timeout)). FusionCache `DistributedLockTimeout`.
258    #[must_use]
259    pub fn with_distributed_lock_timeout(mut self, timeout: Timeout) -> Self {
260        self.distributed_lock_timeout = Some(timeout);
261        self
262    }
263
264    /// Enables or disables fail-safe and (optionally) tunes its windows.
265    ///
266    /// Mirrors FusionCache's `SetFailSafe(isEnabled, maxDuration?, throttleDuration?)`.
267    #[must_use]
268    pub fn with_fail_safe(
269        mut self,
270        enabled: bool,
271        max_duration: Option<Duration>,
272        throttle_duration: Option<Duration>,
273    ) -> Self {
274        self.is_fail_safe_enabled = enabled;
275        if let Some(max) = max_duration {
276            self.fail_safe_max_duration = max;
277        }
278        if let Some(throttle) = throttle_duration {
279            self.fail_safe_throttle_duration = throttle;
280        }
281        self
282    }
283
284    /// Sets the factory soft/hard timeouts and whether a timed-out factory keeps
285    /// running in the background to update the cache.
286    #[must_use]
287    pub fn with_factory_timeouts(
288        mut self,
289        soft: Timeout,
290        hard: Timeout,
291        allow_background_completion: bool,
292    ) -> Self {
293        self.factory_soft_timeout = soft;
294        self.factory_hard_timeout = hard;
295        self.allow_timed_out_factory_background_completion = allow_background_completion;
296        self
297    }
298
299    /// Sets the memory-eviction priority.
300    #[must_use]
301    pub fn with_priority(mut self, priority: Priority) -> Self {
302        self.priority = priority;
303        self
304    }
305
306    /// Sets the entry's size weight (forwarded to the backend's size policy).
307    #[must_use]
308    pub fn with_size(mut self, size: i64) -> Self {
309        self.size = Some(size);
310        self
311    }
312
313    /// Allows read-only methods ([`try_get`](crate::Cache::try_get),
314    /// [`get_or_default`](crate::Cache::get_or_default)) to return a stale value.
315    #[must_use]
316    pub fn with_allow_stale_on_read_only(mut self, allow: bool) -> Self {
317        self.allow_stale_on_read_only = allow;
318        self
319    }
320
321    /// Skips reading from / writing to the L1 memory cache for this operation.
322    #[must_use]
323    pub fn with_skip_memory(mut self, skip_read: bool, skip_write: bool) -> Self {
324        self.skip_memory_read = skip_read;
325        self.skip_memory_write = skip_write;
326        self
327    }
328
329    /// Skips reading from / writing to the L2 distributed cache for this operation.
330    #[must_use]
331    pub fn with_skip_distributed(mut self, skip_read: bool, skip_write: bool) -> Self {
332        self.skip_distributed_read = skip_read;
333        self.skip_distributed_write = skip_write;
334        self
335    }
336
337    /// When the L1 entry is stale, skip reading L2 (a multi-node freshness
338    /// optimization).
339    #[must_use]
340    pub fn with_skip_distributed_read_when_stale(mut self, skip: bool) -> Self {
341        self.skip_distributed_read_when_stale = skip;
342        self
343    }
344
345    /// Overrides the L2-specific fail-safe max duration (defaults to the general
346    /// `fail_safe_max_duration`). FusionCache `DistributedCacheFailSafeMaxDuration`.
347    #[must_use]
348    pub fn with_distributed_fail_safe_max_duration(mut self, max: Duration) -> Self {
349        self.distributed_fail_safe_max_duration = Some(max);
350        self
351    }
352
353    /// Rethrows L2 transport errors to the caller instead of degrading to a miss
354    /// (FusionCache `ReThrowDistributedCacheExceptions`, default `false`).
355    #[must_use]
356    pub fn with_rethrow_distributed_exceptions(mut self, rethrow: bool) -> Self {
357        self.rethrow_distributed_exceptions = rethrow;
358        self
359    }
360
361    /// Rethrows (de)serialization errors on the L2 path to the caller (FusionCache
362    /// `ReThrowSerializationExceptions`, default `true`).
363    #[must_use]
364    pub fn with_rethrow_serialization_exceptions(mut self, rethrow: bool) -> Self {
365        self.rethrow_serialization_exceptions = rethrow;
366        self
367    }
368
369    /// Runs backplane publishes in the background (fire-and-forget) instead of
370    /// awaiting them (FusionCache `AllowBackgroundBackplaneOperations`, default
371    /// `true`).
372    #[must_use]
373    pub fn with_allow_background_backplane_operations(mut self, allow: bool) -> Self {
374        self.allow_background_backplane_operations = allow;
375        self
376    }
377
378    /// Sets the L2 soft/hard timeouts.
379    #[must_use]
380    pub fn with_distributed_timeouts(mut self, soft: Timeout, hard: Timeout) -> Self {
381        self.distributed_soft_timeout = soft;
382        self.distributed_hard_timeout = hard;
383        self
384    }
385
386    /// Performs L2 writes in the background (fire-and-forget) instead of awaiting.
387    #[must_use]
388    pub fn with_allow_background_distributed_operations(mut self, allow: bool) -> Self {
389        self.allow_background_distributed_operations = allow;
390        self
391    }
392
393    /// Skips publishing a backplane notification for this operation.
394    #[must_use]
395    pub fn with_skip_backplane_notifications(mut self, skip: bool) -> Self {
396        self.skip_backplane_notifications = skip;
397        self
398    }
399
400    // ----------------------------------------------------------------- accessors
401
402    /// The logical duration (freshness window).
403    #[must_use]
404    pub fn duration(&self) -> Duration {
405        self.duration
406    }
407
408    /// `true` if fail-safe is enabled.
409    #[must_use]
410    pub fn is_fail_safe_enabled(&self) -> bool {
411        self.is_fail_safe_enabled
412    }
413
414    /// The fail-safe throttle window: after a fail-safe activation, how long the
415    /// stale value is served before the factory is retried.
416    #[must_use]
417    pub fn fail_safe_throttle_duration(&self) -> Duration {
418        self.fail_safe_throttle_duration
419    }
420
421    /// The eager-refresh threshold, if enabled.
422    #[must_use]
423    pub fn eager_refresh_threshold(&self) -> Option<EagerThreshold> {
424        self.eager_refresh_threshold
425    }
426
427    /// The general lock timeout — the fallback for the memory- and
428    /// distributed-specific lock timeouts.
429    #[must_use]
430    pub fn lock_timeout(&self) -> Timeout {
431        self.lock_timeout
432    }
433
434    /// The effective in-memory single-flight lock timeout (`memory_lock_timeout`,
435    /// or [`lock_timeout`](Self::lock_timeout) when unset).
436    #[must_use]
437    pub fn memory_lock_timeout(&self) -> Timeout {
438        self.memory_lock_timeout.unwrap_or(self.lock_timeout)
439    }
440
441    /// The effective cross-node distributed lock timeout
442    /// (`distributed_lock_timeout`, or [`lock_timeout`](Self::lock_timeout) when unset).
443    #[must_use]
444    pub fn distributed_lock_timeout(&self) -> Timeout {
445        self.distributed_lock_timeout.unwrap_or(self.lock_timeout)
446    }
447
448    /// `true` if a timed-out factory is allowed to finish in the background.
449    #[must_use]
450    pub fn allow_timed_out_factory_background_completion(&self) -> bool {
451        self.allow_timed_out_factory_background_completion
452    }
453
454    /// `true` if L1 reads should be skipped.
455    #[must_use]
456    pub fn skip_memory_read(&self) -> bool {
457        self.skip_memory_read
458    }
459
460    /// `true` if L1 writes should be skipped.
461    #[must_use]
462    pub fn skip_memory_write(&self) -> bool {
463        self.skip_memory_write
464    }
465
466    /// `true` if read-only methods may return a stale value.
467    #[must_use]
468    pub fn allow_stale_on_read_only(&self) -> bool {
469        self.allow_stale_on_read_only
470    }
471
472    /// The memory-eviction priority.
473    #[must_use]
474    pub fn priority(&self) -> Priority {
475        self.priority
476    }
477
478    /// `true` if backplane notifications are suppressed for this operation.
479    #[must_use]
480    pub fn skip_backplane_notifications(&self) -> bool {
481        self.skip_backplane_notifications
482    }
483
484    /// `true` if a backplane publish may run in the background.
485    #[must_use]
486    pub fn allow_background_backplane_operations(&self) -> bool {
487        self.allow_background_backplane_operations
488    }
489
490    /// `true` if L2 reads should be skipped.
491    #[must_use]
492    pub fn skip_distributed_read(&self) -> bool {
493        self.skip_distributed_read
494    }
495
496    /// `true` if L2 reads should be skipped when the L1 entry is stale.
497    #[must_use]
498    pub fn skip_distributed_read_when_stale(&self) -> bool {
499        self.skip_distributed_read_when_stale
500    }
501
502    /// `true` if the cross-node distributed lock should be skipped for this op.
503    #[must_use]
504    pub fn skip_distributed_locker(&self) -> bool {
505        self.skip_distributed_locker
506    }
507
508    /// Skips the cross-node distributed lock for this operation.
509    #[must_use]
510    pub fn with_skip_distributed_locker(mut self, skip: bool) -> Self {
511        self.skip_distributed_locker = skip;
512        self
513    }
514
515    /// `true` if L1 values should be (deep-)cloned out on read.
516    ///
517    /// In Rust this is effectively always true: reads return an owned `V`
518    /// (`value_cloned`), so a caller mutating the returned value never affects the
519    /// cached copy. The flag exists for API parity and to opt into the same
520    /// guarantee explicitly.
521    #[must_use]
522    pub fn enable_auto_clone(&self) -> bool {
523        self.enable_auto_clone
524    }
525
526    /// Enables auto-clone of L1 values on read (see [`enable_auto_clone`](Self::enable_auto_clone)).
527    #[must_use]
528    pub fn with_enable_auto_clone(mut self, enable: bool) -> Self {
529        self.enable_auto_clone = enable;
530        self
531    }
532
533    /// The L2 soft timeout (awaited L2 op when a fallback exists).
534    #[must_use]
535    pub fn distributed_soft_timeout(&self) -> Timeout {
536        self.distributed_soft_timeout
537    }
538
539    /// The L2 hard timeout (always enforced on an awaited L2 op).
540    #[must_use]
541    pub fn distributed_hard_timeout(&self) -> Timeout {
542        self.distributed_hard_timeout
543    }
544
545    /// `true` if L2 writes should be skipped.
546    #[must_use]
547    pub fn skip_distributed_write(&self) -> bool {
548        self.skip_distributed_write
549    }
550
551    /// `true` if L2 writes may run in the background.
552    #[must_use]
553    pub fn allow_background_distributed_operations(&self) -> bool {
554        self.allow_background_distributed_operations
555    }
556
557    /// `true` if L2 backend exceptions should bubble to the caller.
558    #[must_use]
559    pub fn rethrow_distributed_exceptions(&self) -> bool {
560        self.rethrow_distributed_exceptions
561    }
562
563    /// `true` if (de)serialization errors should bubble to the caller (the
564    /// FusionCache default is `true`).
565    #[must_use]
566    pub fn rethrow_serialization_exceptions(&self) -> bool {
567        self.rethrow_serialization_exceptions
568    }
569
570    /// `true` if backplane exceptions should bubble to the caller.
571    #[must_use]
572    pub fn rethrow_backplane_exceptions(&self) -> bool {
573        self.rethrow_backplane_exceptions
574    }
575
576    // ----------------------------------------------------------------- resolved
577
578    /// The effective L1 logical duration (`memory_duration` or `duration`).
579    #[must_use]
580    pub fn resolved_memory_duration(&self) -> Duration {
581        self.memory_duration.unwrap_or(self.duration)
582    }
583
584    /// The effective L2 logical duration (`distributed_duration` or `duration`).
585    #[must_use]
586    pub fn resolved_distributed_duration(&self) -> Duration {
587        self.distributed_duration.unwrap_or(self.duration)
588    }
589
590    /// The effective L2 fail-safe max duration.
591    #[must_use]
592    pub fn resolved_distributed_fail_safe_max_duration(&self) -> Duration {
593        self.distributed_fail_safe_max_duration
594            .unwrap_or(self.fail_safe_max_duration)
595    }
596
597    /// The physical time-to-live handed to the L1 backend.
598    ///
599    /// With fail-safe enabled this is `max(duration, fail_safe_max_duration)`
600    /// — **not** the sum — so an entry physically survives long enough to be
601    /// reused as a stale fallback. Without fail-safe it is just the logical
602    /// duration.
603    #[must_use]
604    pub fn physical_ttl(&self) -> Duration {
605        let logical = self.resolved_memory_duration();
606        if self.is_fail_safe_enabled {
607            logical.max(self.fail_safe_max_duration)
608        } else {
609            logical
610        }
611    }
612
613    /// The physical time-to-live handed to the L2 backend — the L2 analogue of
614    /// [`physical_ttl`](Self::physical_ttl), using the distributed-specific
615    /// duration and fail-safe-max overrides.
616    #[must_use]
617    pub fn distributed_physical_ttl(&self) -> Duration {
618        let logical = self.resolved_distributed_duration();
619        if self.is_fail_safe_enabled {
620            logical.max(self.resolved_distributed_fail_safe_max_duration())
621        } else {
622            logical
623        }
624    }
625
626    /// Selects the factory timeout to enforce for this call.
627    ///
628    /// The soft timeout only applies when fail-safe is on *and* a fallback value
629    /// exists; the hard timeout always applies and wins when it is shorter.
630    #[must_use]
631    pub fn appropriate_factory_timeout(&self, has_fallback: bool) -> Timeout {
632        let mut selected = Timeout::Infinite;
633        if self.is_fail_safe_enabled && has_fallback && !self.factory_soft_timeout.is_infinite() {
634            selected = self.factory_soft_timeout;
635        }
636        selected.min(self.factory_hard_timeout)
637    }
638
639    /// Selects the L2 (distributed) timeout to enforce for an awaited L2 read.
640    ///
641    /// Mirrors [`appropriate_factory_timeout`](Self::appropriate_factory_timeout):
642    /// the distributed *soft* timeout only applies when fail-safe is on *and* a
643    /// fallback value exists; the *hard* timeout always applies and wins when it
644    /// is shorter (FusionCache `GetAppropriateDistributedCacheTimeout`).
645    #[must_use]
646    pub fn appropriate_distributed_timeout(&self, has_fallback: bool) -> Timeout {
647        let mut selected = Timeout::Infinite;
648        if self.is_fail_safe_enabled && has_fallback && !self.distributed_soft_timeout.is_infinite()
649        {
650            selected = self.distributed_soft_timeout;
651        }
652        selected.min(self.distributed_hard_timeout)
653    }
654
655    /// Computes the logical-expiration timestamp for a *fresh* entry created at
656    /// `created`, applying jitter (if any).
657    #[must_use]
658    pub fn logical_expiration(&self, created: Timestamp) -> Timestamp {
659        let base = created.saturating_add(self.resolved_memory_duration());
660        if self.jitter_max.is_zero() {
661            base
662        } else {
663            let max_ticks = duration_to_ticks(self.jitter_max);
664            let extra = if max_ticks > 0 {
665                fastrand::i64(0..=max_ticks)
666            } else {
667                0
668            };
669            base.saturating_add(crate::time::ticks_to_duration(extra))
670        }
671    }
672
673    /// Computes the eager-refresh trigger timestamp for an entry created at
674    /// `created`, or `None` if eager refresh is disabled.
675    #[must_use]
676    pub fn eager_refresh_at(&self, created: Timestamp) -> Option<Timestamp> {
677        let threshold = self.eager_refresh_threshold?;
678        let window = self.resolved_memory_duration();
679        let elapsed = window.mul_f32(threshold.fraction());
680        Some(created.saturating_add(elapsed))
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn eager_threshold_rejects_out_of_range() {
690        assert!(EagerThreshold::new(0.5).is_some());
691        assert!(EagerThreshold::new(0.0).is_none());
692        assert!(EagerThreshold::new(1.0).is_none());
693        assert!(EagerThreshold::new(-0.1).is_none());
694        assert!(EagerThreshold::new(1.5).is_none());
695    }
696
697    #[test]
698    fn physical_ttl_uses_max_not_sum() {
699        let opts = EntryOptions::new(Duration::from_secs(10)).with_fail_safe(
700            true,
701            Some(Duration::from_secs(100)),
702            None,
703        );
704        assert_eq!(opts.physical_ttl(), Duration::from_secs(100));
705
706        let opts2 = EntryOptions::new(Duration::from_secs(200)).with_fail_safe(
707            true,
708            Some(Duration::from_secs(100)),
709            None,
710        );
711        // max(200, 100) == 200, never the 300 sum.
712        assert_eq!(opts2.physical_ttl(), Duration::from_secs(200));
713    }
714
715    #[test]
716    fn physical_ttl_without_fail_safe_is_logical_duration() {
717        let opts = EntryOptions::new(Duration::from_secs(10));
718        assert_eq!(opts.physical_ttl(), Duration::from_secs(10));
719    }
720
721    #[test]
722    fn soft_timeout_ignored_without_fallback() {
723        let opts = EntryOptions::new(Duration::from_secs(10)).with_factory_timeouts(
724            Timeout::After(Duration::from_millis(50)),
725            Timeout::Infinite,
726            true,
727        );
728        // Soft timeout requires fail-safe; here fail-safe is off, so infinite.
729        assert_eq!(opts.appropriate_factory_timeout(true), Timeout::Infinite);
730    }
731
732    #[test]
733    fn soft_timeout_applies_with_fail_safe_and_fallback() {
734        let opts = EntryOptions::new(Duration::from_secs(10))
735            .with_fail_safe(true, None, None)
736            .with_factory_timeouts(
737                Timeout::After(Duration::from_millis(50)),
738                Timeout::Infinite,
739                true,
740            );
741        assert_eq!(
742            opts.appropriate_factory_timeout(true),
743            Timeout::After(Duration::from_millis(50))
744        );
745        // No fallback ⇒ soft timeout does not apply.
746        assert_eq!(opts.appropriate_factory_timeout(false), Timeout::Infinite);
747    }
748
749    #[test]
750    fn hard_timeout_wins_when_shorter() {
751        let opts = EntryOptions::new(Duration::from_secs(10))
752            .with_fail_safe(true, None, None)
753            .with_factory_timeouts(
754                Timeout::After(Duration::from_millis(50)),
755                Timeout::After(Duration::from_millis(20)),
756                true,
757            );
758        assert_eq!(
759            opts.appropriate_factory_timeout(true),
760            Timeout::After(Duration::from_millis(20))
761        );
762    }
763
764    #[test]
765    fn distributed_soft_timeout_applies_only_with_fail_safe_and_fallback() {
766        let opts = EntryOptions::new(Duration::from_secs(10))
767            .with_fail_safe(true, None, None)
768            .with_distributed_timeouts(
769                Timeout::After(Duration::from_millis(50)),
770                Timeout::Infinite,
771            );
772        assert_eq!(
773            opts.appropriate_distributed_timeout(true),
774            Timeout::After(Duration::from_millis(50))
775        );
776        // No fallback ⇒ soft does not apply.
777        assert_eq!(
778            opts.appropriate_distributed_timeout(false),
779            Timeout::Infinite
780        );
781
782        // Fail-safe off ⇒ soft does not apply even with a fallback.
783        let no_fs = EntryOptions::new(Duration::from_secs(10)).with_distributed_timeouts(
784            Timeout::After(Duration::from_millis(50)),
785            Timeout::Infinite,
786        );
787        assert_eq!(
788            no_fs.appropriate_distributed_timeout(true),
789            Timeout::Infinite
790        );
791    }
792
793    #[test]
794    fn distributed_hard_timeout_wins_when_shorter() {
795        let opts = EntryOptions::new(Duration::from_secs(10))
796            .with_fail_safe(true, None, None)
797            .with_distributed_timeouts(
798                Timeout::After(Duration::from_millis(50)),
799                Timeout::After(Duration::from_millis(20)),
800            );
801        assert_eq!(
802            opts.appropriate_distributed_timeout(true),
803            Timeout::After(Duration::from_millis(20))
804        );
805    }
806
807    #[test]
808    fn lock_timeouts_inherit_general_by_default() {
809        let opts = EntryOptions::new(Duration::from_secs(10))
810            .with_lock_timeout(Timeout::After(Duration::from_millis(100)));
811        assert_eq!(
812            opts.memory_lock_timeout(),
813            Timeout::After(Duration::from_millis(100))
814        );
815        assert_eq!(
816            opts.distributed_lock_timeout(),
817            Timeout::After(Duration::from_millis(100))
818        );
819    }
820
821    #[test]
822    fn specific_lock_timeouts_override_general() {
823        let opts = EntryOptions::new(Duration::from_secs(10))
824            .with_lock_timeout(Timeout::After(Duration::from_millis(100)))
825            .with_memory_lock_timeout(Timeout::After(Duration::from_millis(20)))
826            .with_distributed_lock_timeout(Timeout::Infinite);
827        assert_eq!(
828            opts.memory_lock_timeout(),
829            Timeout::After(Duration::from_millis(20))
830        );
831        assert_eq!(opts.distributed_lock_timeout(), Timeout::Infinite);
832        // The general fallback is untouched.
833        assert_eq!(
834            opts.lock_timeout(),
835            Timeout::After(Duration::from_millis(100))
836        );
837    }
838
839    #[test]
840    fn jitter_widens_logical_expiration_within_bound() {
841        let base_dur = Duration::from_secs(100);
842        let jitter = Duration::from_secs(10);
843        let created = Timestamp::from_ticks(0);
844
845        // No jitter ⇒ exactly base.
846        let plain = EntryOptions::new(base_dur);
847        let base_exp = plain.logical_expiration(created);
848        assert_eq!(plain.logical_expiration(created).ticks(), base_exp.ticks());
849
850        // With jitter ⇒ always in [base, base + jitter], never shorter, never over.
851        let jittered = EntryOptions::new(base_dur).with_jitter_max(jitter);
852        let upper = base_exp.saturating_add(jitter).ticks();
853        for _ in 0..200 {
854            let exp = jittered.logical_expiration(created).ticks();
855            assert!(exp >= base_exp.ticks(), "jitter never shortens expiration");
856            assert!(exp <= upper, "jitter never exceeds jitter_max");
857        }
858    }
859
860    #[test]
861    fn rethrow_and_background_setters_flip_the_flags() {
862        let o = EntryOptions::new(Duration::from_secs(1))
863            .with_rethrow_distributed_exceptions(true)
864            .with_rethrow_serialization_exceptions(false)
865            .with_allow_background_backplane_operations(false)
866            .with_distributed_fail_safe_max_duration(Duration::from_secs(7));
867        assert!(o.rethrow_distributed_exceptions());
868        assert!(!o.rethrow_serialization_exceptions());
869        assert!(!o.allow_background_backplane_operations());
870        assert_eq!(
871            o.resolved_distributed_fail_safe_max_duration(),
872            Duration::from_secs(7)
873        );
874    }
875}