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}