Skip to main content

objectstore_service/backend/
local_fs.rs

1//! Local filesystem backend for development and testing.
2//!
3//! Complete object files are published by atomically renaming same-directory drafts, so readers
4//! observe either the previous or next complete file. Unpublished drafts are removed automatically
5//! when dropped.
6//!
7//! To avoid races on metadata, expiry, and upload updates, this backend uses locks placed under
8//! `.locks/` to synchronize mutations across backend instances and cooperating processes. The first
9//! two bytes of the BLAKE3 hash of an object's storage path select one of 65,536 permanent lock
10//! slots under `.locks/<first byte>/<second byte>` (lowercase hexadecimal). An object and all of its
11//! resumable uploads share the same lock.
12//!
13//! Shared filesystems are supported only when locks propagate across the cluster, pathname
14//! visibility is coherent, and rename is atomic.
15//!
16//! Newly created files and directories are owner-only on Unix (0600 and 0700 respectively,
17//! further restricted by umask). Existing paths retain their permissions.
18
19use 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
50/// Options for owner-only files on Unix, further restricted by umask.
51fn 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
61/// Creates owner-only directories on Unix without changing existing permissions.
62async 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/// Configuration for [`LocalFsBackend`].
71///
72/// Stores objects as files on the local filesystem. Suitable for development, testing,
73/// and single-server deployments.
74///
75/// # Example
76///
77/// ```yaml
78/// storage:
79///   type: filesystem
80///   path: /data
81/// ```
82#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
83pub struct FileSystemConfig {
84    /// Directory path for storing objects.
85    ///
86    /// The directory will be created if it doesn't exist. Relative paths are resolved from
87    /// the server's working directory.
88    ///
89    /// # Default
90    ///
91    /// `"data"` (relative to the server's working directory)
92    ///
93    /// # Environment Variables
94    ///
95    /// - `OS__STORAGE__TYPE=filesystem`
96    /// - `OS__STORAGE__PATH=/path/to/storage`
97    pub path: PathBuf,
98
99    /// Reports what this backend stores, for per-usecase cost attribution.
100    ///
101    /// # Default
102    ///
103    /// `None`, which disables reporting for this backend.
104    ///
105    /// # Environment Variables
106    ///
107    /// - `OS__STORAGE__COGS__SHARED_RESOURCE_ID=filesystem_objectstore`
108    /// - `OS__STORAGE__COGS__SAMPLE_RATE=1.0` (optional)
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub cogs: Option<CostTrackerStreamConfig>,
111}
112
113/// Local filesystem backend for development and testing.
114#[derive(Debug)]
115pub struct LocalFsBackend {
116    path: PathBuf,
117    locks: ObjectLocks,
118
119    change_stream: Arc<dyn ChangeStream>,
120}
121
122impl LocalFsBackend {
123    /// Creates a new [`LocalFsBackend`] rooted at the directory in `config`.
124    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    /// Returns the filesystem path for the given object ID.
135    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)); // already satisfied
262        }
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    // In this backend, if all the bytes of a resumable upload have been written but publication failed,
330    // an empty chunk or offset query will retry publication.
331    #[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
412// Must be lower than the tokio runtime `max_blocking_threads` setting.
413const 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    /// Acquires a slot lock, released when the returned file is dropped.
438    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        // Leave blocking-pool capacity available for the current lock holder's filesystem work.
446        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
468/// An open resumable upload containing a metadata preamble followed by payload bytes.
469struct UploadFile {
470    file: tokio::fs::File,
471    path: PathBuf,
472    /// Number of payload bytes stored after the metadata preamble.
473    payload_size: u64,
474    /// Total size and expiration, for reporting to the `ChangeStream`.
475    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    /// Publishes the upload; requires a successful `append()` with no subsequent writes.
584    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    /// Opens an object file, returning `None` when it does not exist.
642    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    /// Bytes the metadata preamble occupies, counted as it is written.
685    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    /// Bytes the metadata preamble occupies, for sizing the stored object.
716    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    /// Publishes the draft; requires a successful `prepare()` with no subsequent writes.
733    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        // Create a session and verify its initial state on disk.
807        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        // Upload the first chunk and verify that only the session advances.
827        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        // Zero-length chunks report current progress, while stale non-empty offsets fail.
852        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        // Upload the final chunk and verify atomic publication removes the session.
866        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            // A directory at the destination prevents rename after all bytes are persisted.
896            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        // Persist a prefix, then disconnect after writing one byte of the next chunk.
930        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        // Resume from the partial byte and verify the complete object.
945        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        // Complete both sessions concurrently through their shared object lock.
972        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        // Both requests finish without deadlock; the last publication wins.
983        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        // Request bytes 7-11 → "range"
1441        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        // Request bytes 7- → "range requests!"
1473        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        // Request last 9 bytes → "requests!"
1505        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        // Session creation alone does not report a stored object.
1608        assert!(producer.records().is_empty());
1609
1610        // Completing the upload reports the published file's full stored size.
1611        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        // Try to delete a non-existent object. Don't emit a message.
1641        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}