1use std::collections::HashMap;
9use std::num::NonZeroU64;
10use std::sync::{Arc, Mutex};
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 crate::backend::common::{
20 self, DeleteResponse, ExpiryUpdate, GetResponse, HighVolumeBackend, PutResponse,
21 SetExpiryResponse, TieredGet, TieredMetadata, TieredUpdate, TieredWrite, Tombstone,
22};
23use crate::change_stream::{ChangeStream, NoopStream, flush_change_stream};
24use crate::error::{ErrorKind, Result};
25use crate::id::ObjectId;
26use crate::resumable::{BackendToken, Session, UploadProgress};
27use crate::stream::ClientStream;
28
29#[derive(Clone, Debug)]
31enum StoreEntry {
32 Object(Metadata, Bytes),
33 Tombstone(Tombstone),
34}
35
36impl StoreEntry {
37 fn is_expired(&self, now: Timestamp) -> bool {
38 match self {
39 StoreEntry::Object(metadata, _) => metadata.is_expired(now),
40 StoreEntry::Tombstone(tombstone) => tombstone.is_expired(now),
41 }
42 }
43
44 pub(crate) fn stored_size(&self) -> usize {
46 match self {
47 StoreEntry::Object(metadata, payload) => json_len(metadata) + payload.len(),
48 StoreEntry::Tombstone(tombstone) => {
51 tombstone.target.as_storage_path().to_string().len()
52 + tombstone.time_expires.map_or(0, |d| json_len(&d))
53 }
54 }
55 }
56}
57
58type Store = HashMap<ObjectId, StoreEntry>;
59
60#[derive(Debug)]
61struct ResumableUpload {
62 metadata: Metadata,
63 data: BytesMut,
64}
65
66type ResumableSession = Arc<tokio::sync::Mutex<Option<ResumableUpload>>>;
68type ResumableStore = HashMap<(ObjectId, BackendToken), ResumableSession>;
69
70#[derive(Debug, Clone)]
76pub struct InMemoryBackend {
77 name: &'static str,
78 store: Arc<Mutex<Store>>,
79 resumable_store: Arc<Mutex<ResumableStore>>,
80 upload_markers: Arc<Mutex<HashMap<ObjectId, Timestamp>>>,
81 change_stream: Arc<dyn ChangeStream>,
82}
83
84impl InMemoryBackend {
85 pub fn new(name: &'static str) -> Self {
87 Self {
88 name,
89 store: Arc::new(Mutex::new(HashMap::new())),
90 resumable_store: Arc::new(Mutex::new(HashMap::new())),
91 upload_markers: Arc::new(Mutex::new(HashMap::new())),
92 change_stream: Arc::new(NoopStream),
93 }
94 }
95
96 fn upload_session(&self, session: &Session) -> Result<ResumableSession> {
97 self.resumable_store
98 .lock()
99 .unwrap()
100 .get(&(session.object_id.clone(), session.backend_token.clone()))
101 .cloned()
103 .ok_or_else(|| ErrorKind::UnknownUploadSession.into())
104 }
105
106 pub fn with_change_stream(mut self, change_stream: Arc<dyn ChangeStream>) -> Self {
108 self.change_stream = change_stream;
109 self
110 }
111
112 async fn update_expiry(
116 &self,
117 id: &ObjectId,
118 access_time: Timestamp,
119 extend: impl Fn(&mut StoreEntry) -> Result<ExpiryOutcome> + Send + Sync,
120 ) -> Result<SetExpiryResponse> {
121 let try_extend = |store: &Store| -> Result<(ExpiryOutcome, Option<StoreEntry>)> {
122 match store.get(id) {
123 Some(entry) if !entry.is_expired(access_time) => {
124 let mut updated = entry.clone();
125 let outcome = extend(&mut updated)?;
126 Ok((outcome, Some(updated)))
127 }
128 _ => Ok((ExpiryOutcome::NotFound, None)),
129 }
130 };
131
132 let (outcome, _) = try_extend(&self.store.lock().unwrap())?;
133 let ExpiryOutcome::Extended(expire_at) = outcome else {
134 return Ok(outcome.response());
135 };
136 self.change_stream.begin_update(id, Some(expire_at)).await?;
137
138 {
139 let mut store = self.store.lock().unwrap();
140 match try_extend(&store)? {
141 (ExpiryOutcome::Extended(t), Some(updated)) if t == expire_at => {
142 store.insert(id.clone(), updated);
143 }
144 _ => return Ok(SetExpiryResponse::Rejected),
145 }
146 }
147 self.change_stream.commit_update(id, Some(expire_at)).await;
148 Ok(outcome.response())
149 }
150
151 pub fn get(&self, id: &ObjectId) -> Entry {
153 match self.store.lock().unwrap().get(id).cloned() {
154 None => Entry::NotFound,
155 Some(StoreEntry::Tombstone(tombstone)) => Entry::Tombstone(tombstone),
156 Some(StoreEntry::Object(metadata, bytes)) => Entry::Object(metadata, bytes),
157 }
158 }
159
160 pub fn contains(&self, id: &ObjectId) -> bool {
162 self.store.lock().unwrap().contains_key(id)
163 }
164
165 pub fn is_empty(&self) -> bool {
167 self.store.lock().unwrap().is_empty()
168 }
169
170 pub fn remove(&self, id: &ObjectId) {
174 self.store.lock().unwrap().remove(id);
175 }
176}
177
178#[async_trait::async_trait]
179impl super::common::Backend for InMemoryBackend {
180 fn name(&self) -> &'static str {
181 self.name
182 }
183
184 async fn put_object(
185 &self,
186 id: &ObjectId,
187 metadata: &Metadata,
188 stream: ClientStream,
189 _access_time: Timestamp,
190 ) -> Result<PutResponse> {
191 self.change_stream
192 .begin_write(id, metadata.time_expires)
193 .await?;
194 let bytes: BytesMut = stream.try_collect().await?;
195 let entry = StoreEntry::Object(metadata.clone(), bytes.freeze());
196 let size = entry.stored_size();
197 self.store.lock().unwrap().insert(id.clone(), entry);
198 self.change_stream
199 .commit_write(id, size as u64, metadata.time_expires)
200 .await;
201 Ok(())
202 }
203
204 async fn get_object(
205 &self,
206 id: &ObjectId,
207 access_time: Timestamp,
208 range: Option<ByteRange>,
209 ) -> Result<GetResponse> {
210 let entry = self.store.lock().unwrap().get(id).cloned();
211 match entry {
212 None => Ok(None),
213 Some(entry) if entry.is_expired(access_time) => Ok(None),
214 Some(StoreEntry::Tombstone(_)) => Err(ErrorKind::UnexpectedTombstone.into()),
215 Some(StoreEntry::Object(mut metadata, bytes)) => {
216 let total = bytes.len() as u64;
217 metadata.size = Some(bytes.len());
218 let (content_range, payload) = match range {
219 Some(range) => {
220 let content_range = range
221 .resolve(total)
222 .ok_or(ErrorKind::RangeNotSatisfiable { total })?;
223 let sliced =
224 bytes.slice(content_range.start as usize..=content_range.end as usize);
225 (Some(content_range), sliced)
226 }
227 None => (None, bytes),
228 };
229 Ok(Some((
230 metadata,
231 content_range,
232 crate::stream::single(payload),
233 )))
234 }
235 }
236 }
237
238 async fn set_expiry(
239 &self,
240 id: &ObjectId,
241 target: ExpiryUpdate,
242 access_time: Timestamp,
243 ) -> Result<SetExpiryResponse> {
244 self.update_expiry(id, access_time, |entry| match entry {
245 StoreEntry::Object(metadata, _) => extend_object_expiry(metadata, target, access_time),
246 StoreEntry::Tombstone(_) => Ok(ExpiryOutcome::Rejected),
247 })
248 .await
249 }
250
251 async fn delete_object(
252 &self,
253 id: &ObjectId,
254 _access_time: Timestamp,
255 ) -> Result<DeleteResponse> {
256 if self.store.lock().unwrap().remove(id).is_some() {
257 self.change_stream.commit_delete(id).await;
258 }
259 Ok(())
260 }
261
262 async fn create_upload_session(
263 &self,
264 id: &ObjectId,
265 metadata: &Metadata,
266 _upload_length: NonZeroU64,
267 ) -> Result<Option<BackendToken>> {
268 self.change_stream
269 .begin_write(id, metadata.time_expires)
270 .await?;
271 let token = uuid::Uuid::now_v7().to_string();
272 let upload = ResumableUpload {
273 metadata: metadata.clone(),
274 data: BytesMut::new(),
275 };
276 self.resumable_store.lock().unwrap().insert(
277 (id.clone(), token.clone()),
278 Arc::new(tokio::sync::Mutex::new(Some(upload))),
279 );
280 Ok(Some(token))
281 }
282
283 async fn put_chunk(
284 &self,
285 session: &Session,
286 offset: u64,
287 content_length: u64,
288 mut stream: ClientStream,
289 ) -> Result<UploadProgress> {
290 let upload_session = self.upload_session(session)?;
291 let mut guard = upload_session.lock().await;
292 let upload = guard.as_mut().ok_or(ErrorKind::UnknownUploadSession)?;
293 offset
294 .checked_add(content_length)
295 .filter(|end| *end <= session.upload_length.get())
296 .ok_or(ErrorKind::ChunkExceedsUploadLength {
297 offset,
298 content_length,
299 upload_length: session.upload_length.get(),
300 })?;
301 if content_length != 0 && offset != upload.data.len() as u64 {
302 return Err(ErrorKind::UploadOffsetMismatch {
303 offset: upload.data.len() as u64,
304 }
305 .into());
306 }
307
308 let mut remaining = content_length;
309 while remaining > 0 {
310 let Some(chunk) = stream.try_next().await? else {
311 break;
312 };
313 let count = remaining.min(chunk.len() as u64) as usize;
314 upload.data.extend_from_slice(&chunk[..count]);
315 remaining -= count as u64;
316 }
317
318 let offset = upload.data.len() as u64;
319 if offset != session.upload_length.get() {
320 return Ok(UploadProgress::Incomplete { offset });
321 }
322
323 let upload = guard.take().unwrap();
324 let metadata = upload.metadata;
325 let expires_at = metadata.time_expires;
326 let entry = StoreEntry::Object(metadata, upload.data.freeze());
327 let size = entry.stored_size();
328 self.store
329 .lock()
330 .unwrap()
331 .insert(session.object_id.clone(), entry);
332
333 self.change_stream
334 .commit_write(&session.object_id, size as u64, expires_at)
335 .await;
336 self.resumable_store
337 .lock()
338 .unwrap()
339 .remove(&(session.object_id.clone(), session.backend_token.clone()));
340
341 Ok(UploadProgress::Complete)
342 }
343
344 async fn upload_offset(&self, session: &Session) -> Result<UploadProgress> {
345 let upload_session = self.upload_session(session)?;
346 let guard = upload_session.lock().await;
347 let upload = guard.as_ref().ok_or(ErrorKind::UnknownUploadSession)?;
348 Ok(UploadProgress::Incomplete {
349 offset: upload.data.len() as u64,
350 })
351 }
352
353 async fn cancel_upload(&self, session: &Session) -> Result<()> {
354 let upload_session = self.upload_session(session)?;
355 let mut guard = upload_session.lock().await;
356 guard.take().ok_or(ErrorKind::UnknownUploadSession)?;
357 self.resumable_store
358 .lock()
359 .unwrap()
360 .remove(&(session.object_id.clone(), session.backend_token.clone()));
361 Ok(())
362 }
363
364 async fn join(&self) {
365 flush_change_stream(&self.change_stream).await;
366 }
367}
368
369#[async_trait::async_trait]
370impl HighVolumeBackend for InMemoryBackend {
371 async fn create_upload_marker(
372 &self,
373 revision: &ObjectId,
374 time_expires: Timestamp,
375 ) -> Result<()> {
376 self.change_stream
377 .begin_write(revision, Some(time_expires))
378 .await?;
379 self.upload_markers
380 .lock()
381 .unwrap()
382 .insert(revision.clone(), time_expires);
383 self.change_stream
384 .commit_write(revision, 1, Some(time_expires))
385 .await;
386 Ok(())
387 }
388
389 async fn has_upload_marker(&self, revision: &ObjectId, access_time: Timestamp) -> Result<bool> {
390 Ok(self
391 .upload_markers
392 .lock()
393 .unwrap()
394 .get(revision)
395 .is_some_and(|expiry| *expiry >= access_time))
396 }
397
398 async fn delete_upload_marker(
399 &self,
400 revision: &ObjectId,
401 access_time: Timestamp,
402 ) -> Result<bool> {
403 let deleted = self
404 .upload_markers
405 .lock()
406 .unwrap()
407 .remove(revision)
408 .is_some_and(|expiry| expiry >= access_time);
409 if deleted {
410 self.change_stream.commit_delete(revision).await;
411 }
412 Ok(deleted)
413 }
414
415 async fn put_non_tombstone(
416 &self,
417 id: &ObjectId,
418 metadata: &Metadata,
419 payload: Bytes,
420 access_time: Timestamp,
421 ) -> Result<Option<Tombstone>> {
422 self.change_stream
423 .begin_write(id, metadata.time_expires)
424 .await?;
425 let (size, expires_at) = {
426 let mut store = self.store.lock().unwrap();
427 if let Some(StoreEntry::Tombstone(tombstone)) = store.get(id)
428 && !tombstone.is_expired(access_time)
429 {
430 return Ok(Some(tombstone.clone()));
431 }
432
433 let mut metadata = metadata.clone();
434 metadata.size = Some(payload.len());
435 let expires_at = metadata.time_expires;
436 let entry = StoreEntry::Object(metadata, payload);
437 let size = entry.stored_size();
438 store.insert(id.clone(), entry);
439 (size, expires_at)
440 };
441
442 self.change_stream
443 .commit_write(id, size as u64, expires_at)
444 .await;
445 Ok(None)
446 }
447
448 async fn get_tiered_object(
449 &self,
450 id: &ObjectId,
451 access_time: Timestamp,
452 range: Option<ByteRange>,
453 ) -> Result<TieredGet> {
454 let entry = self.store.lock().unwrap().get(id).cloned();
455 Ok(match entry {
456 None => TieredGet::NotFound,
457 Some(entry) if entry.is_expired(access_time) => TieredGet::NotFound,
458 Some(StoreEntry::Tombstone(tombstone)) => TieredGet::Tombstone(tombstone),
459 Some(StoreEntry::Object(mut metadata, bytes)) => {
460 let total = bytes.len() as u64;
461 metadata.size = Some(bytes.len());
462 let (content_range, payload) = match range {
463 Some(range) => {
464 let content_range = range
465 .resolve(total)
466 .ok_or(ErrorKind::RangeNotSatisfiable { total })?;
467 let sliced =
468 bytes.slice(content_range.start as usize..=content_range.end as usize);
469 (Some(content_range), sliced)
470 }
471 None => (None, bytes),
472 };
473 TieredGet::Object(metadata, content_range, crate::stream::single(payload))
474 }
475 })
476 }
477
478 async fn get_tiered_metadata(
479 &self,
480 id: &ObjectId,
481 access_time: Timestamp,
482 ) -> Result<TieredMetadata> {
483 let entry = self.store.lock().unwrap().get(id).cloned();
484 Ok(match entry {
485 None => TieredMetadata::NotFound,
486 Some(entry) if entry.is_expired(access_time) => TieredMetadata::NotFound,
487 Some(StoreEntry::Tombstone(tombstone)) => TieredMetadata::Tombstone(tombstone),
488 Some(StoreEntry::Object(metadata, _bytes)) => TieredMetadata::Object(metadata),
489 })
490 }
491
492 async fn delete_non_tombstone(
493 &self,
494 id: &ObjectId,
495 access_time: Timestamp,
496 ) -> Result<Option<Tombstone>> {
497 let removed = {
498 let mut store = self.store.lock().unwrap();
499 if let Some(StoreEntry::Tombstone(tombstone)) = store.get(id).cloned()
500 && !tombstone.is_expired(access_time)
501 {
502 return Ok(Some(tombstone));
503 }
504
505 store.remove(id).is_some()
506 };
507
508 if removed {
509 self.change_stream.commit_delete(id).await;
510 }
511 Ok(None)
512 }
513
514 async fn compare_and_update(
515 &self,
516 id: &ObjectId,
517 current: Option<&ObjectId>,
518 update: TieredUpdate,
519 access_time: Timestamp,
520 ) -> Result<SetExpiryResponse> {
521 let TieredUpdate::SetExpiry(expiry_target) = update;
522 self.update_expiry(id, access_time, |entry| match (entry, current) {
523 (StoreEntry::Object(metadata, _), None) => {
524 extend_object_expiry(metadata, expiry_target, access_time)
525 }
526 (StoreEntry::Tombstone(t), Some(target)) if t.target == *target => {
527 extend_expiry(&mut t.time_expires, expiry_target, None, access_time)
528 }
529 _ => Ok(ExpiryOutcome::Rejected),
530 })
531 .await
532 }
533
534 async fn compare_and_write(
535 &self,
536 id: &ObjectId,
537 current: Option<&ObjectId>,
538 write: TieredWrite,
539 access_time: Timestamp,
540 ) -> Result<bool> {
541 match &write {
542 TieredWrite::Tombstone(tombstone) => {
543 self.change_stream
544 .begin_write(id, tombstone.time_expires)
545 .await?;
546 }
547 TieredWrite::Object(metadata, _) => {
548 self.change_stream
549 .begin_write(id, metadata.time_expires)
550 .await?;
551 }
552 TieredWrite::Delete => {}
553 }
554
555 let mut written = None;
556 let mut deleted = false;
557 let matches = {
558 let mut store = self.store.lock().unwrap();
559
560 let actual = store.get(id);
561 let matches_current = matches_redirect(actual, current, access_time);
562 let matches_next = matches_redirect(actual, write.target(), access_time);
563
564 if matches_current {
565 match write {
566 TieredWrite::Tombstone(tombstone) => {
567 let expires_at = tombstone.time_expires;
568 let entry = StoreEntry::Tombstone(tombstone);
569 written = Some((entry.stored_size(), expires_at));
570 store.insert(id.clone(), entry);
571 }
572 TieredWrite::Object(metadata, payload) => {
573 let expires_at = metadata.time_expires;
574 let entry = StoreEntry::Object(metadata, payload);
575 written = Some((entry.stored_size(), expires_at));
576 store.insert(id.clone(), entry);
577 }
578 TieredWrite::Delete => deleted = store.remove(id).is_some(),
579 }
580 }
581
582 matches_current || matches_next
583 };
584
585 if let Some((size, expires_at)) = written {
586 self.change_stream
587 .commit_write(id, size as u64, expires_at)
588 .await;
589 }
590 if deleted {
591 self.change_stream.commit_delete(id).await;
592 }
593
594 Ok(matches)
595 }
596}
597
598fn json_len<T: serde::Serialize>(value: &T) -> usize {
600 serde_json::to_string(value).map_or(0, |json| json.len())
601}
602
603fn matches_redirect(
608 entry: Option<&StoreEntry>,
609 expected: Option<&ObjectId>,
610 now: Timestamp,
611) -> bool {
612 match entry {
613 None | Some(StoreEntry::Object(..)) => expected.is_none(),
614 Some(StoreEntry::Tombstone(tombstone)) => match expected {
615 None => tombstone.is_expired(now),
616 Some(target) => tombstone.target == *target && !tombstone.is_expired(now),
617 },
618 }
619}
620
621#[derive(Clone, Copy, Debug, PartialEq, Eq)]
627enum ExpiryOutcome {
628 NotFound,
630 Rejected,
632 AlreadySatisfied(Timestamp),
634 Extended(Timestamp),
636}
637
638impl ExpiryOutcome {
639 fn response(self) -> SetExpiryResponse {
641 match self {
642 Self::NotFound => SetExpiryResponse::NotFound,
643 Self::Rejected => SetExpiryResponse::Rejected,
644 Self::AlreadySatisfied(deadline) | Self::Extended(deadline) => {
645 SetExpiryResponse::Satisfied(deadline)
646 }
647 }
648 }
649}
650
651fn extend_object_expiry(
653 metadata: &mut Metadata,
654 target: ExpiryUpdate,
655 access_time: Timestamp,
656) -> Result<ExpiryOutcome> {
657 let Some(original_expires) = metadata.time_expires else {
658 return Ok(ExpiryOutcome::Rejected); };
660
661 let mut updated_expires = Some(original_expires);
663 let outcome = extend_expiry(
664 &mut updated_expires,
665 target,
666 metadata.time_created,
667 access_time,
668 )?;
669
670 if let ExpiryOutcome::Extended(expire_at) = outcome {
671 let updated_policy = common::extended_expiration_policy(
672 metadata.expiration_policy,
673 metadata.time_created,
674 original_expires,
675 expire_at,
676 )?;
677
678 metadata.time_expires = updated_expires;
679 metadata.expiration_policy = updated_policy;
680 }
681
682 Ok(outcome)
683}
684
685fn extend_expiry(
689 field: &mut Option<Timestamp>,
690 target: ExpiryUpdate,
691 time_created: Option<Timestamp>,
692 access_time: Timestamp,
693) -> Result<ExpiryOutcome> {
694 let Some(time_expires) = *field else {
695 return Ok(ExpiryOutcome::Rejected); };
697
698 if time_expires < access_time {
699 return Ok(ExpiryOutcome::NotFound); }
701
702 let Some(expire_at) = target.resolve(time_created, access_time)? else {
703 return Ok(ExpiryOutcome::Rejected);
704 };
705 if time_expires >= expire_at {
706 Ok(ExpiryOutcome::AlreadySatisfied(expire_at))
707 } else {
708 *field = Some(expire_at);
709 Ok(ExpiryOutcome::Extended(expire_at))
710 }
711}
712
713#[derive(Clone, Debug)]
715pub enum Entry {
716 NotFound,
718 Object(Metadata, Bytes),
720 Tombstone(Tombstone),
722}
723
724impl Entry {
725 pub fn is_not_found(&self) -> bool {
727 matches!(self, Entry::NotFound)
728 }
729
730 pub fn is_object(&self) -> bool {
732 matches!(self, Entry::Object(_, _))
733 }
734
735 pub fn is_tombstone(&self) -> bool {
737 matches!(self, Entry::Tombstone(_))
738 }
739
740 pub fn expect_not_found(&self) {
742 match self {
743 Entry::NotFound => (),
744 _ => panic!("expected not found entry, got {self:?}"),
745 }
746 }
747
748 pub fn expect_object(&self) -> (Metadata, Bytes) {
750 match self {
751 Entry::Object(metadata, bytes) => (metadata.clone(), bytes.clone()),
752 _ => panic!("expected object entry, got {self:?}"),
753 }
754 }
755
756 pub fn expect_tombstone(&self) -> Tombstone {
758 match self {
759 Entry::Tombstone(tombstone) => tombstone.clone(),
760 _ => panic!("expected tombstone entry, got {self:?}"),
761 }
762 }
763}
764
765#[cfg(test)]
766mod tests {
767 use futures_util::StreamExt;
768 use std::time::Duration;
769
770 #[cfg(feature = "storage-cogs")]
771 use objectstore_inventory_tracker::{OpType, test_utils::DummyProducer};
772 use objectstore_types::metadata::ExpirationPolicy;
773 use objectstore_types::scope::{Scope, Scopes};
774
775 use super::*;
776 use crate::backend::common::{Backend, ExpiryTarget};
777 use crate::id::ObjectContext;
778 use crate::stream;
779
780 fn make_id() -> ObjectId {
781 ObjectId::random(ObjectContext {
782 usecase: "testing".parse().unwrap(),
783 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
784 })
785 }
786
787 async fn create_session(backend: &InMemoryBackend, id: &ObjectId, length: u64) -> Session {
788 let backend_token = backend
789 .create_upload_session(id, &Metadata::default(), NonZeroU64::new(length).unwrap())
790 .await
791 .unwrap()
792 .unwrap();
793 Session {
794 object_id: id.clone(),
795 upload_length: NonZeroU64::new(length).unwrap(),
796 backend_token,
797 }
798 }
799
800 #[tokio::test]
801 async fn resumable_upload() {
802 let backend = InMemoryBackend::new("test");
803 let id = make_id();
804 let metadata = Metadata {
805 custom: [("preserved".into(), "yes".into())].into(),
806 ..Default::default()
807 };
808
809 backend
811 .put_object(
812 &id,
813 &Metadata::default(),
814 stream::single("old"),
815 Timestamp::now(),
816 )
817 .await
818 .unwrap();
819 let upload_length = NonZeroU64::new(3).unwrap();
820 let token = backend
821 .create_upload_session(&id, &metadata, upload_length)
822 .await
823 .unwrap()
824 .unwrap();
825 let token = Session {
826 object_id: id.clone(),
827 upload_length,
828 backend_token: token,
829 };
830 assert_eq!(
831 backend.upload_offset(&token).await.unwrap(),
832 UploadProgress::Incomplete { offset: 0 }
833 );
834
835 assert_eq!(
837 backend
838 .put_chunk(&token, 0, 1, stream::single("a"))
839 .await
840 .unwrap(),
841 UploadProgress::Incomplete { offset: 1 }
842 );
843 assert_eq!(backend.get(&id).expect_object().1, "old");
844 assert_eq!(
845 backend
846 .put_chunk(&token, 0, 0, stream::single(""))
847 .await
848 .unwrap(),
849 UploadProgress::Incomplete { offset: 1 }
850 );
851 let error = backend
852 .put_chunk(&token, 0, 1, stream::single("a"))
853 .await
854 .unwrap_err();
855 assert_eq!(error.kind(), ErrorKind::UploadOffsetMismatch { offset: 1 });
856
857 assert_eq!(
859 backend
860 .put_chunk(&token, 1, 2, stream::single("bc"))
861 .await
862 .unwrap(),
863 UploadProgress::Complete
864 );
865 let (actual, bytes) = backend.get(&id).expect_object();
866 assert_eq!(bytes, "abc");
867 assert_eq!(actual.custom, metadata.custom);
868 assert_eq!(actual.size, metadata.size);
869 assert_eq!(
870 backend.upload_offset(&token).await.unwrap_err().kind(),
871 ErrorKind::UnknownUploadSession
872 );
873 }
874
875 #[tokio::test]
876 async fn resumable_cancel_and_invalid_sessions() {
877 let backend = InMemoryBackend::new("test");
878 let id = make_id();
879 let token = create_session(&backend, &id, 3).await;
880
881 let error = backend
883 .put_chunk(&token, 3, 1, stream::single("x"))
884 .await
885 .unwrap_err();
886 assert_eq!(
887 error.kind(),
888 ErrorKind::ChunkExceedsUploadLength {
889 offset: 3,
890 content_length: 1,
891 upload_length: 3
892 }
893 );
894
895 backend
897 .put_chunk(&token, 0, 1, stream::single("a"))
898 .await
899 .unwrap();
900 backend.cancel_upload(&token).await.unwrap();
901 assert!(!backend.contains(&id));
902 assert_eq!(
903 backend.upload_offset(&token).await.unwrap_err().kind(),
904 ErrorKind::UnknownUploadSession
905 );
906 }
907
908 #[tokio::test]
909 async fn failed_resumable_chunk_preserves_partial_progress() {
910 let backend = InMemoryBackend::new("test");
911 let id = make_id();
912 let token = create_session(&backend, &id, 4).await;
913
914 backend
916 .put_chunk(&token, 0, 2, stream::single("ab"))
917 .await
918 .unwrap();
919 let body = futures_util::stream::iter([
920 Ok(Bytes::from_static(b"c")),
921 Err(stream::ClientError::new(std::io::Error::other(
922 "interrupted",
923 ))),
924 ])
925 .boxed();
926 assert_eq!(
927 backend
928 .put_chunk(&token, 2, 2, body)
929 .await
930 .unwrap_err()
931 .kind(),
932 ErrorKind::ClientStream
933 );
934
935 assert_eq!(
937 backend.upload_offset(&token).await.unwrap(),
938 UploadProgress::Incomplete { offset: 3 }
939 );
940 assert_eq!(
941 backend
942 .put_chunk(&token, 3, 1, stream::single("d"))
943 .await
944 .unwrap(),
945 UploadProgress::Complete
946 );
947 assert_eq!(backend.get(&id).expect_object().1, "abcd");
948 }
949
950 #[tokio::test]
951 async fn resumable_serializes_session_operations() {
952 let backend = InMemoryBackend::new("test");
953 let id = make_id();
954 let token = create_session(&backend, &id, 1).await;
955 let (sender, receiver) = tokio::sync::oneshot::channel();
956 let body = futures_util::stream::once(async { Ok(receiver.await.unwrap()) }).boxed();
957
958 let request = backend.put_chunk(&token, 0, 1, body);
960 tokio::pin!(request);
961 assert!(futures_util::poll!(&mut request).is_pending());
962 let query = backend.upload_offset(&token);
963 tokio::pin!(query);
964 assert!(futures_util::poll!(&mut query).is_pending());
965
966 sender.send(Bytes::from_static(b"x")).unwrap();
968 assert_eq!(request.await.unwrap(), UploadProgress::Complete);
969 assert_eq!(
970 query.await.unwrap_err().kind(),
971 ErrorKind::UnknownUploadSession
972 );
973 assert_eq!(backend.get(&id).expect_object().1, "x");
974 }
975
976 #[cfg(feature = "storage-cogs")]
977 #[tokio::test]
978 async fn resumable_emits_only_publication() {
979 let (backend, producer) = backend_with_change_stream();
980 let id = make_id();
981 let token = create_session(&backend, &id, 2).await;
982
983 backend
985 .put_chunk(&token, 0, 1, stream::single("a"))
986 .await
987 .unwrap();
988 assert!(producer.records().is_empty());
989
990 backend
992 .put_chunk(&token, 1, 1, stream::single("b"))
993 .await
994 .unwrap();
995 let records = producer.records();
996 assert_eq!(records.len(), 1);
997 assert_eq!(records[0].op_type, OpType::Write);
998 }
999
1000 #[tokio::test]
1001 async fn set_expiry() {
1002 let access_time = Timestamp::from_unix_secs(1_700_000_000).unwrap();
1003 for policy in [
1004 ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1005 ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1006 ] {
1007 let original_expiry = access_time + Duration::from_hours(1);
1008 let requested = original_expiry + Duration::from_hours(1) + Duration::from_nanos(999);
1009 for target in [
1010 ExpiryTarget::At(requested),
1011 ExpiryTarget::FromCreation(Duration::from_hours(2) + Duration::from_nanos(999)),
1012 ] {
1013 let backend = InMemoryBackend::new("test");
1014 let id = make_id();
1015 let metadata = Metadata {
1016 expiration_policy: policy,
1017 time_created: Some(access_time),
1018 time_expires: Some(original_expiry),
1019 custom: [("preserved".into(), "yes".into())].into(),
1020 ..Default::default()
1021 };
1022 backend
1023 .put_object(&id, &metadata, stream::single("payload"), access_time)
1024 .await
1025 .unwrap();
1026
1027 let restricted = ExpiryUpdate {
1028 max: Some(Duration::ZERO),
1029 target,
1030 };
1031 assert_eq!(
1032 backend
1033 .set_expiry(&id, restricted, access_time)
1034 .await
1035 .unwrap_err()
1036 .kind(),
1037 ErrorKind::InvalidMetadata,
1038 );
1039 let target = ExpiryUpdate {
1040 max: Some(Duration::from_hours(100)),
1041 target,
1042 };
1043 assert_eq!(
1044 backend.set_expiry(&id, target, access_time).await.unwrap(),
1045 SetExpiryResponse::Satisfied(requested)
1046 );
1047 assert_eq!(
1048 backend
1049 .set_expiry(&id, restricted, access_time)
1050 .await
1051 .unwrap_err()
1052 .kind(),
1053 ErrorKind::InvalidMetadata,
1054 );
1055 let (updated, payload) = backend.get(&id).expect_object();
1056 let expected_policy = match policy {
1057 ExpirationPolicy::TimeToLive(_) => {
1058 ExpirationPolicy::TimeToLive(Duration::from_hours(2))
1059 }
1060 other => other,
1061 };
1062 assert_eq!(updated.expiration_policy, expected_policy);
1063 assert_eq!(updated.time_created, metadata.time_created);
1064 assert_eq!(updated.custom, metadata.custom);
1065 assert_eq!(payload, Bytes::from_static(b"payload"));
1066 assert_eq!(updated.time_expires, Some(requested));
1067
1068 assert_eq!(
1069 backend
1070 .set_expiry(&id, ExpiryTarget::At(original_expiry).into(), access_time)
1071 .await
1072 .unwrap(),
1073 SetExpiryResponse::Satisfied(original_expiry)
1074 );
1075 assert_eq!(backend.get(&id).expect_object().0, updated);
1076 }
1077 }
1078 }
1079
1080 #[test]
1081 fn extend_object_expiry_preserves_metadata_on_error() {
1082 let access_time = Timestamp::from_unix_secs(1_700_000_000).unwrap();
1083 let original_expiry = access_time + Duration::from_hours(1);
1084 let requested = original_expiry + Duration::from_hours(1);
1085 for (time_created, ttl) in [
1086 (
1087 Some(requested + Duration::from_secs(1)),
1088 Duration::from_hours(1),
1089 ),
1090 (None, Duration::MAX),
1091 ] {
1092 let original = Metadata {
1093 expiration_policy: ExpirationPolicy::TimeToLive(ttl),
1094 time_created,
1095 time_expires: Some(original_expiry),
1096 ..Default::default()
1097 };
1098 let mut metadata = original.clone();
1099 for _ in 0..2 {
1100 assert_eq!(
1101 extend_object_expiry(
1102 &mut metadata,
1103 ExpiryTarget::At(requested).into(),
1104 access_time,
1105 )
1106 .unwrap_err()
1107 .kind(),
1108 ErrorKind::CorruptData
1109 );
1110 assert_eq!(metadata, original);
1111 }
1112 }
1113 }
1114
1115 #[tokio::test]
1116 async fn set_expiry_rejected() {
1117 let access_time = Timestamp::from_unix_secs(1_700_000_000).unwrap();
1118 let backend = InMemoryBackend::new("test");
1119 let absent = make_id();
1120 assert_eq!(
1121 backend
1122 .set_expiry(
1123 &absent,
1124 ExpiryTarget::At(access_time + Duration::from_hours(1)).into(),
1125 access_time,
1126 )
1127 .await
1128 .unwrap(),
1129 SetExpiryResponse::NotFound
1130 );
1131
1132 let missing_creation = make_id();
1133 let original_expiry = access_time + Duration::from_hours(1);
1134 backend
1135 .put_object(
1136 &missing_creation,
1137 &Metadata {
1138 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1139 time_expires: Some(original_expiry),
1140 ..Default::default()
1141 },
1142 stream::single("legacy"),
1143 access_time,
1144 )
1145 .await
1146 .unwrap();
1147 let absolute = original_expiry + Duration::from_hours(1);
1148 assert_eq!(
1149 backend
1150 .set_expiry(
1151 &missing_creation,
1152 ExpiryTarget::At(absolute).into(),
1153 access_time
1154 )
1155 .await
1156 .unwrap(),
1157 SetExpiryResponse::Satisfied(absolute)
1158 );
1159 let updated = backend.get(&missing_creation).expect_object().0;
1160 assert_eq!(
1161 updated.expiration_policy,
1162 ExpirationPolicy::TimeToLive(Duration::from_hours(2))
1163 );
1164 assert_eq!(updated.time_created, None);
1165 assert_eq!(
1166 backend
1167 .set_expiry(
1168 &missing_creation,
1169 ExpiryTarget::FromCreation(Duration::ZERO).into(),
1170 access_time,
1171 )
1172 .await
1173 .unwrap(),
1174 SetExpiryResponse::Rejected
1175 );
1176
1177 let manual = make_id();
1178 backend
1179 .put_object(
1180 &manual,
1181 &Metadata::default(),
1182 stream::single("manual"),
1183 access_time,
1184 )
1185 .await
1186 .unwrap();
1187 assert_eq!(
1188 backend
1189 .set_expiry(
1190 &manual,
1191 ExpiryTarget::At(access_time + Duration::from_hours(1)).into(),
1192 access_time,
1193 )
1194 .await
1195 .unwrap(),
1196 SetExpiryResponse::Rejected
1197 );
1198
1199 let expired = make_id();
1200 let metadata = Metadata {
1201 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1202 time_expires: Some(access_time - Duration::from_secs(1)),
1203 ..Default::default()
1204 };
1205 backend
1206 .put_object(&expired, &metadata, stream::single("expired"), access_time)
1207 .await
1208 .unwrap();
1209 let deadline = metadata.time_expires.unwrap();
1210 for time in [deadline - Duration::from_secs(1), deadline] {
1211 assert!(
1212 backend
1213 .get_object(&expired, time, None)
1214 .await
1215 .unwrap()
1216 .is_some()
1217 );
1218 }
1219 assert!(
1220 backend
1221 .get_object(&expired, access_time, None)
1222 .await
1223 .unwrap()
1224 .is_none()
1225 );
1226 assert_eq!(
1227 backend
1228 .set_expiry(
1229 &expired,
1230 ExpiryTarget::At(access_time + Duration::from_hours(1)).into(),
1231 access_time,
1232 )
1233 .await
1234 .unwrap(),
1235 SetExpiryResponse::NotFound
1236 );
1237 }
1238
1239 #[tokio::test]
1240 async fn redirect_expiry() {
1241 let access_time = Timestamp::from_unix_secs(1_700_000_000).unwrap();
1242 let backend = InMemoryBackend::new("test");
1243 let id = make_id();
1244 let target = make_id();
1245 let other = make_id();
1246 let old_expiry = access_time + Duration::from_hours(1);
1247 backend
1248 .compare_and_write(
1249 &id,
1250 None,
1251 TieredWrite::Tombstone(Tombstone {
1252 target: target.clone(),
1253 time_expires: Some(old_expiry),
1254 }),
1255 access_time,
1256 )
1257 .await
1258 .unwrap();
1259
1260 assert!(
1261 backend
1262 .get_object(&id, access_time, None)
1263 .await
1264 .is_err_and(|error| error.kind() == ErrorKind::UnexpectedTombstone)
1265 );
1266 assert!(
1267 backend
1268 .get_metadata(&id, access_time)
1269 .await
1270 .is_err_and(|error| error.kind() == ErrorKind::UnexpectedTombstone)
1271 );
1272
1273 let new_expiry = old_expiry + Duration::from_hours(1);
1274 assert_eq!(
1275 backend
1276 .compare_and_update(
1277 &id,
1278 Some(&other),
1279 TieredUpdate::SetExpiry(ExpiryTarget::At(new_expiry).into()),
1280 access_time,
1281 )
1282 .await
1283 .unwrap(),
1284 SetExpiryResponse::Rejected
1285 );
1286 assert_eq!(
1287 backend.get(&id).expect_tombstone().time_expires,
1288 Some(old_expiry)
1289 );
1290 assert_eq!(
1291 backend
1292 .compare_and_update(
1293 &id,
1294 Some(&target),
1295 TieredUpdate::SetExpiry(
1296 ExpiryTarget::FromCreation(Duration::from_hours(2)).into()
1297 ),
1298 access_time,
1299 )
1300 .await
1301 .unwrap(),
1302 SetExpiryResponse::Rejected
1303 );
1304 assert_eq!(
1305 backend
1306 .compare_and_update(
1307 &id,
1308 Some(&target),
1309 TieredUpdate::SetExpiry(ExpiryTarget::At(new_expiry).into()),
1310 access_time
1311 )
1312 .await
1313 .unwrap(),
1314 SetExpiryResponse::Satisfied(new_expiry)
1315 );
1316 assert_eq!(
1317 backend.get(&id).expect_tombstone().time_expires,
1318 Some(new_expiry)
1319 );
1320 }
1321
1322 #[cfg(feature = "storage-cogs")]
1323 fn backend_with_change_stream() -> (InMemoryBackend, DummyProducer) {
1324 use crate::change_stream::CostTrackerStreamConfig;
1325
1326 let (streams, producer) = crate::change_stream::dummy_factory();
1327 let change_stream = streams.build(Some(&CostTrackerStreamConfig {
1328 shared_resource_id: "in_memory_objectstore".into(),
1329 sample_rate: 1.0,
1330 }));
1331 (
1332 InMemoryBackend::new("test").with_change_stream(change_stream),
1333 producer,
1334 )
1335 }
1336
1337 #[cfg(feature = "storage-cogs")]
1338 #[tokio::test]
1339 async fn change_stream_reports_writes_and_deletes() {
1340 let (backend, producer) = backend_with_change_stream();
1341 let id = make_id();
1342 let metadata = Metadata::default();
1343 let payload = b"hello";
1344
1345 backend
1346 .put_object(
1347 &id,
1348 &metadata,
1349 stream::single(payload.to_vec()),
1350 Timestamp::now(),
1351 )
1352 .await
1353 .unwrap();
1354 backend.delete_object(&id, Timestamp::now()).await.unwrap();
1355 backend.delete_object(&id, Timestamp::now()).await.unwrap();
1357
1358 let records = producer.records();
1359 assert_eq!(records.len(), 2);
1360 assert_eq!(records[0].op_type, OpType::Write);
1361 assert_eq!(records[0].app_feature, "testing");
1362 assert_eq!(
1363 records[0].size,
1364 Some((json_len(&metadata) + payload.len()) as u64),
1365 "the reported size covers metadata as well as the payload"
1366 );
1367 assert!(json_len(&metadata) > 0, "metadata must contribute bytes");
1368 assert_eq!(records[1].op_type, OpType::Delete);
1369 }
1370
1371 #[cfg(feature = "storage-cogs")]
1372 #[tokio::test]
1373 async fn change_stream_reports_expiry_extension_as_update() {
1374 let (backend, producer) = backend_with_change_stream();
1375 let id = make_id();
1376 let access_time = Timestamp::now();
1377 let expires = access_time + Duration::from_secs(3600);
1378 let metadata = Metadata {
1379 time_expires: Some(expires),
1380 ..Default::default()
1381 };
1382
1383 backend
1384 .put_object(
1385 &id,
1386 &metadata,
1387 stream::single(b"hello".to_vec()),
1388 access_time,
1389 )
1390 .await
1391 .unwrap();
1392 producer.clear();
1393
1394 let extended = expires + Duration::from_secs(3600);
1395 assert_eq!(
1396 backend
1397 .set_expiry(&id, ExpiryTarget::At(extended).into(), access_time)
1398 .await
1399 .unwrap(),
1400 SetExpiryResponse::Satisfied(extended)
1401 );
1402
1403 let records = producer.records();
1404 assert_eq!(records.len(), 1);
1405 assert_eq!(records[0].op_type, OpType::Update);
1406
1407 producer.clear();
1409 let requested = expires + Duration::from_secs(60);
1410 assert_eq!(
1411 backend
1412 .set_expiry(&id, ExpiryTarget::At(requested).into(), access_time)
1413 .await
1414 .unwrap(),
1415 SetExpiryResponse::Satisfied(requested)
1416 );
1417 assert!(producer.records().is_empty());
1418 }
1419
1420 #[cfg(feature = "storage-cogs")]
1421 #[tokio::test]
1422 async fn change_stream_reports_tombstone_expiry_extension() {
1423 let (backend, producer) = backend_with_change_stream();
1424 let id = make_id();
1425 let target = make_id();
1426 let access_time = Timestamp::now();
1427 let expires = access_time + Duration::from_secs(3600);
1428
1429 backend
1430 .compare_and_write(
1431 &id,
1432 None,
1433 TieredWrite::Tombstone(Tombstone {
1434 target: target.clone(),
1435 time_expires: Some(expires),
1436 }),
1437 access_time,
1438 )
1439 .await
1440 .unwrap();
1441 producer.clear();
1442
1443 let extended = expires + Duration::from_secs(3600);
1444 assert_eq!(
1445 backend
1446 .compare_and_update(
1447 &id,
1448 Some(&target),
1449 TieredUpdate::SetExpiry(ExpiryTarget::At(extended).into()),
1450 access_time,
1451 )
1452 .await
1453 .unwrap(),
1454 SetExpiryResponse::Satisfied(extended)
1455 );
1456
1457 let records = producer.records();
1458 assert_eq!(records.len(), 1);
1459 assert_eq!(records[0].op_type, OpType::Update);
1460 }
1461}