1use std::collections::{BTreeMap, HashMap};
9use std::sync::{Arc, Mutex};
10use std::time::SystemTime;
11
12use objectstore_types::range::ByteRange;
13use objectstore_types::time::Timestamp;
14
15use bytes::{Bytes, BytesMut};
16use futures_util::TryStreamExt;
17use objectstore_types::metadata::Metadata;
18
19use super::common::{
20 DeleteResponse, GetResponse, HighVolumeBackend, MultipartUploadBackend, PutResponse, TieredGet,
21 TieredMetadata, TieredUpdate, TieredWrite, Tombstone,
22};
23use crate::change_stream::{ChangeStream, NoopStream, flush_change_stream};
24use crate::error::{Error, ErrorKind, Result};
25use crate::id::ObjectId;
26use crate::multipart::{
27 AbortMultipartResponse, CompleteMultipartResponse, CompletedPart, InitiateMultipartResponse,
28 ListPartsResponse, Part, PartNumber, UploadId, UploadPartResponse,
29};
30use crate::stream::ClientStream;
31
32#[derive(Clone, Debug)]
34enum StoreEntry {
35 Object(Metadata, Bytes),
36 Tombstone(Tombstone),
37}
38
39impl StoreEntry {
40 fn is_expired(&self, now: Timestamp) -> bool {
41 match self {
42 StoreEntry::Object(metadata, _) => metadata.is_expired(now),
43 StoreEntry::Tombstone(tombstone) => tombstone.is_expired(now),
44 }
45 }
46
47 pub(crate) fn stored_size(&self) -> usize {
49 match self {
50 StoreEntry::Object(metadata, payload) => json_len(metadata) + payload.len(),
51 StoreEntry::Tombstone(tombstone) => {
54 tombstone.target.as_storage_path().to_string().len()
55 + tombstone.time_expires.map_or(0, |d| json_len(&d))
56 }
57 }
58 }
59}
60
61type Store = HashMap<ObjectId, StoreEntry>;
62
63#[derive(Clone, Debug)]
64struct MultipartUpload {
65 metadata: Metadata,
66 parts: BTreeMap<PartNumber, UploadedPart>,
67}
68
69#[derive(Clone, Debug)]
70struct UploadedPart {
71 etag: String,
72 data: Bytes,
73 uploaded_at: SystemTime,
74}
75
76type MultipartStore = HashMap<(ObjectId, UploadId), MultipartUpload>;
77
78#[derive(Debug, Clone)]
84pub struct InMemoryBackend {
85 name: &'static str,
86 store: Arc<Mutex<Store>>,
87 multipart_store: Arc<Mutex<MultipartStore>>,
88 change_stream: Arc<dyn ChangeStream>,
89}
90
91impl InMemoryBackend {
92 pub fn new(name: &'static str) -> Self {
94 Self {
95 name,
96 store: Arc::new(Mutex::new(HashMap::new())),
97 multipart_store: Arc::new(Mutex::new(HashMap::new())),
98 change_stream: Arc::new(NoopStream),
99 }
100 }
101
102 pub fn with_change_stream(mut self, change_stream: Arc<dyn ChangeStream>) -> Self {
104 self.change_stream = change_stream;
105 self
106 }
107
108 pub fn get(&self, id: &ObjectId) -> Entry {
110 match self.store.lock().unwrap().get(id).cloned() {
111 None => Entry::NotFound,
112 Some(StoreEntry::Tombstone(tombstone)) => Entry::Tombstone(tombstone),
113 Some(StoreEntry::Object(metadata, bytes)) => Entry::Object(metadata, bytes),
114 }
115 }
116
117 pub fn contains(&self, id: &ObjectId) -> bool {
119 self.store.lock().unwrap().contains_key(id)
120 }
121
122 pub fn is_empty(&self) -> bool {
124 self.store.lock().unwrap().is_empty()
125 }
126
127 pub fn remove(&self, id: &ObjectId) {
131 self.store.lock().unwrap().remove(id);
132 }
133}
134
135#[async_trait::async_trait]
136impl super::common::Backend for InMemoryBackend {
137 fn name(&self) -> &'static str {
138 self.name
139 }
140
141 fn as_multipart_upload_backend(&self) -> Result<&dyn MultipartUploadBackend> {
142 Ok(self)
143 }
144
145 async fn put_object(
146 &self,
147 id: &ObjectId,
148 metadata: &Metadata,
149 stream: ClientStream,
150 _access_time: Timestamp,
151 ) -> Result<PutResponse> {
152 let bytes: BytesMut = stream.try_collect().await?;
153 let entry = StoreEntry::Object(metadata.clone(), bytes.freeze());
154 let size = entry.stored_size();
155 self.store.lock().unwrap().insert(id.clone(), entry);
156 self.change_stream
157 .write(id, size as u64, metadata.time_expires);
158 Ok(())
159 }
160
161 async fn get_object(
162 &self,
163 id: &ObjectId,
164 access_time: Timestamp,
165 range: Option<ByteRange>,
166 ) -> Result<GetResponse> {
167 let entry = self.store.lock().unwrap().get(id).cloned();
168 match entry {
169 None => Ok(None),
170 Some(entry) if entry.is_expired(access_time) => Ok(None),
171 Some(StoreEntry::Tombstone(_)) => Err(ErrorKind::UnexpectedTombstone.into()),
172 Some(StoreEntry::Object(mut metadata, bytes)) => {
173 let total = bytes.len() as u64;
174 metadata.size = Some(bytes.len());
175 let (content_range, payload) = match range {
176 Some(range) => {
177 let content_range = range
178 .resolve(total)
179 .ok_or(ErrorKind::RangeNotSatisfiable { total })?;
180 let sliced =
181 bytes.slice(content_range.start as usize..=content_range.end as usize);
182 (Some(content_range), sliced)
183 }
184 None => (None, bytes),
185 };
186 Ok(Some((
187 metadata,
188 content_range,
189 crate::stream::single(payload),
190 )))
191 }
192 }
193 }
194
195 async fn set_expiry(
196 &self,
197 id: &ObjectId,
198 expire_at: Timestamp,
199 access_time: Timestamp,
200 ) -> Result<bool> {
201 let outcome = {
202 let mut store = self.store.lock().unwrap();
203 match store.get_mut(id) {
204 Some(StoreEntry::Object(metadata, _)) => {
205 extend_expiry(&mut metadata.time_expires, expire_at, access_time)
206 }
207 _ => ExpiryOutcome::Rejected,
208 }
209 };
210
211 if outcome == ExpiryOutcome::Extended {
212 self.change_stream.update(id, Some(expire_at));
213 }
214
215 Ok(outcome.is_satisfied())
216 }
217
218 async fn delete_object(
219 &self,
220 id: &ObjectId,
221 _access_time: Timestamp,
222 ) -> Result<DeleteResponse> {
223 if self.store.lock().unwrap().remove(id).is_some() {
224 self.change_stream.delete(id);
225 }
226 Ok(())
227 }
228
229 async fn join(&self) {
230 flush_change_stream(&self.change_stream).await;
231 }
232}
233
234#[async_trait::async_trait]
235impl HighVolumeBackend for InMemoryBackend {
236 async fn put_non_tombstone(
237 &self,
238 id: &ObjectId,
239 metadata: &Metadata,
240 payload: Bytes,
241 access_time: Timestamp,
242 ) -> Result<Option<Tombstone>> {
243 let mut store = self.store.lock().unwrap();
244 if let Some(StoreEntry::Tombstone(tombstone)) = store.get(id)
245 && !tombstone.is_expired(access_time)
246 {
247 return Ok(Some(tombstone.clone()));
248 }
249
250 let mut metadata = metadata.clone();
251 metadata.size = Some(payload.len());
252 let expires_at = metadata.time_expires;
253 let entry = StoreEntry::Object(metadata, payload);
254 let size = entry.stored_size();
255 store.insert(id.clone(), entry);
256 self.change_stream.write(id, size as u64, expires_at);
257 Ok(None)
258 }
259
260 async fn get_tiered_object(
261 &self,
262 id: &ObjectId,
263 access_time: Timestamp,
264 range: Option<ByteRange>,
265 ) -> Result<TieredGet> {
266 let entry = self.store.lock().unwrap().get(id).cloned();
267 Ok(match entry {
268 None => TieredGet::NotFound,
269 Some(entry) if entry.is_expired(access_time) => TieredGet::NotFound,
270 Some(StoreEntry::Tombstone(tombstone)) => TieredGet::Tombstone(tombstone),
271 Some(StoreEntry::Object(mut metadata, bytes)) => {
272 let total = bytes.len() as u64;
273 metadata.size = Some(bytes.len());
274 let (content_range, payload) = match range {
275 Some(range) => {
276 let content_range = range
277 .resolve(total)
278 .ok_or(ErrorKind::RangeNotSatisfiable { total })?;
279 let sliced =
280 bytes.slice(content_range.start as usize..=content_range.end as usize);
281 (Some(content_range), sliced)
282 }
283 None => (None, bytes),
284 };
285 TieredGet::Object(metadata, content_range, crate::stream::single(payload))
286 }
287 })
288 }
289
290 async fn get_tiered_metadata(
291 &self,
292 id: &ObjectId,
293 access_time: Timestamp,
294 ) -> Result<TieredMetadata> {
295 let entry = self.store.lock().unwrap().get(id).cloned();
296 Ok(match entry {
297 None => TieredMetadata::NotFound,
298 Some(entry) if entry.is_expired(access_time) => TieredMetadata::NotFound,
299 Some(StoreEntry::Tombstone(tombstone)) => TieredMetadata::Tombstone(tombstone),
300 Some(StoreEntry::Object(metadata, _bytes)) => TieredMetadata::Object(metadata),
301 })
302 }
303
304 async fn delete_non_tombstone(
305 &self,
306 id: &ObjectId,
307 access_time: Timestamp,
308 ) -> Result<Option<Tombstone>> {
309 let mut store = self.store.lock().unwrap();
310 if let Some(StoreEntry::Tombstone(tombstone)) = store.get(id).cloned()
311 && !tombstone.is_expired(access_time)
312 {
313 return Ok(Some(tombstone));
314 }
315
316 if store.remove(id).is_some() {
317 self.change_stream.delete(id);
318 }
319 Ok(None)
320 }
321
322 async fn compare_and_update(
323 &self,
324 id: &ObjectId,
325 current: Option<&ObjectId>,
326 update: TieredUpdate,
327 access_time: Timestamp,
328 ) -> Result<bool> {
329 let TieredUpdate::SetExpiry(expire_at) = update;
330 let outcome = {
331 let mut store = self.store.lock().unwrap();
332 match (store.get_mut(id), current) {
333 (Some(StoreEntry::Object(metadata, _)), None) => {
334 extend_expiry(&mut metadata.time_expires, expire_at, access_time)
335 }
336 (Some(StoreEntry::Tombstone(t)), Some(target)) if t.target == *target => {
337 extend_expiry(&mut t.time_expires, expire_at, access_time)
338 }
339 _ => ExpiryOutcome::Rejected,
340 }
341 };
342
343 if outcome == ExpiryOutcome::Extended {
344 self.change_stream.update(id, Some(expire_at));
345 }
346
347 Ok(outcome.is_satisfied())
348 }
349
350 async fn compare_and_write(
351 &self,
352 id: &ObjectId,
353 current: Option<&ObjectId>,
354 write: TieredWrite,
355 access_time: Timestamp,
356 ) -> Result<bool> {
357 let mut store = self.store.lock().unwrap();
358
359 let actual = store.get(id);
360 let matches_current = matches_redirect(actual, current, access_time);
361 let matches_next = matches_redirect(actual, write.target(), access_time);
362
363 if matches_current {
364 match write {
365 TieredWrite::Tombstone(tombstone) => {
366 let expires_at = tombstone.time_expires;
367 let entry = StoreEntry::Tombstone(tombstone);
368 let size = entry.stored_size();
369 store.insert(id.clone(), entry);
370 self.change_stream.write(id, size as u64, expires_at);
371 }
372 TieredWrite::Object(metadata, payload) => {
373 let expires_at = metadata.time_expires;
374 let entry = StoreEntry::Object(metadata, payload);
375 let size = entry.stored_size();
376 store.insert(id.clone(), entry);
377 self.change_stream.write(id, size as u64, expires_at);
378 }
379 TieredWrite::Delete => {
380 if store.remove(id).is_some() {
381 self.change_stream.delete(id);
382 }
383 }
384 }
385 }
386
387 Ok(matches_current || matches_next)
388 }
389}
390
391#[async_trait::async_trait]
392impl MultipartUploadBackend for InMemoryBackend {
393 async fn initiate_multipart(
394 &self,
395 id: &ObjectId,
396 metadata: &Metadata,
397 ) -> Result<InitiateMultipartResponse> {
398 let upload_id = UploadId::new(uuid::Uuid::now_v7().to_string())?;
399 let upload = MultipartUpload {
400 metadata: metadata.clone(),
401 parts: BTreeMap::new(),
402 };
403 self.multipart_store
404 .lock()
405 .unwrap()
406 .insert((id.clone(), upload_id.clone()), upload);
407 Ok(upload_id)
408 }
409
410 async fn upload_part(
411 &self,
412 id: &ObjectId,
413 upload_id: &UploadId,
414 part_number: PartNumber,
415 _content_length: u64,
416 _content_md5: Option<&str>,
417 body: ClientStream,
418 ) -> Result<UploadPartResponse> {
419 let data: BytesMut = body.try_collect().await?;
420 let data = data.freeze();
421 let etag = format!("\"etag-{part_number}-{}\"", data.len());
422
423 let mut store = self.multipart_store.lock().unwrap();
424 let upload = store
425 .get_mut(&(id.clone(), upload_id.clone()))
426 .ok_or_else(|| {
427 Error::new(
428 ErrorKind::BackendFailure,
429 "in-memory multipart upload not found",
430 )
431 })?;
432
433 upload.parts.insert(
434 part_number,
435 UploadedPart {
436 etag: etag.clone(),
437 data,
438 uploaded_at: SystemTime::now(),
439 },
440 );
441
442 Ok(etag)
443 }
444
445 async fn list_parts(
446 &self,
447 id: &ObjectId,
448 upload_id: &UploadId,
449 max_parts: Option<u32>,
450 part_number_marker: Option<PartNumber>,
451 ) -> Result<ListPartsResponse> {
452 let store = self.multipart_store.lock().unwrap();
453 let upload = store.get(&(id.clone(), upload_id.clone())).ok_or_else(|| {
454 Error::new(
455 ErrorKind::BackendFailure,
456 "in-memory multipart upload not found",
457 )
458 })?;
459
460 let iter = upload
461 .parts
462 .iter()
463 .filter(|(pn, _)| part_number_marker.is_none_or(|marker| **pn > marker));
464
465 let max = max_parts.unwrap_or(u32::MAX) as usize;
466 let all: Vec<_> = iter.collect();
467 let is_truncated = all.len() > max;
468 let page: Vec<_> = all.into_iter().take(max).collect();
469
470 let next_part_number_marker = if is_truncated {
471 page.last().map(|(pn, _)| **pn)
472 } else {
473 None
474 };
475
476 let parts = page
477 .into_iter()
478 .map(|(pn, part)| Part {
479 part_number: *pn,
480 etag: part.etag.clone(),
481 last_modified: part.uploaded_at,
482 size: part.data.len() as u64,
483 })
484 .collect();
485
486 Ok(ListPartsResponse {
487 parts,
488 is_truncated,
489 next_part_number_marker,
490 })
491 }
492
493 async fn abort_multipart(
494 &self,
495 id: &ObjectId,
496 upload_id: &UploadId,
497 ) -> Result<AbortMultipartResponse> {
498 self.multipart_store
499 .lock()
500 .unwrap()
501 .remove(&(id.clone(), upload_id.clone()));
502 Ok(())
503 }
504
505 async fn complete_multipart(
506 &self,
507 id: &ObjectId,
508 upload_id: &UploadId,
509 parts: Vec<CompletedPart>,
510 _access_time: Timestamp,
511 ) -> Result<CompleteMultipartResponse> {
512 let key = (id.clone(), upload_id.clone());
513
514 let (metadata, payload) = {
521 let store = self.multipart_store.lock().unwrap();
522 let upload = store.get(&key).ok_or_else(|| {
523 Error::new(
524 ErrorKind::BackendFailure,
525 "in-memory multipart upload not found",
526 )
527 })?;
528
529 for completed in &parts {
530 match upload.parts.get(&completed.part_number) {
531 None => {
532 return Ok(Some(crate::multipart::CompleteMultipartError {
533 code: "InvalidPart".into(),
534 message: format!(
535 "part number {} was not uploaded",
536 completed.part_number
537 ),
538 }));
539 }
540 Some(stored) if stored.etag != completed.etag => {
541 return Ok(Some(crate::multipart::CompleteMultipartError {
542 code: "InvalidPart".into(),
543 message: format!(
544 "etag mismatch for part {}: expected {}, got {}",
545 completed.part_number, stored.etag, completed.etag
546 ),
547 }));
548 }
549 _ => {}
550 }
551 }
552
553 let mut payload = BytesMut::new();
554 for completed in &parts {
555 let stored = &upload.parts[&completed.part_number];
556 payload.extend_from_slice(&stored.data);
557 }
558
559 let mut metadata = upload.metadata.clone();
560 metadata.size = Some(payload.len());
561
562 (metadata, payload.freeze())
563 };
564
565 let expires_at = metadata.time_expires;
566 let entry = StoreEntry::Object(metadata, payload);
567 let size = entry.stored_size();
568 self.store.lock().unwrap().insert(id.clone(), entry);
569 self.change_stream.write(id, size as u64, expires_at);
570
571 self.multipart_store.lock().unwrap().remove(&key);
572
573 Ok(None)
574 }
575}
576
577fn json_len<T: serde::Serialize>(value: &T) -> usize {
579 serde_json::to_string(value).map_or(0, |json| json.len())
580}
581
582fn matches_redirect(
587 entry: Option<&StoreEntry>,
588 expected: Option<&ObjectId>,
589 now: Timestamp,
590) -> bool {
591 match entry {
592 None | Some(StoreEntry::Object(..)) => expected.is_none(),
593 Some(StoreEntry::Tombstone(tombstone)) => match expected {
594 None => tombstone.is_expired(now),
595 Some(target) => tombstone.target == *target && !tombstone.is_expired(now),
596 },
597 }
598}
599
600#[derive(Clone, Copy, Debug, PartialEq, Eq)]
606enum ExpiryOutcome {
607 Rejected,
609 AlreadySatisfied,
611 Extended,
613}
614
615impl ExpiryOutcome {
616 fn is_satisfied(self) -> bool {
618 !matches!(self, Self::Rejected)
619 }
620}
621
622fn extend_expiry(
624 field: &mut Option<Timestamp>,
625 expire_at: Timestamp,
626 now: Timestamp,
627) -> ExpiryOutcome {
628 let Some(time_expires) = *field else {
629 return ExpiryOutcome::Rejected; };
631
632 if time_expires < now {
633 ExpiryOutcome::Rejected } else if time_expires >= expire_at {
635 ExpiryOutcome::AlreadySatisfied
636 } else {
637 *field = Some(expire_at);
638 ExpiryOutcome::Extended
639 }
640}
641
642#[derive(Clone, Debug)]
644pub enum Entry {
645 NotFound,
647 Object(Metadata, Bytes),
649 Tombstone(Tombstone),
651}
652
653impl Entry {
654 pub fn is_not_found(&self) -> bool {
656 matches!(self, Entry::NotFound)
657 }
658
659 pub fn is_object(&self) -> bool {
661 matches!(self, Entry::Object(_, _))
662 }
663
664 pub fn is_tombstone(&self) -> bool {
666 matches!(self, Entry::Tombstone(_))
667 }
668
669 pub fn expect_not_found(&self) {
671 match self {
672 Entry::NotFound => (),
673 _ => panic!("expected not found entry, got {self:?}"),
674 }
675 }
676
677 pub fn expect_object(&self) -> (Metadata, Bytes) {
679 match self {
680 Entry::Object(metadata, bytes) => (metadata.clone(), bytes.clone()),
681 _ => panic!("expected object entry, got {self:?}"),
682 }
683 }
684
685 pub fn expect_tombstone(&self) -> Tombstone {
687 match self {
688 Entry::Tombstone(tombstone) => tombstone.clone(),
689 _ => panic!("expected tombstone entry, got {self:?}"),
690 }
691 }
692}
693
694#[cfg(test)]
695mod tests {
696 use std::num::NonZeroU32;
697 use std::time::Duration;
698
699 use objectstore_types::metadata::ExpirationPolicy;
700 use objectstore_types::scope::{Scope, Scopes};
701
702 #[cfg(feature = "storage-cogs")]
703 use objectstore_inventory_tracker::OpType;
704 #[cfg(feature = "storage-cogs")]
705 use objectstore_inventory_tracker::test_utils::DummyProducer;
706
707 use super::*;
708 use crate::backend::common::Backend;
709 use crate::id::ObjectContext;
710 use crate::stream;
711
712 fn make_id() -> ObjectId {
713 ObjectId::random(ObjectContext {
714 usecase: "testing".into(),
715 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
716 })
717 }
718
719 #[tokio::test]
720 async fn set_expiry() {
721 let access_time = Timestamp::from_unix_secs(1_700_000_000).unwrap();
722 for policy in [
723 ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
724 ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
725 ] {
726 let backend = InMemoryBackend::new("test");
727 let id = make_id();
728 let original_expiry = access_time + Duration::from_hours(1);
729 let metadata = Metadata {
730 expiration_policy: policy,
731 time_expires: Some(original_expiry),
732 custom: [("preserved".into(), "yes".into())].into(),
733 ..Default::default()
734 };
735 backend
736 .put_object(&id, &metadata, stream::single("payload"), access_time)
737 .await
738 .unwrap();
739
740 let requested = original_expiry + Duration::from_hours(1) + Duration::from_nanos(999);
741 assert!(
742 backend
743 .set_expiry(&id, requested, access_time)
744 .await
745 .unwrap()
746 );
747 let (updated, payload) = backend.get(&id).expect_object();
748 assert_eq!(updated.expiration_policy, policy);
749 assert_eq!(updated.custom, metadata.custom);
750 assert_eq!(payload, Bytes::from_static(b"payload"));
751 assert_eq!(updated.time_expires, Some(requested));
752
753 assert!(
754 backend
755 .set_expiry(&id, original_expiry, access_time)
756 .await
757 .unwrap()
758 );
759 assert_eq!(
760 backend.get(&id).expect_object().0.time_expires,
761 updated.time_expires
762 );
763 }
764 }
765
766 #[tokio::test]
767 async fn set_expiry_rejected() {
768 let access_time = Timestamp::from_unix_secs(1_700_000_000).unwrap();
769 let backend = InMemoryBackend::new("test");
770 let absent = make_id();
771 assert!(
772 !backend
773 .set_expiry(&absent, access_time + Duration::from_hours(1), access_time)
774 .await
775 .unwrap()
776 );
777
778 let manual = make_id();
779 backend
780 .put_object(
781 &manual,
782 &Metadata::default(),
783 stream::single("manual"),
784 access_time,
785 )
786 .await
787 .unwrap();
788 assert!(
789 !backend
790 .set_expiry(&manual, access_time + Duration::from_hours(1), access_time)
791 .await
792 .unwrap()
793 );
794
795 let expired = make_id();
796 let metadata = Metadata {
797 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
798 time_expires: Some(access_time - Duration::from_secs(1)),
799 ..Default::default()
800 };
801 backend
802 .put_object(&expired, &metadata, stream::single("expired"), access_time)
803 .await
804 .unwrap();
805 let deadline = metadata.time_expires.unwrap();
806 for time in [deadline - Duration::from_secs(1), deadline] {
807 assert!(
808 backend
809 .get_object(&expired, time, None)
810 .await
811 .unwrap()
812 .is_some()
813 );
814 }
815 assert!(
816 backend
817 .get_object(&expired, access_time, None)
818 .await
819 .unwrap()
820 .is_none()
821 );
822 assert!(
823 !backend
824 .set_expiry(&expired, access_time + Duration::from_hours(1), access_time)
825 .await
826 .unwrap()
827 );
828 }
829
830 #[tokio::test]
831 async fn redirect_expiry() {
832 let access_time = Timestamp::from_unix_secs(1_700_000_000).unwrap();
833 let backend = InMemoryBackend::new("test");
834 let id = make_id();
835 let target = make_id();
836 let other = make_id();
837 let old_expiry = access_time + Duration::from_hours(1);
838 backend
839 .compare_and_write(
840 &id,
841 None,
842 TieredWrite::Tombstone(Tombstone {
843 target: target.clone(),
844 time_expires: Some(old_expiry),
845 }),
846 access_time,
847 )
848 .await
849 .unwrap();
850
851 assert!(
852 backend
853 .get_object(&id, access_time, None)
854 .await
855 .is_err_and(|error| error.kind() == ErrorKind::UnexpectedTombstone)
856 );
857 assert!(
858 backend
859 .get_metadata(&id, access_time)
860 .await
861 .is_err_and(|error| error.kind() == ErrorKind::UnexpectedTombstone)
862 );
863
864 let new_expiry = old_expiry + Duration::from_hours(1);
865 assert!(
866 !backend
867 .compare_and_update(
868 &id,
869 Some(&other),
870 TieredUpdate::SetExpiry(new_expiry),
871 access_time,
872 )
873 .await
874 .unwrap()
875 );
876 assert_eq!(
877 backend.get(&id).expect_tombstone().time_expires,
878 Some(old_expiry)
879 );
880 assert!(
881 backend
882 .compare_and_update(
883 &id,
884 Some(&target),
885 TieredUpdate::SetExpiry(new_expiry),
886 access_time
887 )
888 .await
889 .unwrap()
890 );
891 assert_eq!(
892 backend.get(&id).expect_tombstone().time_expires,
893 Some(new_expiry)
894 );
895 }
896
897 #[tokio::test]
898 async fn multipart_single_part() {
899 let backend = InMemoryBackend::new("test");
900 let id = make_id();
901 let metadata = Metadata {
902 content_type: "text/plain".into(),
903 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
904 origin: Some("203.0.113.42".into()),
905 custom: [("foo".into(), "bar".into())].into(),
906 ..Default::default()
907 };
908
909 let upload_id = backend.initiate_multipart(&id, &metadata).await.unwrap();
910
911 let data = b"hello, multipart world!";
912 let etag = backend
913 .upload_part(
914 &id,
915 &upload_id,
916 NonZeroU32::new(1).unwrap(),
917 data.len() as u64,
918 None,
919 stream::single(data.to_vec()),
920 )
921 .await
922 .unwrap();
923
924 let result = backend
925 .complete_multipart(
926 &id,
927 &upload_id,
928 vec![CompletedPart {
929 part_number: NonZeroU32::new(1).unwrap(),
930 etag,
931 }],
932 Timestamp::now(),
933 )
934 .await
935 .unwrap();
936 assert!(result.is_none(), "expected no error on complete");
937
938 let (meta, _, body) = backend
939 .get_object(&id, Timestamp::now(), None)
940 .await
941 .unwrap()
942 .unwrap();
943 let payload = stream::read_to_vec(body).await.unwrap();
944 assert_eq!(payload, data);
945 assert_eq!(meta.content_type, "text/plain".to_string());
946 assert_eq!(
947 meta.expiration_policy,
948 ExpirationPolicy::TimeToIdle(Duration::from_hours(1))
949 );
950 assert_eq!(meta.origin, Some("203.0.113.42".into()));
951 assert_eq!(meta.custom, [("foo".into(), "bar".into())].into());
952 }
953
954 #[tokio::test]
955 async fn multipart_multiple_parts() {
956 let backend = InMemoryBackend::new("test");
957 let id = make_id();
958 let metadata = Metadata::default();
959
960 let upload_id = backend.initiate_multipart(&id, &metadata).await.unwrap();
961
962 let part1 = b"aaaa".to_vec();
963 let part2 = b"bbbb".to_vec();
964 let part3 = b"cc".to_vec();
965
966 let etag1 = backend
967 .upload_part(
968 &id,
969 &upload_id,
970 NonZeroU32::new(1).unwrap(),
971 part1.len() as u64,
972 None,
973 stream::single(part1.clone()),
974 )
975 .await
976 .unwrap();
977 let etag2 = backend
978 .upload_part(
979 &id,
980 &upload_id,
981 NonZeroU32::new(2).unwrap(),
982 part2.len() as u64,
983 None,
984 stream::single(part2.clone()),
985 )
986 .await
987 .unwrap();
988 let etag3 = backend
989 .upload_part(
990 &id,
991 &upload_id,
992 NonZeroU32::new(3).unwrap(),
993 part3.len() as u64,
994 None,
995 stream::single(part3.clone()),
996 )
997 .await
998 .unwrap();
999
1000 let result = backend
1001 .complete_multipart(
1002 &id,
1003 &upload_id,
1004 vec![
1005 CompletedPart {
1006 part_number: NonZeroU32::new(1).unwrap(),
1007 etag: etag1,
1008 },
1009 CompletedPart {
1010 part_number: NonZeroU32::new(2).unwrap(),
1011 etag: etag2,
1012 },
1013 CompletedPart {
1014 part_number: NonZeroU32::new(3).unwrap(),
1015 etag: etag3,
1016 },
1017 ],
1018 Timestamp::now(),
1019 )
1020 .await
1021 .unwrap();
1022 assert!(result.is_none());
1023
1024 let (_, _, body) = backend
1025 .get_object(&id, Timestamp::now(), None)
1026 .await
1027 .unwrap()
1028 .unwrap();
1029 let payload = stream::read_to_vec(body).await.unwrap();
1030 assert_eq!(payload, b"aaaabbbbcc");
1031 }
1032
1033 #[tokio::test]
1034 async fn multipart_list_parts() {
1035 let backend = InMemoryBackend::new("test");
1036 let id = make_id();
1037 let metadata = Metadata::default();
1038
1039 let upload_id = backend.initiate_multipart(&id, &metadata).await.unwrap();
1040
1041 let etag1 = backend
1042 .upload_part(
1043 &id,
1044 &upload_id,
1045 NonZeroU32::new(1).unwrap(),
1046 3,
1047 None,
1048 stream::single(b"aaa".to_vec()),
1049 )
1050 .await
1051 .unwrap();
1052 let etag2 = backend
1053 .upload_part(
1054 &id,
1055 &upload_id,
1056 NonZeroU32::new(2).unwrap(),
1057 3,
1058 None,
1059 stream::single(b"bbb".to_vec()),
1060 )
1061 .await
1062 .unwrap();
1063
1064 let list = backend
1065 .list_parts(&id, &upload_id, None, None)
1066 .await
1067 .unwrap();
1068 assert_eq!(list.parts.len(), 2);
1069 assert_eq!(list.parts[0].part_number.get(), 1);
1070 assert_eq!(list.parts[0].etag, etag1);
1071 assert_eq!(list.parts[0].size, 3);
1072 assert_eq!(list.parts[1].part_number.get(), 2);
1073 assert_eq!(list.parts[1].etag, etag2);
1074 assert_eq!(list.parts[1].size, 3);
1075
1076 let page1 = backend
1078 .list_parts(&id, &upload_id, Some(1), None)
1079 .await
1080 .unwrap();
1081 assert_eq!(page1.parts.len(), 1);
1082 assert_eq!(page1.parts[0].part_number.get(), 1);
1083 assert!(page1.is_truncated);
1084 assert!(page1.next_part_number_marker.is_some());
1085
1086 let page2 = backend
1087 .list_parts(&id, &upload_id, Some(1), page1.next_part_number_marker)
1088 .await
1089 .unwrap();
1090 assert_eq!(page2.parts.len(), 1);
1091 assert_eq!(page2.parts[0].part_number.get(), 2);
1092
1093 backend.abort_multipart(&id, &upload_id).await.unwrap();
1094 }
1095
1096 #[tokio::test]
1097 async fn multipart_abort() {
1098 let backend = InMemoryBackend::new("test");
1099 let id = make_id();
1100 let metadata = Metadata::default();
1101
1102 let upload_id = backend.initiate_multipart(&id, &metadata).await.unwrap();
1103
1104 backend
1105 .upload_part(
1106 &id,
1107 &upload_id,
1108 NonZeroU32::new(1).unwrap(),
1109 5,
1110 None,
1111 stream::single(b"hello".to_vec()),
1112 )
1113 .await
1114 .unwrap();
1115
1116 backend.abort_multipart(&id, &upload_id).await.unwrap();
1117
1118 let result = backend
1119 .get_object(&id, Timestamp::now(), None)
1120 .await
1121 .unwrap();
1122 assert!(result.is_none(), "object should not exist after abort");
1123 }
1124
1125 #[tokio::test]
1126 async fn multipart_invalid_etag() {
1127 let backend = InMemoryBackend::new("test");
1128 let id = make_id();
1129 let metadata = Metadata::default();
1130
1131 let upload_id = backend.initiate_multipart(&id, &metadata).await.unwrap();
1132
1133 let etag = backend
1134 .upload_part(
1135 &id,
1136 &upload_id,
1137 NonZeroU32::new(1).unwrap(),
1138 5,
1139 None,
1140 stream::single(b"hello".to_vec()),
1141 )
1142 .await
1143 .unwrap();
1144
1145 let result = backend
1146 .complete_multipart(
1147 &id,
1148 &upload_id,
1149 vec![CompletedPart {
1150 part_number: NonZeroU32::new(1).unwrap(),
1151 etag: "wrong-etag".into(),
1152 }],
1153 Timestamp::now(),
1154 )
1155 .await
1156 .unwrap();
1157 assert!(result.is_some(), "expected error for bad etag");
1158 assert_eq!(result.unwrap().code, "InvalidPart");
1159
1160 let result = backend
1162 .complete_multipart(
1163 &id,
1164 &upload_id,
1165 vec![CompletedPart {
1166 part_number: NonZeroU32::new(1).unwrap(),
1167 etag,
1168 }],
1169 Timestamp::now(),
1170 )
1171 .await
1172 .unwrap();
1173 assert!(result.is_none(), "retry with correct etag should succeed");
1174 }
1175
1176 #[tokio::test]
1177 async fn multipart_missing_part() {
1178 let backend = InMemoryBackend::new("test");
1179 let id = make_id();
1180 let metadata = Metadata::default();
1181
1182 let upload_id = backend.initiate_multipart(&id, &metadata).await.unwrap();
1183
1184 let etag = backend
1185 .upload_part(
1186 &id,
1187 &upload_id,
1188 NonZeroU32::new(1).unwrap(),
1189 5,
1190 None,
1191 stream::single(b"hello".to_vec()),
1192 )
1193 .await
1194 .unwrap();
1195
1196 let result = backend
1197 .complete_multipart(
1198 &id,
1199 &upload_id,
1200 vec![CompletedPart {
1201 part_number: NonZeroU32::new(99).unwrap(),
1202 etag: "whatever".into(),
1203 }],
1204 Timestamp::now(),
1205 )
1206 .await
1207 .unwrap();
1208 assert!(result.is_some(), "expected error for missing part");
1209 assert_eq!(result.unwrap().code, "InvalidPart");
1210
1211 let result = backend
1213 .complete_multipart(
1214 &id,
1215 &upload_id,
1216 vec![CompletedPart {
1217 part_number: NonZeroU32::new(1).unwrap(),
1218 etag,
1219 }],
1220 Timestamp::now(),
1221 )
1222 .await
1223 .unwrap();
1224 assert!(result.is_none(), "retry with correct part should succeed");
1225 }
1226
1227 #[cfg(feature = "storage-cogs")]
1228 fn backend_with_change_stream() -> (InMemoryBackend, DummyProducer) {
1229 use crate::change_stream::CostTrackerStreamConfig;
1230
1231 let (streams, producer) = crate::change_stream::dummy_factory();
1232 let change_stream = streams.build(Some(&CostTrackerStreamConfig {
1233 shared_resource_id: "in_memory_objectstore".into(),
1234 sample_rate: 1.0,
1235 }));
1236 (
1237 InMemoryBackend::new("test").with_change_stream(change_stream),
1238 producer,
1239 )
1240 }
1241
1242 #[cfg(feature = "storage-cogs")]
1243 #[tokio::test]
1244 async fn change_stream_reports_writes_and_deletes() {
1245 let (backend, producer) = backend_with_change_stream();
1246 let id = make_id();
1247 let metadata = Metadata::default();
1248 let payload = b"hello";
1249
1250 backend
1251 .put_object(
1252 &id,
1253 &metadata,
1254 stream::single(payload.to_vec()),
1255 Timestamp::now(),
1256 )
1257 .await
1258 .unwrap();
1259 backend.delete_object(&id, Timestamp::now()).await.unwrap();
1260 backend.delete_object(&id, Timestamp::now()).await.unwrap();
1262
1263 let records = producer.records();
1264 assert_eq!(records.len(), 2);
1265 assert_eq!(records[0].op_type, OpType::Write);
1266 assert_eq!(records[0].app_feature, "testing");
1267 assert_eq!(
1268 records[0].size,
1269 Some((json_len(&metadata) + payload.len()) as u64),
1270 "the reported size covers metadata as well as the payload"
1271 );
1272 assert!(json_len(&metadata) > 0, "metadata must contribute bytes");
1273 assert_eq!(records[1].op_type, OpType::Delete);
1274 }
1275
1276 #[cfg(feature = "storage-cogs")]
1277 #[tokio::test]
1278 async fn change_stream_reports_expiry_extension_as_update() {
1279 let (backend, producer) = backend_with_change_stream();
1280 let id = make_id();
1281 let access_time = Timestamp::now();
1282 let expires = access_time + Duration::from_secs(3600);
1283 let metadata = Metadata {
1284 time_expires: Some(expires),
1285 ..Default::default()
1286 };
1287
1288 backend
1289 .put_object(
1290 &id,
1291 &metadata,
1292 stream::single(b"hello".to_vec()),
1293 access_time,
1294 )
1295 .await
1296 .unwrap();
1297 producer.clear();
1298
1299 let extended = expires + Duration::from_secs(3600);
1300 assert!(
1301 backend
1302 .set_expiry(&id, extended, access_time)
1303 .await
1304 .unwrap()
1305 );
1306
1307 let records = producer.records();
1308 assert_eq!(records.len(), 1);
1309 assert_eq!(records[0].op_type, OpType::Update);
1310
1311 producer.clear();
1313 assert!(
1314 backend
1315 .set_expiry(&id, expires + Duration::from_secs(60), access_time)
1316 .await
1317 .unwrap()
1318 );
1319 assert!(producer.records().is_empty());
1320 }
1321
1322 #[cfg(feature = "storage-cogs")]
1323 #[tokio::test]
1324 async fn change_stream_reports_tombstone_expiry_extension() {
1325 let (backend, producer) = backend_with_change_stream();
1326 let id = make_id();
1327 let target = make_id();
1328 let access_time = Timestamp::now();
1329 let expires = access_time + Duration::from_secs(3600);
1330
1331 backend
1332 .compare_and_write(
1333 &id,
1334 None,
1335 TieredWrite::Tombstone(Tombstone {
1336 target: target.clone(),
1337 time_expires: Some(expires),
1338 }),
1339 access_time,
1340 )
1341 .await
1342 .unwrap();
1343 producer.clear();
1344
1345 let extended = expires + Duration::from_secs(3600);
1346 assert!(
1347 backend
1348 .compare_and_update(
1349 &id,
1350 Some(&target),
1351 TieredUpdate::SetExpiry(extended),
1352 access_time,
1353 )
1354 .await
1355 .unwrap()
1356 );
1357
1358 let records = producer.records();
1359 assert_eq!(records.len(), 1);
1360 assert_eq!(records[0].op_type, OpType::Update);
1361 }
1362}