1use std::fs::File;
20use std::io;
21use std::num::NonZeroU64;
22use std::path::{Path, PathBuf};
23use std::pin::pin;
24use std::sync::Arc;
25
26use futures_util::StreamExt;
27use objectstore_types::metadata::Metadata;
28use objectstore_types::range::ByteRange;
29use objectstore_types::resumable::UploadProgress;
30use objectstore_types::time::Timestamp;
31use tokio::fs::OpenOptions;
32use tokio::io::{
33 AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufReader, BufWriter,
34};
35use tokio::sync::Semaphore;
36use tokio_util::io::{ReaderStream, StreamReader};
37use uuid::Uuid;
38
39use crate::backend::common::{
40 self, Backend, DeleteResponse, ExpiryUpdate, GetResponse, PutResponse, SetExpiryResponse,
41};
42use crate::change_stream::{
43 ChangeStream, ChangeStreamFactory, CostTrackerStreamConfig, flush_change_stream,
44};
45use crate::error::{Error, ErrorKind, Result, ResultExt as _};
46use crate::id::ObjectId;
47use crate::resumable::{BackendToken, Session};
48use crate::stream::{self, ClientStream};
49
50fn file_options() -> std::fs::OpenOptions {
52 let mut options = std::fs::OpenOptions::new();
53 #[cfg(unix)]
54 {
55 use std::os::unix::fs::OpenOptionsExt as _;
56 options.mode(0o600);
57 }
58 options
59}
60
61async fn create_directories(path: &Path) -> io::Result<()> {
63 let mut builder = tokio::fs::DirBuilder::new();
64 builder.recursive(true);
65 #[cfg(unix)]
66 builder.mode(0o700);
67 builder.create(path).await
68}
69
70#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
83pub struct FileSystemConfig {
84 pub path: PathBuf,
98
99 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub cogs: Option<CostTrackerStreamConfig>,
111}
112
113#[derive(Debug)]
115pub struct LocalFsBackend {
116 path: PathBuf,
117 locks: ObjectLocks,
118
119 change_stream: Arc<dyn ChangeStream>,
120}
121
122impl LocalFsBackend {
123 pub fn new(config: FileSystemConfig, streams: &ChangeStreamFactory) -> Self {
125 let FileSystemConfig { path, cogs } = config;
126 let locks = ObjectLocks::new(&path);
127 Self {
128 path,
129 locks,
130 change_stream: streams.build(cogs.as_ref()),
131 }
132 }
133
134 fn path(&self, id: &ObjectId) -> PathBuf {
136 self.path.join(id.as_storage_path().to_string())
137 }
138
139 fn upload_path(&self, upload_id: Uuid) -> PathBuf {
140 self.path.join("uploads").join(upload_id.to_string())
141 }
142}
143
144#[async_trait::async_trait]
145impl Backend for LocalFsBackend {
146 fn name(&self) -> &'static str {
147 "local-fs"
148 }
149
150 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
151 async fn put_object(
152 &self,
153 id: &ObjectId,
154 metadata: &Metadata,
155 stream: ClientStream,
156 _access_time: Timestamp,
157 ) -> Result<PutResponse> {
158 let path = self.path(id);
159 objectstore_log::debug!(path=%path.display(), "Writing to local_fs backend");
160 self.change_stream
161 .begin_write(id, metadata.time_expires)
162 .await?;
163 create_directories(path.parent().unwrap()).await.context(
164 ErrorKind::BackendFailure,
165 "creating local-fs object directory",
166 )?;
167
168 let mut draft = Draft::create(&path, metadata).await?;
169 let mut reader = pin!(StreamReader::new(stream));
170 let payload_size = tokio::io::copy(&mut reader, draft.writer())
171 .await
172 .map_err(|e| match stream::unpack_client_error(&e) {
173 Some(ce) => Error::from(ce),
174 None => Error::with_context(
175 ErrorKind::BackendFailure,
176 "writing local-fs object payload",
177 e,
178 ),
179 })?;
180
181 let stored_size = draft.preamble_len() + payload_size;
182
183 draft.prepare().await?;
184 let _guard = self.locks.acquire(id).await?;
185 draft.publish().await?;
186
187 self.change_stream
188 .commit_write(id, stored_size, metadata.time_expires)
189 .await;
190
191 Ok(())
192 }
193
194 #[tracing::instrument(level = "debug", skip(self))]
195 async fn get_object(
196 &self,
197 id: &ObjectId,
198 access_time: Timestamp,
199 range: Option<ByteRange>,
200 ) -> Result<GetResponse> {
201 objectstore_log::debug!("Reading from local_fs backend");
202 let path = self.path(id);
203 let Some(object) = ObjectFile::try_open(&path, access_time).await? else {
204 objectstore_log::debug!("Object not found");
205 return Ok(None);
206 };
207 let ObjectFile {
208 metadata,
209 preamble_len,
210 payload_size,
211 mut reader,
212 } = object;
213
214 let (content_range, stream) = match range {
215 Some(byte_range) => {
216 let content_range =
217 byte_range
218 .resolve(payload_size)
219 .ok_or(ErrorKind::RangeNotSatisfiable {
220 total: payload_size,
221 })?;
222 let payload_start = preamble_len + content_range.start;
223 reader
224 .seek(std::io::SeekFrom::Start(payload_start))
225 .await
226 .context(ErrorKind::BackendFailure, "seeking local-fs object payload")?;
227 let limited = reader.take(content_range.len());
228 (Some(content_range), ReaderStream::new(limited).boxed())
229 }
230 None => (None, ReaderStream::new(reader).boxed()),
231 };
232 Ok(Some((metadata, content_range, stream)))
233 }
234
235 #[tracing::instrument(level = "debug", skip(self))]
236 async fn set_expiry(
237 &self,
238 id: &ObjectId,
239 target: ExpiryUpdate,
240 access_time: Timestamp,
241 ) -> Result<SetExpiryResponse> {
242 let _guard = self.locks.acquire(id).await?;
243
244 let path = self.path(id);
245 let Some(object) = ObjectFile::try_open(&path, access_time).await? else {
246 return Ok(SetExpiryResponse::NotFound);
247 };
248 let ObjectFile {
249 mut metadata,
250 mut reader,
251 ..
252 } = object;
253
254 let Some(current_expiry) = metadata.time_expires else {
255 return Ok(SetExpiryResponse::Rejected);
256 };
257 let Some(expire_at) = target.resolve(metadata.time_created, access_time)? else {
258 return Ok(SetExpiryResponse::Rejected);
259 };
260 if current_expiry >= expire_at {
261 return Ok(SetExpiryResponse::Satisfied(expire_at)); }
263 metadata.expiration_policy = common::extended_expiration_policy(
264 metadata.expiration_policy,
265 metadata.time_created,
266 current_expiry,
267 expire_at,
268 )?;
269 metadata.time_expires = Some(expire_at);
270 self.change_stream.begin_update(id, Some(expire_at)).await?;
271
272 let mut draft = Draft::create(&path, &metadata).await?;
273 tokio::io::copy(&mut reader, draft.writer()).await.context(
274 ErrorKind::BackendFailure,
275 "copying local-fs object payload for expiry extension",
276 )?;
277
278 draft.prepare().await?;
279 draft.publish().await?;
280
281 self.change_stream.commit_update(id, Some(expire_at)).await;
282
283 Ok(SetExpiryResponse::Satisfied(expire_at))
284 }
285
286 #[tracing::instrument(level = "debug", skip(self))]
287 async fn delete_object(
288 &self,
289 id: &ObjectId,
290 _access_time: Timestamp,
291 ) -> Result<DeleteResponse> {
292 let _guard = self.locks.acquire(id).await?;
293
294 objectstore_log::debug!("Deleting from local_fs backend");
295 let path = self.path(id);
296 match tokio::fs::remove_file(path).await {
297 Ok(()) => self.change_stream.commit_delete(id).await,
298 Err(error) if error.kind() == io::ErrorKind::NotFound => {
299 objectstore_log::debug!("Object not found");
300 }
301 result => {
302 result.context(ErrorKind::BackendFailure, "deleting local-fs object")?;
303 }
304 }
305
306 Ok(())
307 }
308
309 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
310 async fn create_upload_session(
311 &self,
312 id: &ObjectId,
313 metadata: &Metadata,
314 _upload_length: NonZeroU64,
315 ) -> Result<Option<BackendToken>> {
316 self.change_stream
317 .begin_write(id, metadata.time_expires)
318 .await?;
319 let upload_id = uuid::Uuid::now_v7();
320 let path = self.upload_path(upload_id);
321 create_directories(path.parent().unwrap()).await.context(
322 ErrorKind::BackendFailure,
323 "creating local-fs object directory",
324 )?;
325 UploadFile::create(&path, metadata).await?;
326 Ok(Some(upload_id.to_string()))
327 }
328
329 #[tracing::instrument(level = "debug", fields(?session, offset, content_length), skip_all)]
332 async fn put_chunk(
333 &self,
334 session: &Session,
335 offset: u64,
336 content_length: u64,
337 stream: ClientStream,
338 ) -> Result<UploadProgress> {
339 let upload_id =
340 Uuid::parse_str(&session.backend_token).map_err(|_| ErrorKind::UnknownUploadSession)?;
341 offset
342 .checked_add(content_length)
343 .filter(|end| *end <= session.upload_length.get())
344 .ok_or(ErrorKind::ChunkExceedsUploadLength {
345 offset,
346 content_length,
347 upload_length: session.upload_length.get(),
348 })?;
349 let _guard = self.locks.acquire(&session.object_id).await?;
350
351 let upload_path = self.upload_path(upload_id);
352 let mut upload = UploadFile::open(&upload_path).await?;
353 if content_length != 0 && offset != upload.offset() {
354 return Err(ErrorKind::UploadOffsetMismatch {
355 offset: upload.offset(),
356 }
357 .into());
358 }
359
360 let reader = StreamReader::new(stream).take(content_length);
361 let persisted_offset = upload.append(reader).await?;
362
363 if persisted_offset != session.upload_length.get() {
364 return Ok(UploadProgress::Incomplete {
365 offset: persisted_offset,
366 });
367 }
368
369 let object_path = self.path(&session.object_id);
370 create_directories(object_path.parent().unwrap())
371 .await
372 .context(
373 ErrorKind::BackendFailure,
374 "creating local-fs object directory",
375 )?;
376 let (stored_size, expires_at) = upload.publish(object_path).await?;
377 self.change_stream
378 .commit_write(&session.object_id, stored_size, expires_at)
379 .await;
380 Ok(UploadProgress::Complete)
381 }
382
383 #[tracing::instrument(level = "debug", fields(?session), skip_all)]
384 async fn upload_offset(&self, session: &Session) -> Result<UploadProgress> {
385 self.put_chunk(session, 0, 0, futures_util::stream::empty().boxed())
386 .await
387 }
388
389 #[tracing::instrument(level = "debug", fields(?session), skip_all)]
390 async fn cancel_upload(&self, session: &Session) -> Result<()> {
391 let upload_id =
392 Uuid::parse_str(&session.backend_token).map_err(|_| ErrorKind::UnknownUploadSession)?;
393 let _guard = self.locks.acquire(&session.object_id).await?;
394 let path = self.upload_path(upload_id);
395 match tokio::fs::remove_file(&path).await {
396 Ok(()) => Ok(()),
397 Err(error) if error.kind() == io::ErrorKind::NotFound => {
398 Err(ErrorKind::UnknownUploadSession.into())
399 }
400 result => result.context(
401 ErrorKind::BackendFailure,
402 "canceling local-fs resumable upload",
403 ),
404 }
405 }
406
407 async fn join(&self) {
408 flush_change_stream(&self.change_stream).await;
409 }
410}
411
412const MAX_BLOCKING_LOCK_WAITERS: usize = 256;
414
415#[derive(Debug)]
416struct ObjectLocks {
417 root: PathBuf,
418 blocking_waiters: Arc<Semaphore>,
419}
420
421impl ObjectLocks {
422 pub fn new(storage_root: &Path) -> Self {
423 Self {
424 root: storage_root.join(".locks"),
425 blocking_waiters: Arc::new(Semaphore::new(MAX_BLOCKING_LOCK_WAITERS)),
426 }
427 }
428
429 fn path(&self, id: &ObjectId) -> PathBuf {
430 let hash = blake3::hash(id.as_storage_path().to_string().as_bytes());
431 let bytes = hash.as_bytes();
432 self.root
433 .join(format!("{:02x}", bytes[0]))
434 .join(format!("{:02x}", bytes[1]))
435 }
436
437 pub async fn acquire(&self, id: &ObjectId) -> Result<File> {
439 let path = self.path(id);
440 create_directories(path.parent().unwrap()).await.context(
441 ErrorKind::BackendFailure,
442 "creating local-fs object lock directory",
443 )?;
444
445 let permit = Arc::clone(&self.blocking_waiters)
447 .acquire_owned()
448 .await
449 .expect("local-fs lock semaphore is never closed");
450
451 tokio::task::spawn_blocking(move || -> io::Result<File> {
452 let _permit = permit;
453 let file = file_options()
454 .create(true)
455 .truncate(false)
456 .read(true)
457 .write(true)
458 .open(&path)?;
459 file.lock()?;
460 Ok(file)
461 })
462 .await
463 .context(ErrorKind::Internal, "waiting for local-fs object lock")?
464 .context(ErrorKind::BackendFailure, "acquiring local-fs object lock")
465 }
466}
467
468struct UploadFile {
470 file: tokio::fs::File,
471 path: PathBuf,
472 payload_size: u64,
474 stored_size: u64,
476 expires_at: Option<Timestamp>,
477}
478
479impl UploadFile {
480 async fn create(path: &Path, metadata: &Metadata) -> Result<()> {
481 let mut options = OpenOptions::from(file_options());
482 options.create_new(true).read(true).write(true);
483
484 let mut file = options.open(path).await.context(
485 ErrorKind::BackendFailure,
486 "creating local-fs resumable upload",
487 )?;
488 write_metadata_preamble(&mut file, metadata).await?;
489 file.sync_data().await.context(
490 ErrorKind::BackendFailure,
491 "syncing local-fs resumable upload",
492 )
493 }
494
495 async fn open(path: &Path) -> Result<Self> {
496 let file = match OpenOptions::new().read(true).write(true).open(path).await {
497 Ok(file) => file,
498 Err(error) if error.kind() == io::ErrorKind::NotFound => {
499 return Err(ErrorKind::UnknownUploadSession.into());
500 }
501 result => result.context(
502 ErrorKind::BackendFailure,
503 "opening local-fs resumable upload",
504 )?,
505 };
506 let mut reader = BufReader::new(file);
507 let (metadata, preamble_len) = read_metadata_preamble(&mut reader).await?;
508 let file = reader.into_inner();
509 let stored_size = file
510 .metadata()
511 .await
512 .context(
513 ErrorKind::BackendFailure,
514 "reading local-fs resumable upload size",
515 )?
516 .len();
517 let preamble_len = preamble_len as u64;
518 let payload_size = stored_size.checked_sub(preamble_len).ok_or_else(|| {
519 Error::new(
520 ErrorKind::CorruptData,
521 "reading truncated local-fs resumable upload",
522 )
523 })?;
524 Ok(Self {
525 file,
526 path: path.to_path_buf(),
527 stored_size,
528 expires_at: metadata.time_expires,
529 payload_size,
530 })
531 }
532
533 fn offset(&self) -> u64 {
534 self.payload_size
535 }
536
537 async fn append(&mut self, mut reader: impl AsyncRead + Unpin) -> Result<u64> {
538 self.file.seek(std::io::SeekFrom::End(0)).await.context(
539 ErrorKind::BackendFailure,
540 "seeking local-fs resumable upload",
541 )?;
542
543 let copied = match tokio::io::copy(&mut reader, &mut self.file).await {
544 Ok(copied) => copied,
545 Err(error) => {
546 let client_error = stream::unpack_client_error(&error);
547 self.file.sync_data().await.context(
548 ErrorKind::BackendFailure,
549 "syncing partial local-fs resumable chunk",
550 )?;
551 return Err(match client_error {
552 Some(client_error) => Error::from(client_error),
553 None => Error::with_context(
554 ErrorKind::BackendFailure,
555 "writing local-fs resumable chunk",
556 error,
557 ),
558 });
559 }
560 };
561
562 let payload_size = self.payload_size.checked_add(copied).ok_or_else(|| {
563 Error::new(
564 ErrorKind::BackendFailure,
565 "local-fs resumable payload size overflow",
566 )
567 })?;
568 let stored_size = self.stored_size.checked_add(copied).ok_or_else(|| {
569 Error::new(
570 ErrorKind::BackendFailure,
571 "local-fs resumable stored size overflow",
572 )
573 })?;
574 self.file.sync_data().await.context(
575 ErrorKind::BackendFailure,
576 "syncing local-fs resumable chunk",
577 )?;
578 self.payload_size = payload_size;
579 self.stored_size = stored_size;
580 Ok(payload_size)
581 }
582
583 async fn publish(self, target: PathBuf) -> Result<(u64, Option<Timestamp>)> {
585 let Self {
586 file,
587 path,
588 stored_size,
589 expires_at,
590 ..
591 } = self;
592 drop(file);
593 tokio::fs::rename(path, target).await.context(
594 ErrorKind::BackendFailure,
595 "publishing local-fs resumable upload",
596 )?;
597 Ok((stored_size, expires_at))
598 }
599}
600
601struct ObjectFile {
602 metadata: Metadata,
603 preamble_len: u64,
604 payload_size: u64,
605 reader: BufReader<tokio::fs::File>,
606}
607
608async fn write_metadata_preamble<W>(writer: &mut W, metadata: &Metadata) -> Result<u64>
609where
610 W: tokio::io::AsyncWrite + Unpin,
611{
612 let metadata_json = serde_json::to_string(metadata)
613 .context(ErrorKind::Internal, "encoding local-fs object metadata")?;
614 writer.write_all(metadata_json.as_bytes()).await.context(
615 ErrorKind::BackendFailure,
616 "writing local-fs object metadata",
617 )?;
618 writer.write_all(b"\n").await.context(
619 ErrorKind::BackendFailure,
620 "writing local-fs object metadata",
621 )?;
622
623 Ok(metadata_json.len() as u64 + 1)
624}
625
626async fn read_metadata_preamble<R>(reader: &mut R) -> Result<(Metadata, usize)>
627where
628 R: tokio::io::AsyncBufRead + Unpin,
629{
630 let mut metadata_line = String::new();
631 let preamble_len = reader.read_line(&mut metadata_line).await.context(
632 ErrorKind::BackendFailure,
633 "reading local-fs object metadata",
634 )?;
635 let metadata = serde_json::from_str(metadata_line.trim_end())
636 .context(ErrorKind::CorruptData, "decoding local-fs object metadata")?;
637 Ok((metadata, preamble_len))
638}
639
640impl ObjectFile {
641 async fn try_open(path: &Path, access_time: Timestamp) -> Result<Option<Self>> {
643 let file = match OpenOptions::new().read(true).open(path).await {
644 Ok(file) => file,
645 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
646 result => result.context(ErrorKind::BackendFailure, "opening local-fs object")?,
647 };
648
649 let mut reader = BufReader::new(file);
650 let (mut metadata, preamble_len) = read_metadata_preamble(&mut reader).await?;
651
652 if metadata.is_expired(access_time) {
653 objectstore_log::debug!("Object found but past expiry");
654 return Ok(None);
655 }
656
657 let preamble_len = preamble_len as u64;
658 let file_len = reader
659 .get_ref()
660 .metadata()
661 .await
662 .context(ErrorKind::BackendFailure, "reading local-fs object size")?
663 .len();
664
665 let payload_size = file_len.checked_sub(preamble_len).ok_or_else(|| {
666 Error::new(ErrorKind::CorruptData, "reading truncated local-fs object")
667 })?;
668
669 metadata.size = Some(payload_size as usize);
670
671 Ok(Some(Self {
672 metadata,
673 preamble_len,
674 payload_size,
675 reader,
676 }))
677 }
678}
679
680struct Draft {
681 writer: BufWriter<tokio::fs::File>,
682 path: tempfile::TempPath,
683 target: PathBuf,
684 preamble_len: u64,
686}
687
688impl Draft {
689 async fn create(target: &Path, metadata: &Metadata) -> Result<Self> {
690 let parent = target.parent().unwrap().to_path_buf();
691 let tempfile = tokio::task::spawn_blocking(move || create_tempfile(&parent))
692 .await
693 .context(
694 ErrorKind::Internal,
695 "waiting for local-fs object draft creation",
696 )?
697 .context(ErrorKind::BackendFailure, "creating local-fs object draft")?;
698 let (file, path) = tempfile.into_parts();
699
700 let mut writer = BufWriter::new(tokio::fs::File::from_std(file));
701 let preamble_len = write_metadata_preamble(&mut writer, metadata).await?;
702
703 Ok(Self {
704 writer,
705 path,
706 target: target.to_path_buf(),
707 preamble_len,
708 })
709 }
710
711 fn writer(&mut self) -> &mut BufWriter<tokio::fs::File> {
712 &mut self.writer
713 }
714
715 fn preamble_len(&self) -> u64 {
717 self.preamble_len
718 }
719
720 async fn prepare(&mut self) -> Result<()> {
721 self.writer
722 .flush()
723 .await
724 .context(ErrorKind::BackendFailure, "flushing local-fs object draft")?;
725 self.writer
726 .get_ref()
727 .sync_data()
728 .await
729 .context(ErrorKind::BackendFailure, "syncing local-fs object draft")
730 }
731
732 async fn publish(self) -> Result<()> {
734 let Self {
735 writer,
736 path,
737 target,
738 ..
739 } = self;
740
741 drop(writer);
742 tokio::task::spawn_blocking(move || path.persist(target))
743 .await
744 .context(
745 ErrorKind::Internal,
746 "waiting to publish local-fs object draft",
747 )?
748 .context(
749 ErrorKind::BackendFailure,
750 "publishing local-fs object draft",
751 )
752 }
753}
754
755fn create_tempfile(parent: &Path) -> io::Result<tempfile::NamedTempFile> {
756 let mut builder = tempfile::Builder::new();
757 builder.suffix(".draft");
758
759 builder.tempfile_in(parent)
760}
761
762#[cfg(test)]
763mod tests {
764 use std::collections::HashMap;
765 use std::sync::Arc;
766 use std::time::Duration;
767
768 use bytes::{Bytes, BytesMut};
769 use futures_util::{TryStreamExt, stream as futures_stream};
770 use objectstore_types::metadata::{Compression, ExpirationPolicy};
771 use objectstore_types::scope::{Scope, Scopes};
772 use objectstore_types::time::Timestamp;
773
774 #[cfg(feature = "storage-cogs")]
775 use objectstore_inventory_tracker::OpType;
776 #[cfg(feature = "storage-cogs")]
777 use objectstore_inventory_tracker::test_utils::DummyProducer;
778
779 use super::*;
780 use crate::backend::common::ExpiryTarget;
781 use crate::id::ObjectContext;
782 use crate::stream;
783
784 async fn upload_token(backend: &LocalFsBackend, id: &ObjectId, length: u64) -> Session {
785 let backend_token = backend
786 .create_upload_session(id, &Metadata::default(), NonZeroU64::new(length).unwrap())
787 .await
788 .unwrap()
789 .unwrap();
790 Session {
791 object_id: id.clone(),
792 upload_length: NonZeroU64::new(length).unwrap(),
793 backend_token,
794 }
795 }
796
797 #[tokio::test]
798 async fn resumable_upload() {
799 let (_tempdir, backend) = make_backend();
800 let id = make_id();
801 let metadata = Metadata {
802 content_type: "text/resumable".into(),
803 ..Default::default()
804 };
805
806 let upload_length = NonZeroU64::new(6).unwrap();
808 let token = backend
809 .create_upload_session(&id, &metadata, upload_length)
810 .await
811 .unwrap()
812 .unwrap();
813 let token = Session {
814 object_id: id.clone(),
815 upload_length,
816 backend_token: token,
817 };
818 let upload_id = Uuid::parse_str(&token.backend_token).unwrap();
819 let upload_path = backend.upload_path(upload_id);
820 assert_eq!(
821 upload_path.parent().unwrap().file_name().unwrap(),
822 "uploads"
823 );
824 assert!(!backend.path(&id).exists());
825
826 assert_eq!(
828 backend.upload_offset(&token).await.unwrap(),
829 UploadProgress::Incomplete { offset: 0 }
830 );
831 assert_eq!(
832 backend
833 .put_chunk(&token, 2, 0, stream::single(""))
834 .await
835 .unwrap(),
836 UploadProgress::Incomplete { offset: 0 }
837 );
838 assert_eq!(
839 backend
840 .put_chunk(&token, 0, 3, stream::single("abc"))
841 .await
842 .unwrap(),
843 UploadProgress::Incomplete { offset: 3 }
844 );
845 assert!(!backend.path(&id).exists());
846 assert_eq!(
847 backend.upload_offset(&token).await.unwrap(),
848 UploadProgress::Incomplete { offset: 3 }
849 );
850
851 assert_eq!(
853 backend
854 .put_chunk(&token, 0, 0, stream::single(""))
855 .await
856 .unwrap(),
857 UploadProgress::Incomplete { offset: 3 }
858 );
859 let error = backend
860 .put_chunk(&token, 0, 3, stream::single("abc"))
861 .await
862 .unwrap_err();
863 assert_eq!(error.kind(), ErrorKind::UploadOffsetMismatch { offset: 3 });
864
865 assert_eq!(
867 backend
868 .put_chunk(&token, 3, 3, stream::single("def"))
869 .await
870 .unwrap(),
871 UploadProgress::Complete
872 );
873 assert!(!upload_path.exists());
874 let (stored_metadata, _, payload) = backend
875 .get_object(&id, Timestamp::now(), None)
876 .await
877 .unwrap()
878 .unwrap();
879 assert_eq!(stored_metadata.content_type, metadata.content_type);
880 assert_eq!(stream::read_to_vec(payload).await.unwrap(), b"abcdef");
881 assert_eq!(
882 backend.upload_offset(&token).await.unwrap_err().kind(),
883 ErrorKind::UnknownUploadSession
884 );
885 }
886
887 #[tokio::test]
888 async fn resumable_publication_can_be_retried() {
889 for query_offset in [false, true] {
890 let (_tempdir, backend) = make_backend();
891 let id = make_id();
892 let token = upload_token(&backend, &id, 4).await;
893 let object_path = backend.path(&id);
894
895 tokio::fs::create_dir_all(&object_path).await.unwrap();
897 let error = backend
898 .put_chunk(&token, 0, 4, stream::single("data"))
899 .await
900 .unwrap_err();
901 assert_eq!(error.kind(), ErrorKind::BackendFailure);
902 tokio::fs::remove_dir(&object_path).await.unwrap();
903
904 let progress = if query_offset {
905 backend.upload_offset(&token).await
906 } else {
907 backend.put_chunk(&token, 4, 0, stream::single("")).await
908 };
909 assert_eq!(progress.unwrap(), UploadProgress::Complete);
910 let (_, _, payload) = backend
911 .get_object(&id, Timestamp::now(), None)
912 .await
913 .unwrap()
914 .unwrap();
915 assert_eq!(stream::read_to_vec(payload).await.unwrap(), b"data");
916 assert_eq!(
917 backend.upload_offset(&token).await.unwrap_err().kind(),
918 ErrorKind::UnknownUploadSession
919 );
920 }
921 }
922
923 #[tokio::test]
924 async fn failed_resumable_chunk_preserves_partial_progress() {
925 let (_tempdir, backend) = make_backend();
926 let id = make_id();
927 let token = upload_token(&backend, &id, 4).await;
928
929 backend
931 .put_chunk(&token, 0, 2, stream::single("ab"))
932 .await
933 .unwrap();
934 let broken = futures_stream::iter([
935 Ok(Bytes::from_static(b"c")),
936 Err(stream::ClientError::new(io::Error::other(
937 "client disconnected",
938 ))),
939 ])
940 .boxed();
941 let error = backend.put_chunk(&token, 2, 2, broken).await.unwrap_err();
942 assert_eq!(error.kind(), ErrorKind::ClientStream);
943
944 assert_eq!(
946 backend.upload_offset(&token).await.unwrap(),
947 UploadProgress::Incomplete { offset: 3 }
948 );
949 assert_eq!(
950 backend
951 .put_chunk(&token, 3, 1, stream::single("d"))
952 .await
953 .unwrap(),
954 UploadProgress::Complete
955 );
956 let (_, _, payload) = backend
957 .get_object(&id, Timestamp::now(), None)
958 .await
959 .unwrap()
960 .unwrap();
961 assert_eq!(stream::read_to_vec(payload).await.unwrap(), b"abcd");
962 }
963
964 #[tokio::test]
965 async fn concurrent_uploads_for_same_object_do_not_deadlock() {
966 let (_tempdir, backend) = make_backend();
967 let id = make_id();
968 let first = upload_token(&backend, &id, 3).await;
969 let second = upload_token(&backend, &id, 3).await;
970
971 let writes = async {
973 tokio::join!(
974 backend.put_chunk(&first, 0, 3, stream::single("one")),
975 backend.put_chunk(&second, 0, 3, stream::single("two")),
976 )
977 };
978 let (first_result, second_result) = tokio::time::timeout(Duration::from_secs(2), writes)
979 .await
980 .unwrap();
981
982 assert_eq!(first_result.unwrap(), UploadProgress::Complete);
984 assert_eq!(second_result.unwrap(), UploadProgress::Complete);
985 let (_, _, payload) = backend
986 .get_object(&id, Timestamp::now(), None)
987 .await
988 .unwrap()
989 .unwrap();
990 let payload = stream::read_to_vec(payload).await.unwrap();
991 assert!(payload == b"one" || payload == b"two");
992 }
993
994 #[tokio::test]
995 async fn stores_metadata() {
996 let tempdir = tempfile::tempdir().unwrap();
997 let backend = LocalFsBackend::new(
998 FileSystemConfig {
999 path: tempdir.path().to_path_buf(),
1000 cogs: None,
1001 },
1002 &ChangeStreamFactory::default(),
1003 );
1004
1005 let id = ObjectId::random(ObjectContext {
1006 usecase: "testing".parse().unwrap(),
1007 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1008 });
1009
1010 let metadata = Metadata {
1011 content_type: "text/plain".into(),
1012 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1013 time_created: Some(Timestamp::now()),
1014 time_expires: Some(Timestamp::now() + Duration::from_hours(1)),
1015 compression: Some(Compression::Zstd),
1016 origin: Some("203.0.113.42".into()),
1017 filename: Some("hello.txt".into()),
1018 custom: [("foo".into(), "bar".into())].into(),
1019 size: None,
1020 };
1021 backend
1022 .put_object(&id, &metadata, stream::single("oh hai!"), Timestamp::now())
1023 .await
1024 .unwrap();
1025
1026 let (read_metadata, _, stream) = backend
1027 .get_object(&id, Timestamp::now(), None)
1028 .await
1029 .unwrap()
1030 .unwrap();
1031 let file_contents: BytesMut = stream.try_collect().await.unwrap();
1032
1033 assert_eq!(
1034 read_metadata,
1035 Metadata {
1036 size: Some(file_contents.len()),
1037 ..metadata
1038 }
1039 );
1040 assert_eq!(file_contents.as_ref(), b"oh hai!");
1041
1042 let lock_path = backend.locks.path(&id);
1043 assert!(lock_path.exists());
1044 backend.delete_object(&id, Timestamp::now()).await.unwrap();
1045 assert!(lock_path.exists());
1046 }
1047
1048 #[tokio::test]
1049 async fn object_locks_coordinate_by_slot() {
1050 let tempdir = tempfile::tempdir().unwrap();
1051 let first_locks = ObjectLocks::new(tempdir.path());
1052 let second_locks = ObjectLocks::new(tempdir.path());
1053 let mut slots = HashMap::new();
1054 let (first_id, colliding_id) = (0..=65_536)
1055 .find_map(|key| {
1056 let id = ObjectId::from_parts(
1057 "testing".parse().unwrap(),
1058 Scopes::empty(),
1059 key.to_string(),
1060 );
1061 let path = first_locks.path(&id);
1062 slots.insert(path, id.clone()).map(|first| (first, id))
1063 })
1064 .expect("65,537 keys must collide in 65,536 slots");
1065 let lock_path = first_locks.path(&first_id);
1066 let other_id = slots
1067 .values()
1068 .find(|id| first_locks.path(id) != lock_path)
1069 .unwrap();
1070 assert_eq!(second_locks.path(&colliding_id), lock_path);
1071
1072 let first_guard = first_locks.acquire(&first_id).await.unwrap();
1073 let mut waiter =
1074 tokio::spawn(async move { second_locks.acquire(&colliding_id).await.unwrap() });
1075 assert!(
1076 tokio::time::timeout(Duration::from_millis(50), &mut waiter)
1077 .await
1078 .is_err()
1079 );
1080
1081 let other_guard =
1082 tokio::time::timeout(Duration::from_secs(1), first_locks.acquire(other_id))
1083 .await
1084 .unwrap()
1085 .unwrap();
1086 drop(other_guard);
1087 drop(first_guard);
1088 let guard = tokio::time::timeout(Duration::from_secs(1), waiter)
1089 .await
1090 .unwrap()
1091 .unwrap();
1092 drop(guard);
1093 assert!(lock_path.exists());
1094 }
1095
1096 #[tokio::test]
1097 async fn missing_object_expiry_does_not_block_descendant() {
1098 let (_tempdir, backend) = make_backend();
1099 let id = ObjectId::from_parts("testing".parse().unwrap(), Scopes::empty(), "foo".into());
1100 assert_eq!(
1101 backend
1102 .set_expiry(
1103 &id,
1104 ExpiryTarget::At(Timestamp::now() + Duration::from_hours(1)).into(),
1105 Timestamp::now()
1106 )
1107 .await
1108 .unwrap(),
1109 SetExpiryResponse::NotFound
1110 );
1111 let descendant = ObjectId::new(id.context.clone(), "foo/bar".into());
1112 backend
1113 .put_object(
1114 &descendant,
1115 &Metadata::default(),
1116 stream::single("payload"),
1117 Timestamp::now(),
1118 )
1119 .await
1120 .unwrap();
1121 let (_, _, payload) = backend
1122 .get_object(&descendant, Timestamp::now(), None)
1123 .await
1124 .unwrap()
1125 .unwrap();
1126 assert_eq!(stream::read_to_vec(payload).await.unwrap(), b"payload");
1127 }
1128
1129 #[tokio::test]
1130 async fn failed_put_preserves_published_object() {
1131 let (_tempdir, backend) = make_backend();
1132 let id = make_id();
1133 let original_metadata = Metadata {
1134 content_type: "text/original".into(),
1135 ..Default::default()
1136 };
1137 backend
1138 .put_object(
1139 &id,
1140 &original_metadata,
1141 stream::single("original"),
1142 Timestamp::now(),
1143 )
1144 .await
1145 .unwrap();
1146
1147 let replacement_metadata = Metadata {
1148 content_type: "text/replacement".into(),
1149 ..Default::default()
1150 };
1151 let replacement = futures_stream::iter([
1152 Ok(Bytes::from_static(b"partial")),
1153 Err(stream::ClientError::new(io::Error::other(
1154 "replacement stream failed",
1155 ))),
1156 ])
1157 .boxed();
1158 let error = backend
1159 .put_object(&id, &replacement_metadata, replacement, Timestamp::now())
1160 .await
1161 .unwrap_err();
1162 assert_eq!(error.kind(), ErrorKind::ClientStream);
1163
1164 let (metadata, _, payload) = backend
1165 .get_object(&id, Timestamp::now(), None)
1166 .await
1167 .unwrap()
1168 .unwrap();
1169 assert_eq!(metadata.content_type, original_metadata.content_type);
1170 assert_eq!(stream::read_to_vec(payload).await.unwrap(), b"original");
1171
1172 assert_eq!(draft_count(&backend, &id), 0);
1173 }
1174
1175 #[tokio::test]
1176 async fn cancelled_put_removes_draft() {
1177 let (_tempdir, backend) = make_backend();
1178 let backend = Arc::new(backend);
1179 let id = make_id();
1180 backend
1181 .put_object(
1182 &id,
1183 &Metadata::default(),
1184 stream::single("original"),
1185 Timestamp::now(),
1186 )
1187 .await
1188 .unwrap();
1189
1190 let (writing, writing_started) = tokio::sync::oneshot::channel();
1191 let replacement = futures_stream::once(async move {
1192 let _ = writing.send(());
1193 Ok(Bytes::from_static(b"partial"))
1194 })
1195 .chain(futures_stream::pending())
1196 .boxed();
1197
1198 let task = tokio::spawn({
1199 let backend = Arc::clone(&backend);
1200 let id = id.clone();
1201 async move {
1202 backend
1203 .put_object(&id, &Metadata::default(), replacement, Timestamp::now())
1204 .await
1205 }
1206 });
1207
1208 writing_started.await.unwrap();
1209 assert_eq!(draft_count(&backend, &id), 1);
1210 task.abort();
1211 assert!(task.await.unwrap_err().is_cancelled());
1212
1213 let (_, _, payload) = backend
1214 .get_object(&id, Timestamp::now(), None)
1215 .await
1216 .unwrap()
1217 .unwrap();
1218 assert_eq!(stream::read_to_vec(payload).await.unwrap(), b"original");
1219
1220 assert_eq!(draft_count(&backend, &id), 0);
1221 }
1222
1223 #[tokio::test]
1224 async fn set_expiry() {
1225 for is_ttl in [false, true] {
1226 let (_tempdir, backend) = make_backend();
1227 let id = make_id();
1228 let created = Timestamp::now();
1229 let old_expiry = created + Duration::from_hours(1);
1230 let metadata = Metadata {
1231 expiration_policy: if is_ttl {
1232 ExpirationPolicy::TimeToLive(Duration::from_hours(1))
1233 } else {
1234 ExpirationPolicy::TimeToIdle(Duration::from_hours(1))
1235 },
1236 time_created: Some(created),
1237 time_expires: Some(old_expiry),
1238 custom: [("preserved".into(), "yes".into())].into(),
1239 ..Default::default()
1240 };
1241 backend
1242 .put_object(&id, &metadata, stream::single("payload"), Timestamp::now())
1243 .await
1244 .unwrap();
1245
1246 let duration = Duration::from_hours(2) + Duration::from_nanos(999);
1247 let requested = created + duration;
1248 for target in [
1249 ExpiryTarget::At(requested),
1250 ExpiryTarget::FromCreation(duration),
1251 ] {
1252 assert_eq!(
1253 backend
1254 .set_expiry(&id, target.into(), Timestamp::now())
1255 .await
1256 .unwrap(),
1257 SetExpiryResponse::Satisfied(requested)
1258 );
1259 }
1260 let (updated, _, payload) = backend
1261 .get_object(&id, Timestamp::now(), None)
1262 .await
1263 .unwrap()
1264 .unwrap();
1265 assert_eq!(
1266 updated.expiration_policy,
1267 if is_ttl {
1268 ExpirationPolicy::TimeToLive(Duration::from_hours(2))
1269 } else {
1270 metadata.expiration_policy
1271 }
1272 );
1273 assert_eq!(updated.time_created, metadata.time_created);
1274 assert_eq!(updated.custom, metadata.custom);
1275 assert_eq!(updated.time_expires, Some(requested));
1276 assert_eq!(stream::read_to_vec(payload).await.unwrap(), b"payload");
1277
1278 assert_eq!(draft_count(&backend, &id), 0);
1279 }
1280 }
1281
1282 #[tokio::test]
1283 async fn expired_object() {
1284 let (_tempdir, backend) = make_backend();
1285 let id = make_id();
1286 let metadata = Metadata {
1287 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1288 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
1289 ..Default::default()
1290 };
1291 backend
1292 .put_object(&id, &metadata, stream::single("expired"), Timestamp::now())
1293 .await
1294 .unwrap();
1295
1296 assert!(
1297 backend
1298 .get_object(&id, Timestamp::now(), None)
1299 .await
1300 .unwrap()
1301 .is_none()
1302 );
1303 assert_eq!(
1304 backend
1305 .set_expiry(
1306 &id,
1307 ExpiryTarget::At(Timestamp::now() + Duration::from_hours(1)).into(),
1308 Timestamp::now()
1309 )
1310 .await
1311 .unwrap(),
1312 SetExpiryResponse::NotFound
1313 );
1314 }
1315
1316 #[tokio::test]
1317 async fn get_metadata_returns_metadata() {
1318 let tempdir = tempfile::tempdir().unwrap();
1319 let backend = LocalFsBackend::new(
1320 FileSystemConfig {
1321 path: tempdir.path().to_path_buf(),
1322 cogs: None,
1323 },
1324 &ChangeStreamFactory::default(),
1325 );
1326
1327 let id = ObjectId::random(ObjectContext {
1328 usecase: "testing".parse().unwrap(),
1329 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1330 });
1331
1332 let metadata = Metadata {
1333 content_type: "text/plain".into(),
1334 compression: Some(Compression::Zstd),
1335 origin: Some("203.0.113.42".into()),
1336 custom: [("foo".into(), "bar".into())].into(),
1337 ..Default::default()
1338 };
1339 backend
1340 .put_object(&id, &metadata, stream::single("oh hai!"), Timestamp::now())
1341 .await
1342 .unwrap();
1343
1344 let read_metadata = backend
1345 .get_metadata(&id, Timestamp::now())
1346 .await
1347 .unwrap()
1348 .unwrap();
1349 assert_eq!(
1350 read_metadata,
1351 Metadata {
1352 size: Some(7),
1353 ..metadata
1354 }
1355 );
1356 }
1357
1358 #[tokio::test]
1359 async fn get_metadata_nonexistent() {
1360 let tempdir = tempfile::tempdir().unwrap();
1361 let backend = LocalFsBackend::new(
1362 FileSystemConfig {
1363 path: tempdir.path().to_path_buf(),
1364 cogs: None,
1365 },
1366 &ChangeStreamFactory::default(),
1367 );
1368
1369 let id = ObjectId::random(ObjectContext {
1370 usecase: "testing".parse().unwrap(),
1371 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1372 });
1373
1374 let result = backend.get_metadata(&id, Timestamp::now()).await.unwrap();
1375 assert!(result.is_none());
1376 }
1377
1378 fn make_id() -> ObjectId {
1379 ObjectId::random(ObjectContext {
1380 usecase: "testing".parse().unwrap(),
1381 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1382 })
1383 }
1384
1385 #[cfg(feature = "storage-cogs")]
1386 fn make_backend_with_change_stream() -> (tempfile::TempDir, LocalFsBackend, DummyProducer) {
1387 let tempdir = tempfile::tempdir().unwrap();
1388 let (streams, producer) = crate::change_stream::dummy_factory();
1389 let backend = LocalFsBackend::new(
1390 FileSystemConfig {
1391 path: tempdir.path().to_path_buf(),
1392 cogs: Some(CostTrackerStreamConfig {
1393 shared_resource_id: "filesystem_objectstore".into(),
1394 sample_rate: 1.0,
1395 }),
1396 },
1397 &streams,
1398 );
1399 (tempdir, backend, producer)
1400 }
1401
1402 fn make_backend() -> (tempfile::TempDir, LocalFsBackend) {
1403 let tempdir = tempfile::tempdir().unwrap();
1404 let backend = LocalFsBackend::new(
1405 FileSystemConfig {
1406 path: tempdir.path().to_path_buf(),
1407 cogs: None,
1408 },
1409 &ChangeStreamFactory::default(),
1410 );
1411 (tempdir, backend)
1412 }
1413
1414 fn draft_count(backend: &LocalFsBackend, id: &ObjectId) -> usize {
1415 let object_path = backend.path(id);
1416 std::fs::read_dir(object_path.parent().unwrap())
1417 .unwrap()
1418 .flat_map(|entry| entry.map(|entry| entry.path()))
1419 .filter(|path| path.to_string_lossy().ends_with(".draft"))
1420 .count()
1421 }
1422
1423 #[tokio::test]
1424 async fn get_object_range_bounded() {
1425 let (_tempdir, backend) = make_backend();
1426 let id = make_id();
1427 let metadata = Metadata::default();
1428
1429 let payload = b"Hello, range requests!";
1430 backend
1431 .put_object(
1432 &id,
1433 &metadata,
1434 stream::single(payload.to_vec()),
1435 Timestamp::now(),
1436 )
1437 .await
1438 .unwrap();
1439
1440 let (_, content_range, body) = backend
1442 .get_object(&id, Timestamp::now(), Some(ByteRange::Bounded(7, 11)))
1443 .await
1444 .unwrap()
1445 .unwrap();
1446 let data: BytesMut = body.try_collect().await.unwrap();
1447
1448 assert_eq!(data.as_ref(), b"range");
1449 let content_range = content_range.unwrap();
1450 assert_eq!(content_range.start, 7);
1451 assert_eq!(content_range.end, 11);
1452 assert_eq!(content_range.total, payload.len() as u64);
1453 }
1454
1455 #[tokio::test]
1456 async fn get_object_range_from() {
1457 let (_tempdir, backend) = make_backend();
1458 let id = make_id();
1459 let metadata = Metadata::default();
1460
1461 let payload = b"Hello, range requests!";
1462 backend
1463 .put_object(
1464 &id,
1465 &metadata,
1466 stream::single(payload.to_vec()),
1467 Timestamp::now(),
1468 )
1469 .await
1470 .unwrap();
1471
1472 let (_, content_range, body) = backend
1474 .get_object(&id, Timestamp::now(), Some(ByteRange::From(7)))
1475 .await
1476 .unwrap()
1477 .unwrap();
1478 let data: BytesMut = body.try_collect().await.unwrap();
1479
1480 assert_eq!(data.as_ref(), b"range requests!");
1481 let content_range = content_range.unwrap();
1482 assert_eq!(content_range.start, 7);
1483 assert_eq!(content_range.end, 21);
1484 assert_eq!(content_range.total, payload.len() as u64);
1485 }
1486
1487 #[tokio::test]
1488 async fn get_object_range_last() {
1489 let (_tempdir, backend) = make_backend();
1490 let id = make_id();
1491 let metadata = Metadata::default();
1492
1493 let payload = b"Hello, range requests!";
1494 backend
1495 .put_object(
1496 &id,
1497 &metadata,
1498 stream::single(payload.to_vec()),
1499 Timestamp::now(),
1500 )
1501 .await
1502 .unwrap();
1503
1504 let (_, content_range, body) = backend
1506 .get_object(&id, Timestamp::now(), Some(ByteRange::Last(9)))
1507 .await
1508 .unwrap()
1509 .unwrap();
1510 let data: BytesMut = body.try_collect().await.unwrap();
1511
1512 assert_eq!(data.as_ref(), b"requests!");
1513 let content_range = content_range.unwrap();
1514 assert_eq!(content_range.start, 13);
1515 assert_eq!(content_range.end, 21);
1516 assert_eq!(content_range.total, payload.len() as u64);
1517 }
1518
1519 #[tokio::test]
1520 async fn get_object_range_unsatisfiable() {
1521 let (_tempdir, backend) = make_backend();
1522 let id = make_id();
1523 let metadata = Metadata::default();
1524
1525 backend
1526 .put_object(
1527 &id,
1528 &metadata,
1529 stream::single(b"short".to_vec()),
1530 Timestamp::now(),
1531 )
1532 .await
1533 .unwrap();
1534
1535 match backend
1536 .get_object(&id, Timestamp::now(), Some(ByteRange::From(100)))
1537 .await
1538 {
1539 Err(error) if matches!(error.kind(), ErrorKind::RangeNotSatisfiable { total: 5 }) => {}
1540 Err(other) => panic!("expected RangeNotSatisfiable, got: {other:?}"),
1541 Ok(_) => panic!("expected RangeNotSatisfiable, got Ok"),
1542 }
1543 }
1544
1545 #[cfg(feature = "storage-cogs")]
1546 #[tokio::test]
1547 async fn change_stream_reports_size_written_to_disk() {
1548 let (tempdir, backend, producer) = make_backend_with_change_stream();
1549 let id = make_id();
1550 let metadata = Metadata {
1551 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1552 time_expires: Some(Timestamp::now() + Duration::from_hours(1)),
1553 ..Default::default()
1554 };
1555 let payload = b"oh hai!";
1556
1557 backend
1558 .put_object(
1559 &id,
1560 &metadata,
1561 stream::single(payload.to_vec()),
1562 Timestamp::now(),
1563 )
1564 .await
1565 .unwrap();
1566
1567 let file = tokio::fs::read(tempdir.path().join(id.as_storage_path().to_string()))
1568 .await
1569 .unwrap();
1570
1571 let records = producer.records();
1572 assert_eq!(records.len(), 1);
1573 assert_eq!(records[0].op_type, OpType::Write);
1574 assert_eq!(records[0].shared_resource_id, "filesystem_objectstore");
1575 assert_eq!(records[0].app_feature, "testing");
1576 assert_eq!(
1577 records[0].size,
1578 Some(file.len() as u64),
1579 "the metadata header line counts towards the reported size"
1580 );
1581 assert!(records[0].expiration_time.is_some());
1582 }
1583
1584 #[cfg(feature = "storage-cogs")]
1585 #[tokio::test]
1586 async fn change_stream_reports_resumable_upload_size() {
1587 let (tempdir, backend, producer) = make_backend_with_change_stream();
1588 let id = make_id();
1589 let metadata = Metadata {
1590 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1591 time_expires: Some(Timestamp::now() + Duration::from_hours(1)),
1592 ..Default::default()
1593 };
1594 let payload = b"oh hai!";
1595 let upload_length = NonZeroU64::new(payload.len() as u64).unwrap();
1596 let token = backend
1597 .create_upload_session(&id, &metadata, upload_length)
1598 .await
1599 .unwrap()
1600 .unwrap();
1601 let token = Session {
1602 object_id: id.clone(),
1603 upload_length,
1604 backend_token: token,
1605 };
1606
1607 assert!(producer.records().is_empty());
1609
1610 assert_eq!(
1612 backend
1613 .put_chunk(
1614 &token,
1615 0,
1616 payload.len() as u64,
1617 stream::single(payload.to_vec()),
1618 )
1619 .await
1620 .unwrap(),
1621 UploadProgress::Complete
1622 );
1623
1624 let file = tokio::fs::read(tempdir.path().join(id.as_storage_path().to_string()))
1625 .await
1626 .unwrap();
1627 let records = producer.records();
1628 assert_eq!(records.len(), 1);
1629 assert_eq!(records[0].op_type, OpType::Write);
1630 assert_eq!(records[0].size, Some(file.len() as u64));
1631 assert!(records[0].expiration_time.is_some());
1632 }
1633
1634 #[cfg(feature = "storage-cogs")]
1635 #[tokio::test]
1636 async fn change_stream_reports_deletes_on_success() {
1637 let (_tempdir, backend, producer) = make_backend_with_change_stream();
1638 let id = make_id();
1639
1640 backend
1642 .delete_object(&id, Timestamp::now())
1643 .await
1644 .expect("deleting a non-existent object returns Ok(())");
1645 assert!(producer.records().is_empty());
1646
1647 backend
1648 .put_object(
1649 &id,
1650 &Metadata::default(),
1651 stream::single(b"hi".to_vec()),
1652 Timestamp::now(),
1653 )
1654 .await
1655 .unwrap();
1656 producer.clear();
1657
1658 backend.delete_object(&id, Timestamp::now()).await.unwrap();
1659
1660 let records = producer.records();
1661 assert_eq!(records.len(), 1);
1662 assert_eq!(records[0].op_type, OpType::Delete);
1663 }
1664}