Skip to main content

objectstore_service/backend/
common.rs

1//! Shared trait definition and types for all backends.
2
3use std::fmt;
4use std::num::NonZeroU64;
5use std::time::Duration;
6
7use objectstore_types::metadata::{ExpirationPolicy, Metadata};
8use objectstore_types::range::{ByteRange, ContentRange};
9use objectstore_types::resumable::UploadProgress;
10use objectstore_types::time::Timestamp;
11
12use bytes::Bytes;
13
14use crate::error::{Error, ErrorKind, Result};
15use crate::id::ObjectId;
16use crate::resumable::{BackendToken, Session};
17use crate::stream::{ClientStream, PayloadStream};
18
19/// User agent string used for outgoing requests.
20///
21/// This intentionally has a "sentry" prefix so that it can easily be traced back to us.
22pub const USER_AGENT: &str = concat!("sentry-objectstore/", env!("CARGO_PKG_VERSION"));
23
24/// Backend response for put operations.
25pub type PutResponse = ();
26/// Backend response for get operations.
27pub type GetResponse = Option<(Metadata, Option<ContentRange>, PayloadStream)>;
28/// Backend response for metadata-only get operations.
29pub type MetadataResponse = Option<Metadata>;
30/// Backend response for delete operations.
31pub type DeleteResponse = ();
32
33/// The outcome of an expiry update.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum SetExpiryResponse {
36    /// The deadline was extended or already satisfied the request.
37    ///
38    /// Contains the resolved requested deadline, not necessarily the stored deadline.
39    Satisfied(Timestamp),
40    /// The object or redirect was observed to be absent or expired.
41    NotFound,
42    /// The update could not be satisfied.
43    ///
44    /// The entry is non-expiring, lacks required creation metadata, or conflicts
45    /// with the conditional update. A failed conditional write does not establish
46    /// absence, even if a concurrent deletion caused it to fail.
47    Rejected,
48}
49
50/// The requested minimum deadline for an expiry update.
51///
52/// [`ExpiryTarget::At`] is already resolved. [`ExpiryTarget::FromCreation`]
53/// is resolved by the backend from the creation time read by the update
54/// operation itself. Zero durations and resolved deadlines in the past remain
55/// valid minimum-deadline requests.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum ExpiryTarget {
58    /// An absolute deadline.
59    At(Timestamp),
60    /// A deadline relative to the object's creation time.
61    FromCreation(Duration),
62}
63
64impl ExpiryTarget {
65    /// Resolves this target against an optional creation time.
66    ///
67    /// Returns `None` when a creation-relative target has no creation time.
68    /// Creation-relative deadlines use the timestamp's existing rounding and
69    /// clamp to its maximum value on overflow.
70    pub fn resolve(self, time_created: Option<Timestamp>) -> Option<Timestamp> {
71        match self {
72            Self::At(deadline) => Some(deadline),
73            Self::FromCreation(duration) => {
74                time_created.map(|created| created.saturating_add(duration))
75            }
76        }
77    }
78}
79
80/// An expiry target and optional limit on the remaining lifetime it requests.
81///
82/// The limit is measured from the operation's `access_time`, regardless of the target's
83/// anchor. It does not change the policy kind or shorten existing deadlines.
84/// Repeated updates can therefore keep an object alive indefinitely.
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub struct ExpiryUpdate {
87    /// The requested minimum deadline.
88    pub target: ExpiryTarget,
89    /// Maximum remaining lifetime. `None` means no limit.
90    pub max: Option<Duration>,
91}
92
93impl From<ExpiryTarget> for ExpiryUpdate {
94    fn from(target: ExpiryTarget) -> Self {
95        Self { target, max: None }
96    }
97}
98
99impl ExpiryUpdate {
100    /// Resolves and validates the requested deadline using the stored object metadata.
101    ///
102    /// Returns `None` if the creation-time anchor is required but unavailable.
103    ///
104    /// Returns [`ErrorKind::InvalidMetadata`] if the requested deadline exceeds
105    /// `access_time + max`, even if the existing deadline already satisfies it.
106    /// Timestamp addition rounds up to seconds and saturates at the supported maximum.
107    pub fn resolve(
108        self,
109        time_created: Option<Timestamp>,
110        access_time: Timestamp,
111    ) -> Result<Option<Timestamp>> {
112        let Some(deadline) = self.target.resolve(time_created) else {
113            return Ok(None);
114        };
115
116        if let Some(max) = self.max
117            && deadline > access_time.saturating_add(max)
118        {
119            return Err(Error::new(
120                ErrorKind::InvalidMetadata,
121                "requested expiry exceeds maximum remaining lifetime",
122            ));
123        }
124        Ok(Some(deadline))
125    }
126}
127
128/// Derives a TTL matching an extended deadline, leaving other policies unchanged.
129///
130/// Uses creation time when available, otherwise infers the original lifetime from
131/// the previous deadline and TTL. The inferred creation time is not persisted.
132/// Rounds to the coarsest whole day, hour, or minute within 1% of the duration,
133/// falling back to whole seconds. The deadline itself must remain exact.
134///
135/// Returns corrupt-data errors for inconsistent creation times or duration overflow.
136pub(super) fn extended_expiration_policy(
137    policy: ExpirationPolicy,
138    time_created: Option<Timestamp>,
139    old_expiry: Timestamp,
140    new_expiry: Timestamp,
141) -> Result<ExpirationPolicy> {
142    let ExpirationPolicy::TimeToLive(ttl) = policy else {
143        return Ok(policy);
144    };
145
146    let duration_opt = match time_created {
147        Some(created) => new_expiry.checked_duration_since(created),
148        None => new_expiry
149            .checked_duration_since(old_expiry)
150            .and_then(|extension| ttl.checked_add(extension)),
151    };
152
153    let duration = duration_opt
154        .ok_or_else(|| Error::new(ErrorKind::CorruptData, "invalid TTL expiry metadata"))?;
155    Ok(ExpirationPolicy::TimeToLive(round_duration(duration)))
156}
157
158/// Rounds a duration to the coarsest whole day, hour, or minute within 1%, otherwise seconds.
159fn round_duration(duration: Duration) -> Duration {
160    let mut seconds = duration.as_secs();
161
162    // Match Timestamp's upward rounding for legacy fractional durations.
163    if duration.subsec_nanos() != 0 {
164        seconds = seconds.saturating_add(1);
165    }
166
167    for unit in [86_400, 3_600, 60] {
168        let rounded = seconds.saturating_add(unit / 2) / unit * unit;
169        if rounded.abs_diff(seconds) <= seconds / 100 {
170            return Duration::from_secs(rounded);
171        }
172    }
173
174    Duration::from_secs(seconds)
175}
176
177/// Trait implemented by all storage backends.
178///
179/// Object operations take `access_time`, the timestamp of the caller's operation.
180/// Use it to decide whether an object or redirect has expired, so all steps of
181/// an operation use the same time, including retries and calls to other backends.
182/// An object is expired when its deadline is strictly earlier than `access_time`.
183/// Writes preserve the creation time and deadline in the supplied metadata.
184#[async_trait::async_trait]
185pub trait Backend: fmt::Debug + Send + Sync + 'static {
186    /// The backend name, used for diagnostics.
187    fn name(&self) -> &'static str;
188
189    /// Returns the upload granularity for sessions opened by this backend, in bytes.
190    ///
191    /// A value of zero means these uploads have no granularity. A positive value means that a
192    /// non-final chunk can persist only a multiple of this value. Implementations must reject a
193    /// non-empty, non-final chunk shorter than one unit with [`ErrorKind::ChunkTooSmall`].
194    fn upload_granularity(&self) -> u64 {
195        0
196    }
197
198    /// Stores an object at the given path with the given metadata.
199    async fn put_object(
200        &self,
201        id: &ObjectId,
202        metadata: &Metadata,
203        stream: ClientStream,
204        access_time: Timestamp,
205    ) -> Result<PutResponse>;
206
207    /// Retrieves (part of) an object at the given path, returning its metadata, a description of
208    /// the part being returned, and the payload.
209    async fn get_object(
210        &self,
211        id: &ObjectId,
212        access_time: Timestamp,
213        range: Option<ByteRange>,
214    ) -> Result<GetResponse>;
215
216    /// Retrieves only the metadata for an object, without the payload.
217    async fn get_metadata(
218        &self,
219        id: &ObjectId,
220        access_time: Timestamp,
221    ) -> Result<MetadataResponse> {
222        Ok(self
223            .get_object(id, access_time, None)
224            .await?
225            .map(|(metadata, _range, _stream)| metadata))
226    }
227
228    /// Extends the deadline of an existing object with expiration policy.
229    ///
230    /// Actual extensions also update TTL durations to approximately match the time since
231    /// creation. TTI durations, payload, and other metadata remain unchanged.
232    ///
233    /// Returns [`SetExpiryResponse::Satisfied`] when extended or already satisfied,
234    /// [`SetExpiryResponse::NotFound`] when observed absent or expired, or
235    /// [`SetExpiryResponse::Rejected`] when ineligible or conflicting.
236    /// Limit violations return [`ErrorKind::InvalidMetadata`]; backend failures also
237    /// return errors. Limits are checked before reporting an already-satisfied request.
238    async fn set_expiry(
239        &self,
240        id: &ObjectId,
241        target: ExpiryUpdate,
242        access_time: Timestamp,
243    ) -> Result<SetExpiryResponse>;
244
245    /// Deletes the object at the given path.
246    async fn delete_object(&self, id: &ObjectId, access_time: Timestamp) -> Result<DeleteResponse>;
247
248    /// Waits for any outstanding background operations to complete before shutdown.
249    ///
250    /// The default implementation is a no-op. Backends that spawn background tasks
251    /// (such as [`TieredStorage`](super::tiered::TieredStorage)) should override this
252    /// to wait for those tasks to complete.
253    async fn join(&self) {}
254
255    /// Creates a resumable upload session for the object at `id`.
256    ///
257    /// Object metadata and its total length are declared upfront and cannot be mutated
258    /// during the upload.
259    ///
260    /// The returned string is opaque backend-defined state. [`StorageService`](crate::StorageService)
261    /// protects it before exposing the session token outside the service layer.
262    ///
263    /// Returns `Ok(None)` when this backend cannot store the described object resumably. Declining
264    /// is a routine outcome rather than an error, and the default implementation declines.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error only when the backend supports resumable uploads but failed to open the
269    /// session.
270    async fn create_upload_session(
271        &self,
272        id: &ObjectId,
273        metadata: &Metadata,
274        upload_length: NonZeroU64,
275    ) -> Result<Option<BackendToken>> {
276        let _ = (id, metadata, upload_length);
277        Ok(None)
278    }
279
280    /// Writes a chunk of `content_length` bytes at `offset` into an open session.
281    ///
282    /// A backend may acknowledge fewer bytes than the chunk supplied, for example by persisting
283    /// only an aligned prefix. Callers must continue from the authoritative offset in the returned
284    /// [`UploadProgress`], or query [`Self::upload_offset`] after an ambiguous failure. A backend
285    /// may or may not accept a replay starting before its persisted offset.
286    ///
287    /// [`UploadProgress::Complete`] means the upload is terminal and the object is available
288    /// through this backend's normal read methods. A backend that composes another backend must
289    /// finish its own publication work before returning that outcome.
290    ///
291    /// A `content_length` of zero is valid. It writes nothing and reports the offset the backend
292    /// holds.
293    ///
294    /// Returns [`ErrorKind::UnknownUploadSession`] when `session` does not identify an open session,
295    /// and [`ErrorKind::ChunkExceedsUploadLength`] when the chunk would exceed the total length
296    /// declared when the session was created. Returns [`ErrorKind::ChunkTooSmall`] when a non-empty,
297    /// non-final chunk is shorter than the upload granularity.
298    async fn put_chunk(
299        &self,
300        session: &Session,
301        offset: u64,
302        content_length: u64,
303        stream: ClientStream,
304    ) -> Result<UploadProgress> {
305        let _ = (session, offset, content_length, stream);
306        Err(ErrorKind::Unsupported.into())
307    }
308
309    /// Reports how far the session has progressed.
310    ///
311    /// This can return [`UploadProgress::Complete`] repeatedly after the final chunk, including
312    /// when its original response was lost. A composed backend may finish pending idempotent
313    /// publication work before returning that terminal outcome.
314    ///
315    /// Returns [`ErrorKind::UnknownUploadSession`] when `session` does not identify a known session.
316    async fn upload_offset(&self, session: &Session) -> Result<UploadProgress> {
317        let _ = session;
318        Err(ErrorKind::Unsupported.into())
319    }
320
321    /// Cancels an upload session, discarding whatever was uploaded.
322    ///
323    /// Returns [`ErrorKind::UnknownUploadSession`] when `session` does not identify an open session.
324    async fn cancel_upload(&self, session: &Session) -> Result<()> {
325        let _ = session;
326        Err(ErrorKind::Unsupported.into())
327    }
328}
329
330/// Trait for backends that support tombstone-conditional operations.
331///
332/// Only backends suitable for the high-volume tier of
333/// [`TieredStorage`](super::tiered::TieredStorage) implement this trait.
334/// The conditional methods provide atomic operations to avoid overwriting
335/// redirect tombstones.
336#[async_trait::async_trait]
337pub trait HighVolumeBackend: Backend {
338    /// Creates an upload marker separate from the logical object row.
339    ///
340    /// `revision` is the upload's unique LT revision. The fixed deadline is independent
341    /// of object expiration and must not be refreshed by subsequent requests.
342    async fn create_upload_marker(
343        &self,
344        revision: &ObjectId,
345        time_expires: Timestamp,
346    ) -> Result<()>;
347
348    /// Returns whether an upload marker exists and is live at `access_time`.
349    async fn has_upload_marker(&self, revision: &ObjectId, access_time: Timestamp) -> Result<bool>;
350
351    /// Atomically deletes a live upload marker.
352    ///
353    /// Returns `true` only when this call deletes a live marker.
354    /// Missing and expired markers return `false`, including on a repeated deletion.
355    async fn delete_upload_marker(
356        &self,
357        revision: &ObjectId,
358        access_time: Timestamp,
359    ) -> Result<bool>;
360
361    /// Writes the object only if NO redirect tombstone exists at this key.
362    ///
363    /// Returns `None` after storing the object, or `Some(tombstone)` (skipping
364    /// the write) when a redirect tombstone is present. The returned tombstone
365    /// carries the target LT `ObjectId` so the caller can route without a
366    /// second round trip.
367    ///
368    /// Takes [`Bytes`] instead of a [`ClientStream`] because callers on this
369    /// path have already fully buffered the payload.
370    async fn put_non_tombstone(
371        &self,
372        id: &ObjectId,
373        metadata: &Metadata,
374        payload: Bytes,
375        access_time: Timestamp,
376    ) -> Result<Option<Tombstone>>;
377
378    /// Retrieves (part of) an object with explicit tombstone awareness.
379    ///
380    /// Returns [`TieredGet::Tombstone`] instead of synthesizing a tombstone
381    /// object, making the caller's routing logic a compile-time distinction.
382    async fn get_tiered_object(
383        &self,
384        id: &ObjectId,
385        access_time: Timestamp,
386        range: Option<ByteRange>,
387    ) -> Result<TieredGet>;
388
389    /// Retrieves only metadata with explicit tombstone awareness.
390    ///
391    /// Implementations should skip the payload column where possible to avoid
392    /// fetching up to 1 MiB of data just to discover a tombstone.
393    async fn get_tiered_metadata(
394        &self,
395        id: &ObjectId,
396        access_time: Timestamp,
397    ) -> Result<TieredMetadata>;
398
399    /// Deletes the object only if it is NOT a redirect tombstone.
400    ///
401    /// Returns `None` after deleting the row (or if the row was already absent),
402    /// or `Some(tombstone)` (leaving the row intact) when the object is a
403    /// redirect tombstone. The returned tombstone carries the target LT
404    /// `ObjectId` so the caller can delete from long-term storage directly,
405    /// without a second round trip.
406    async fn delete_non_tombstone(
407        &self,
408        id: &ObjectId,
409        access_time: Timestamp,
410    ) -> Result<Option<Tombstone>>;
411
412    /// Atomically mutates the row if the current redirect state matches.
413    ///
414    /// `current` determines the precondition:
415    /// - `None`: succeeds only if no live tombstone exists (row absent, inline,
416    ///   or tombstone present but logically expired).
417    /// - `Some(target)`: succeeds only if a tombstone exists whose redirect
418    ///   resolves to `target`.
419    ///
420    /// **This operation is idempotent:** if the object is already in the target
421    /// state, it returns `true`. Whether the mutation runs again is up to the
422    /// implementation.
423    ///
424    /// Returns `true` on success or idempotent match, `false` if a conflicting
425    /// state was found (another writer won the race).
426    async fn compare_and_write(
427        &self,
428        id: &ObjectId,
429        current: Option<&ObjectId>,
430        write: TieredWrite,
431        access_time: Timestamp,
432    ) -> Result<bool>;
433
434    /// Atomically updates an existing row if its kind and redirect target match.
435    ///
436    /// `current = None` requires a live inline object. `Some(target)` requires
437    /// a live redirect to exactly that target. Updates never authorize creation
438    /// of an absent row.
439    ///
440    /// Returns [`SetExpiryResponse::Satisfied`] when applied or already satisfied,
441    /// [`SetExpiryResponse::NotFound`] when observed absent or expired, or
442    /// [`SetExpiryResponse::Rejected`] for an ineligible entry or failed condition.
443    /// Redirects can only resolve absolute targets because tombstones do not store
444    /// creation time. Backend failures are returned as errors.
445    async fn compare_and_update(
446        &self,
447        id: &ObjectId,
448        current: Option<&ObjectId>,
449        update: TieredUpdate,
450        access_time: Timestamp,
451    ) -> Result<SetExpiryResponse>;
452}
453
454/// Information about a redirect tombstone in the high-volume backend.
455#[derive(Clone, Debug, PartialEq, Eq)]
456pub struct Tombstone {
457    /// The [`ObjectId`] of the object in the long-term backend.
458    ///
459    /// For legacy tombstones with an empty `r` column, the HV backend resolves
460    /// this to the HV `ObjectId` itself before surfacing the tombstone to callers.
461    pub target: ObjectId,
462
463    /// The concrete deadline stored on the redirect.
464    pub time_expires: Option<Timestamp>,
465}
466
467impl Tombstone {
468    /// Returns whether the tombstone has expired at the given time.
469    pub fn is_expired(&self, now: Timestamp) -> bool {
470        self.time_expires.is_some_and(|deadline| deadline < now)
471    }
472}
473
474/// Typed response from [`HighVolumeBackend::get_tiered_object`].
475pub enum TieredGet {
476    /// A real object was found.
477    Object(Metadata, Option<ContentRange>, PayloadStream),
478    /// A redirect tombstone was found; the real object lives in the long-term backend.
479    Tombstone(Tombstone),
480    /// No entry exists at this key.
481    NotFound,
482}
483
484impl fmt::Debug for TieredGet {
485    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
486        match self {
487            TieredGet::Object(metadata, content_range, _stream) => f
488                .debug_tuple("Object")
489                .field(metadata)
490                .field(content_range)
491                .finish_non_exhaustive(),
492            TieredGet::Tombstone(info) => f.debug_tuple("Tombstone").field(info).finish(),
493            TieredGet::NotFound => write!(f, "NotFound"),
494        }
495    }
496}
497
498/// Typed metadata-only response from [`HighVolumeBackend::get_tiered_metadata`].
499#[derive(Debug)]
500pub enum TieredMetadata {
501    /// Metadata for a real object was found.
502    Object(Metadata),
503    /// A redirect tombstone was found; the real object lives in the long-term backend.
504    Tombstone(Tombstone),
505    /// No entry exists at this key.
506    NotFound,
507}
508
509/// The write operation performed by [`HighVolumeBackend::compare_and_write`].
510#[derive(Clone, Debug)]
511pub enum TieredWrite {
512    /// Write a redirect tombstone.
513    Tombstone(Tombstone),
514    /// Write inline object data.
515    Object(Metadata, Bytes),
516    /// Delete the row entirely.
517    Delete,
518}
519
520impl TieredWrite {
521    /// Returns the tombstone target if this is a tombstone write, or `None` otherwise.
522    pub fn target(&self) -> Option<&ObjectId> {
523        match self {
524            TieredWrite::Tombstone(t) => Some(&t.target),
525            _ => None,
526        }
527    }
528}
529
530/// The in-place operation performed by [`HighVolumeBackend::compare_and_update`].
531#[derive(Clone, Debug)]
532pub enum TieredUpdate {
533    /// Extend the deadline and adjust TTL duration while preserving other stored data.
534    SetExpiry(ExpiryUpdate),
535}
536
537/// Creates a reqwest client with required defaults.
538///
539/// Automatic decompression is disabled because backends store pre-compressed
540/// payloads and manage `Content-Encoding` themselves.
541pub(super) fn reqwest_client(tcp_user_timeout: Duration) -> reqwest::Client {
542    let builder = reqwest::Client::builder()
543        .user_agent(USER_AGENT)
544        .hickory_dns(true)
545        .http1_only()
546        .no_zstd()
547        .no_brotli()
548        .no_gzip()
549        .no_deflate();
550
551    #[cfg(target_os = "linux")]
552    let builder = builder.tcp_user_timeout(tcp_user_timeout);
553    #[cfg(not(target_os = "linux"))]
554    let _ = tcp_user_timeout;
555
556    builder
557        .build()
558        // INVARIANT: Building fails only if the TLS backend cannot be initialized, which
559        // is checked at startup when the rustls crypto provider is installed.
560        .expect("failed to build backend HTTP client")
561}
562
563/// Matches reqwest's default TCP user timeout on supported platforms.
564pub(super) fn default_tcp_user_timeout() -> Duration {
565    Duration::from_secs(30)
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn extended_ttl_rounding() {
574        let created = Timestamp::from_unix_secs(1_700_000_000).unwrap();
575        let policy = ExpirationPolicy::TimeToLive(Duration::from_hours(1));
576        for (seconds, rounded) in [
577            (0, 0),
578            (59, 59),
579            (61, 61),
580            (119, 120),
581            (121, 120),
582            (3598, 3600),
583            (3602, 3600),
584            (86_398, 86_400),
585            (86_402, 86_400),
586            (90 * 86_400 - 2, 90 * 86_400),
587            (90 * 86_400 + 2, 90 * 86_400),
588            (40_000, 39_600), // Exactly 1% rounds to hours.
589            (40_001, 40_020), // Just outside 1% falls back to minutes.
590        ] {
591            assert_eq!(
592                extended_expiration_policy(
593                    policy,
594                    Some(created),
595                    created,
596                    created + Duration::from_secs(seconds)
597                )
598                .unwrap(),
599                ExpirationPolicy::TimeToLive(Duration::from_secs(rounded)),
600                "{seconds} seconds",
601            );
602        }
603
604        assert_eq!(
605            round_duration(Duration::MAX),
606            Duration::from_secs(u64::MAX - u64::MAX % 86_400),
607        );
608    }
609
610    #[test]
611    fn extended_ttl_without_creation_time() {
612        // The inferred creation time may precede the epoch.
613        let epoch = Timestamp::from_unix_secs(0).unwrap();
614        let policy = ExpirationPolicy::TimeToLive(Duration::from_hours(1));
615        let first_expiry = epoch + Duration::from_hours(1);
616        let extended = extended_expiration_policy(policy, None, epoch, first_expiry).unwrap();
617        assert_eq!(
618            extended,
619            ExpirationPolicy::TimeToLive(Duration::from_hours(2))
620        );
621
622        let next_expiry = first_expiry + Duration::from_hours(1);
623        let extended_again =
624            extended_expiration_policy(extended, None, first_expiry, next_expiry).unwrap();
625        assert_eq!(
626            extended_again,
627            ExpirationPolicy::TimeToLive(Duration::from_hours(3))
628        );
629    }
630
631    #[test]
632    fn extended_ttl_fractional_duration() {
633        let epoch = Timestamp::from_unix_secs(0).unwrap();
634        assert_eq!(
635            extended_expiration_policy(
636                ExpirationPolicy::TimeToLive(Duration::from_millis(1500)),
637                None,
638                epoch,
639                epoch + Duration::from_secs(1)
640            )
641            .unwrap(),
642            ExpirationPolicy::TimeToLive(Duration::from_secs(3)),
643        );
644    }
645
646    #[test]
647    fn extended_ttl_invalid_creation_time() {
648        let expiry = Timestamp::from_unix_secs(1_700_000_000).unwrap();
649        let created = expiry + Duration::from_secs(1);
650        let policy = ExpirationPolicy::TimeToLive(Duration::from_hours(1));
651        assert_eq!(
652            extended_expiration_policy(policy, Some(created), expiry, expiry)
653                .unwrap_err()
654                .kind(),
655            ErrorKind::CorruptData
656        );
657    }
658
659    #[test]
660    fn extended_ttl_duration_overflow() {
661        let epoch = Timestamp::from_unix_secs(0).unwrap();
662        assert_eq!(
663            extended_expiration_policy(
664                ExpirationPolicy::TimeToLive(Duration::MAX),
665                None,
666                epoch,
667                epoch + Duration::from_secs(1),
668            )
669            .unwrap_err()
670            .kind(),
671            ErrorKind::CorruptData
672        );
673    }
674
675    #[test]
676    fn extended_ttl_saturated_deadline() {
677        let max = Timestamp::from_unix_secs(253_402_300_799).unwrap();
678        assert_eq!(
679            extended_expiration_policy(
680                ExpirationPolicy::TimeToLive(Duration::from_hours(3)),
681                Some(max - Duration::from_hours(1)),
682                max - Duration::from_secs(1),
683                max.saturating_add(Duration::from_hours(1))
684            )
685            .unwrap(),
686            ExpirationPolicy::TimeToLive(Duration::from_hours(1))
687        );
688    }
689
690    #[test]
691    fn expiry_target_resolution() {
692        let created = Timestamp::from_unix_secs(1_700_000_000).unwrap();
693        assert_eq!(
694            ExpiryTarget::FromCreation(Duration::ZERO).resolve(Some(created)),
695            Some(created)
696        );
697        assert_eq!(ExpiryTarget::At(created).resolve(None), Some(created));
698        assert_eq!(
699            ExpiryTarget::FromCreation(Duration::ZERO).resolve(None),
700            None
701        );
702        let max = Timestamp::from_unix_secs(253_402_300_799).unwrap();
703        assert_eq!(
704            ExpiryTarget::FromCreation(Duration::from_secs(1)).resolve(Some(max)),
705            Some(max)
706        );
707
708        // The cap is measured from access time, not creation.
709        let deadline = created + Duration::from_hours(3);
710        let access_time = created + Duration::from_hours(1);
711        for target in [
712            ExpiryTarget::At(deadline),
713            ExpiryTarget::FromCreation(Duration::from_hours(3)),
714        ] {
715            let update = ExpiryUpdate {
716                target,
717                max: Some(Duration::from_hours(1)),
718            };
719            assert_eq!(
720                update
721                    .resolve(Some(created), access_time)
722                    .unwrap_err()
723                    .kind(),
724                ErrorKind::InvalidMetadata
725            );
726            assert_eq!(
727                update
728                    .resolve(Some(created), access_time + Duration::from_hours(1))
729                    .unwrap(),
730                Some(deadline)
731            );
732        }
733    }
734}