diff --git a/Cargo.lock b/Cargo.lock index 3431864c..4c80df6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2566,6 +2566,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "base64", "bigtable_rs", "bytes", "chrono", diff --git a/objectstore-server/src/config.rs b/objectstore-server/src/config.rs index 297ffecd..c8b2eb13 100644 --- a/objectstore-server/src/config.rs +++ b/objectstore-server/src/config.rs @@ -46,7 +46,7 @@ use secrecy::{CloneableSecret, SecretBox, SerializableSecret, zeroize::Zeroize}; use serde::{Deserialize, Serialize}; pub use objectstore_log::{LevelFilter, LogFormat, LoggingConfig}; -pub use objectstore_service::backend::StorageConfig; +pub use objectstore_service::backend::{MultipartUploadStorageConfig, StorageConfig}; use crate::killswitches::Killswitches; use crate::rate_limits::RateLimits; @@ -625,7 +625,7 @@ impl Config { mod tests { use std::io::Write; - use objectstore_service::backend::HighVolumeStorageConfig; + use objectstore_service::backend::{HighVolumeStorageConfig, MultipartUploadStorageConfig}; use secrecy::ExposeSecret; use crate::killswitches::Killswitch; @@ -771,7 +771,7 @@ mod tests { }; let HighVolumeStorageConfig::BigTable(hv) = &c.high_volume; assert_eq!(hv.project_id, "my-project"); - let StorageConfig::Gcs(lt) = c.long_term.as_ref() else { + let MultipartUploadStorageConfig::Gcs(lt) = &c.long_term else { panic!("expected gcs long_term"); }; assert_eq!(lt.bucket, "my-objectstore-bucket"); @@ -800,7 +800,7 @@ mod tests { assert_eq!(hv.project_id, "my-project"); assert_eq!(hv.instance_name, "my-instance"); assert_eq!(hv.table_name, "my-table"); - let StorageConfig::FileSystem(lt) = c.long_term.as_ref() else { + let MultipartUploadStorageConfig::FileSystem(lt) = &c.long_term else { panic!("expected filesystem long_term"); }; assert_eq!(lt.path, Path::new("/data/lt")); diff --git a/objectstore-service/Cargo.toml b/objectstore-service/Cargo.toml index be44d790..e4434d42 100644 --- a/objectstore-service/Cargo.toml +++ b/objectstore-service/Cargo.toml @@ -12,6 +12,7 @@ publish = false [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +base64 = "0.22" bigtable_rs = { git = "https://github.com/getsentry/bigtable_rs.git", rev = "4cb75bc5e5f87204363973f6302107768e64972e" } chrono = "0.4" bytes = { workspace = true } diff --git a/objectstore-service/src/backend/changelog.rs b/objectstore-service/src/backend/changelog.rs index 0e05bb84..548be885 100644 --- a/objectstore-service/src/backend/changelog.rs +++ b/objectstore-service/src/backend/changelog.rs @@ -27,7 +27,7 @@ use std::time::Duration; use tokio_util::task::TaskTracker; use tokio_util::task::task_tracker::TaskTrackerToken; -use crate::backend::common::{Backend, HighVolumeBackend, TieredMetadata}; +use crate::backend::common::{HighVolumeBackend, MultipartUploadBackend, TieredMetadata}; use crate::error::Result; use crate::id::ObjectId; @@ -88,7 +88,7 @@ pub struct ChangeManager { /// The backend for small objects (≤ 1 MiB). pub(crate) high_volume: Box, /// The backend for large objects (> 1 MiB). - pub(crate) long_term: Box, + pub(crate) long_term: Box, /// Durable write-ahead log for multi-step changes. pub(crate) changelog: Box, /// Tracks outstanding background cleanup operations for graceful shutdown. @@ -99,7 +99,7 @@ impl ChangeManager { /// Creates a new `ChangeManager` with the given backends and changelog. pub fn new( high_volume: Box, - long_term: Box, + long_term: Box, changelog: Box, ) -> Arc { Arc::new(Self { diff --git a/objectstore-service/src/backend/mod.rs b/objectstore-service/src/backend/mod.rs index 401aad1b..b4a8fbe0 100644 --- a/objectstore-service/src/backend/mod.rs +++ b/objectstore-service/src/backend/mod.rs @@ -60,7 +60,7 @@ pub async fn from_config(config: StorageConfig) -> Result { let hv = hv_from_config(c.high_volume).await?; - let lt = from_leaf_config(*c.long_term).await?; + let lt = lt_from_config(c.long_term).await?; let log = Box::new(changelog::NoopChangeLog); Box::new(tiered::TieredStorage::new(hv, lt, log)) } @@ -104,3 +104,28 @@ async fn hv_from_config( HighVolumeStorageConfig::BigTable(c) => Box::new(bigtable::BigTableBackend::new(c).await?), }) } + +/// Configuration for the long-term backend in a [`tiered::TieredStorageConfig`]. +/// +/// Only backends that implement [`common::MultipartUploadBackend`] are valid here. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum MultipartUploadStorageConfig { + /// Local filesystem storage backend (type `"filesystem"`). + FileSystem(local_fs::FileSystemConfig), + + /// [Google Cloud Storage] backend (type `"gcs"`). + /// + /// [Google Cloud Storage]: https://cloud.google.com/storage + Gcs(gcs::GcsConfig), +} + +/// Constructs a type-erased [`common::MultipartUploadBackend`] from the given config. +async fn lt_from_config( + config: MultipartUploadStorageConfig, +) -> anyhow::Result> { + Ok(match config { + MultipartUploadStorageConfig::FileSystem(c) => Box::new(local_fs::LocalFsBackend::new(c)), + MultipartUploadStorageConfig::Gcs(c) => Box::new(gcs::GcsBackend::new(c).await?), + }) +} diff --git a/objectstore-service/src/backend/testing.rs b/objectstore-service/src/backend/testing.rs index edfc813d..4b767e21 100644 --- a/objectstore-service/src/backend/testing.rs +++ b/objectstore-service/src/backend/testing.rs @@ -40,12 +40,16 @@ use bytes::Bytes; use objectstore_types::metadata::Metadata; use crate::backend::common::{ - Backend, DeleteResponse, GetResponse, HighVolumeBackend, MetadataResponse, PutResponse, - TieredGet, TieredMetadata, TieredWrite, Tombstone, + Backend, DeleteResponse, GetResponse, HighVolumeBackend, MetadataResponse, + MultipartUploadBackend, PutResponse, TieredGet, TieredMetadata, TieredWrite, Tombstone, }; use crate::backend::in_memory::InMemoryBackend; use crate::error::Result; use crate::id::ObjectId; +use crate::multipart::{ + AbortMultipartResponse, CompleteMultipartResponse, CompletedPart, InitiateMultipartResponse, + ListPartsResponse, PartNumber, UploadId, UploadPartResponse, +}; use crate::stream::ClientStream; /// Hooks for [`TestBackend`]. @@ -150,6 +154,77 @@ pub trait Hooks: fmt::Debug + Send + Sync + 'static { ) -> Result { inner.compare_and_write(id, current, write).await } + + // --- MultipartUploadBackend methods --- + + /// Intercepts [`MultipartUploadBackend::initiate_multipart`]. Default delegates to `inner`. + async fn initiate_multipart( + &self, + inner: &InMemoryBackend, + id: &ObjectId, + metadata: &Metadata, + ) -> Result { + inner.initiate_multipart(id, metadata).await + } + + /// Intercepts [`MultipartUploadBackend::upload_part`]. Default delegates to `inner`. + #[allow(clippy::too_many_arguments)] + async fn upload_part( + &self, + inner: &InMemoryBackend, + id: &ObjectId, + upload_id: &UploadId, + part_number: PartNumber, + content_length: u64, + content_md5: Option<&str>, + body: ClientStream, + ) -> Result { + inner + .upload_part( + id, + upload_id, + part_number, + content_length, + content_md5, + body, + ) + .await + } + + /// Intercepts [`MultipartUploadBackend::list_parts`]. Default delegates to `inner`. + async fn list_parts( + &self, + inner: &InMemoryBackend, + id: &ObjectId, + upload_id: &UploadId, + max_parts: Option, + part_number_marker: Option, + ) -> Result { + inner + .list_parts(id, upload_id, max_parts, part_number_marker) + .await + } + + /// Intercepts [`MultipartUploadBackend::abort_multipart`]. Default delegates to `inner`. + async fn abort_multipart( + &self, + inner: &InMemoryBackend, + id: &ObjectId, + upload_id: &UploadId, + ) -> Result { + inner.abort_multipart(id, upload_id).await + } + + /// Intercepts [`MultipartUploadBackend::complete_multipart`]. Default delegates to `inner`. + async fn complete_multipart( + &self, + inner: &InMemoryBackend, + id: &ObjectId, + upload_id: &UploadId, + parts: Vec, + ) -> Result { + inner.complete_multipart(id, upload_id, parts).await + } } /// Generic test backend that implements both [`Backend`] and [`HighVolumeBackend`]. @@ -258,3 +333,69 @@ impl HighVolumeBackend for TestBackend { .await } } + +#[async_trait::async_trait] +impl MultipartUploadBackend for TestBackend { + async fn initiate_multipart( + &self, + id: &ObjectId, + metadata: &Metadata, + ) -> Result { + self.hooks + .initiate_multipart(&self.inner, id, metadata) + .await + } + + async fn upload_part( + &self, + id: &ObjectId, + upload_id: &UploadId, + part_number: PartNumber, + content_length: u64, + content_md5: Option<&str>, + body: ClientStream, + ) -> Result { + self.hooks + .upload_part( + &self.inner, + id, + upload_id, + part_number, + content_length, + content_md5, + body, + ) + .await + } + + async fn list_parts( + &self, + id: &ObjectId, + upload_id: &UploadId, + max_parts: Option, + part_number_marker: Option, + ) -> Result { + self.hooks + .list_parts(&self.inner, id, upload_id, max_parts, part_number_marker) + .await + } + + async fn abort_multipart( + &self, + id: &ObjectId, + upload_id: &UploadId, + ) -> Result { + self.hooks.abort_multipart(&self.inner, id, upload_id).await + } + + async fn complete_multipart( + &self, + id: &ObjectId, + upload_id: &UploadId, + parts: Vec, + ) -> Result { + self.hooks + .complete_multipart(&self.inner, id, upload_id, parts) + .await + } +} diff --git a/objectstore-service/src/backend/tiered.rs b/objectstore-service/src/backend/tiered.rs index ec626ca2..0398e668 100644 --- a/objectstore-service/src/backend/tiered.rs +++ b/objectstore-service/src/backend/tiered.rs @@ -101,6 +101,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; +use base64::Engine as _; use bytes::Bytes; use futures_util::{Stream, StreamExt}; use objectstore_types::metadata::Metadata; @@ -108,12 +109,16 @@ use serde::{Deserialize, Serialize}; use crate::backend::changelog::{Change, ChangeGuard, ChangeLog, ChangeManager, ChangePhase}; use crate::backend::common::{ - Backend, DeleteResponse, GetResponse, HighVolumeBackend, MetadataResponse, PutResponse, - TieredGet, TieredMetadata, TieredWrite, Tombstone, + Backend, DeleteResponse, GetResponse, HighVolumeBackend, MetadataResponse, + MultipartUploadBackend, PutResponse, TieredGet, TieredMetadata, TieredWrite, Tombstone, }; -use crate::backend::{HighVolumeStorageConfig, StorageConfig}; -use crate::error::Result; +use crate::backend::{HighVolumeStorageConfig, MultipartUploadStorageConfig}; +use crate::error::{Error, Result}; use crate::id::ObjectId; +use crate::multipart::{ + AbortMultipartResponse, CompleteMultipartResponse, CompletedPart, InitiateMultipartResponse, + ListPartsResponse, PartNumber, UploadId, UploadPartResponse, +}; use crate::stream::{ClientStream, SizedPeek}; /// The threshold up until which we will go to the "high volume" backend. @@ -133,7 +138,7 @@ fn new_long_term_revision(id: &ObjectId) -> ObjectId { /// Configuration for [`TieredStorage`]. /// /// Composes two backends into a tiered routing setup: `high_volume` for small -/// objects and `long_term` for large objects. Nesting [`StorageConfig::Tiered`] +/// objects and `long_term` for large objects. Nesting [`super::StorageConfig::Tiered`] /// inside another tiered config is not supported. /// /// # Example @@ -158,7 +163,9 @@ pub struct TieredStorageConfig { /// only BigTable). pub high_volume: HighVolumeStorageConfig, /// Backend for large, long-term objects. - pub long_term: Box, + /// + /// Must be a backend that implements [`MultipartUploadBackend`]. + pub long_term: MultipartUploadStorageConfig, } /// Two-tier storage backend that routes objects by size. @@ -219,7 +226,7 @@ impl TieredStorage { /// Creates a new `TieredStorage` with the given backends and change log. pub fn new( high_volume: Box, - long_term: Box, + long_term: Box, changelog: Box, ) -> Self { let inner = ChangeManager::new(high_volume, long_term, changelog); @@ -540,6 +547,192 @@ where ) } +/// The multipart upload state for TieredStorage. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct TieredUploadId { + revision: String, + upload_id: String, +} + +impl TryInto for TieredUploadId { + type Error = Error; + + fn try_into(self) -> Result { + use base64::Engine; + let json = + serde_json::to_vec(&self).map_err(|e| Error::serde("encoding multipart token", e))?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)) + } +} + +impl TryFrom<&UploadId> for TieredUploadId { + type Error = Error; + + fn try_from(value: &UploadId) -> Result { + let json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(value.as_bytes()) + .map_err(|e| Error::generic(format!("invalid multipart upload ID: {e}")))?; + serde_json::from_slice(&json).map_err(|e| Error::serde("decoding multipart token", e)) + } +} + +#[async_trait::async_trait] +impl MultipartUploadBackend for TieredStorage { + async fn initiate_multipart( + &self, + id: &ObjectId, + metadata: &Metadata, + ) -> Result { + let physical = new_long_term_revision(id); + + let id = self + .inner + .long_term + .initiate_multipart(&physical, metadata) + .await?; + + let id = TieredUploadId { + revision: physical.key, + upload_id: id, + }; + id.try_into() + } + + async fn upload_part( + &self, + id: &ObjectId, + upload_id: &UploadId, + part_number: PartNumber, + content_length: u64, + content_md5: Option<&str>, + body: ClientStream, + ) -> Result { + let tiered: TieredUploadId = upload_id.try_into()?; + + let physical = ObjectId { + context: id.context.clone(), + key: tiered.revision, + }; + + self.inner + .long_term + .upload_part( + &physical, + &tiered.upload_id, + part_number, + content_length, + content_md5, + body, + ) + .await + } + + async fn list_parts( + &self, + id: &ObjectId, + upload_id: &UploadId, + max_parts: Option, + part_number_marker: Option, + ) -> Result { + let tiered: TieredUploadId = upload_id.try_into()?; + + let physical = ObjectId { + context: id.context.clone(), + key: tiered.revision, + }; + + self.inner + .long_term + .list_parts(&physical, &tiered.upload_id, max_parts, part_number_marker) + .await + } + + async fn abort_multipart( + &self, + id: &ObjectId, + upload_id: &UploadId, + ) -> Result { + let tiered: TieredUploadId = upload_id.try_into()?; + + let physical = ObjectId { + context: id.context.clone(), + key: tiered.revision, + }; + + self.inner + .long_term + .abort_multipart(&physical, &tiered.upload_id) + .await + } + + async fn complete_multipart( + &self, + id: &ObjectId, + upload_id: &UploadId, + parts: Vec, + ) -> Result { + let tiered: TieredUploadId = upload_id.try_into()?; + + // 1. Read current HV revision to establish the write precondition + let current = match self.inner.high_volume.get_tiered_metadata(id).await? { + TieredMetadata::Tombstone(t) => Some(t.target), + _ => None, + }; + + // 2. Complete the upload, creating the object at the given revision key. + let physical = ObjectId { + context: id.context.clone(), + key: tiered.revision, + }; + let mut guard = self + .record_change(Change { + id: id.clone(), + new: Some(physical.clone()), + old: current.clone(), + }) + .await?; + + let error = self + .inner + .long_term + .complete_multipart(&physical, &tiered.upload_id, parts) + .await?; + + if error.is_some() { + return Ok(error); + } + + guard.advance(ChangePhase::Written); + + // 3. Retrieve the metadata of the object, which was determined at initiation time, to + // get the expiration policy. + let metadata = self + .inner + .long_term + .get_metadata(&physical) + .await? + .ok_or_else(|| { + Error::generic("completed multipart object not found in long-term storage") + })?; + + // 4. CAS commit: write tombstone only if HV state matches what we saw. + let tombstone = Tombstone { + target: physical.clone(), + expiration_policy: metadata.expiration_policy, + }; + let written = self + .inner + .high_volume + .compare_and_write(id, current.as_ref(), TieredWrite::Tombstone(tombstone)) + .await?; + + // Update guard and let it schedule cleanup in the background. + guard.advance(ChangePhase::compare_and_write(written)); + + Ok(None) + } +} + #[cfg(test)] mod tests { use std::time::Duration; @@ -1286,4 +1479,300 @@ mod tests { "changelog entry not removed after cleanup" ); } + + // --- Multipart upload --- + + #[test] + fn multipart_upload_id_roundtrip() { + let id = TieredUploadId { + revision: "my-key/01924a6f-7e28-7b9a-9c1d-abcdef123456".into(), + upload_id: "upstream-upload-id-abc".into(), + }; + let encoded: UploadId = id.clone().try_into().unwrap(); + let decoded: TieredUploadId = (&encoded.clone()).try_into().unwrap(); + assert_eq!(decoded, id); + } + + #[tokio::test] + async fn multipart_single_part_roundtrip() { + let (storage, hv, lt, _) = make_tiered_storage(); + let id = make_id("mp-single"); + let metadata = Metadata { + content_type: "application/octet-stream".into(), + expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(3600)), + ..Default::default() + }; + let payload = vec![0xABu8; 2 * 1024 * 1024]; // 2 MiB + + let upload_id = storage.initiate_multipart(&id, &metadata).await.unwrap(); + + let etag = storage + .upload_part( + &id, + &upload_id, + 1, + payload.len() as u64, + None, + stream::single(payload.clone()), + ) + .await + .unwrap(); + + let error = storage + .complete_multipart( + &id, + &upload_id, + vec![CompletedPart { + part_number: 1, + etag, + }], + ) + .await + .unwrap(); + assert!( + error.is_none(), + "complete_multipart returned error: {error:?}" + ); + + // get_object should follow the tombstone and return the payload. + let (got_meta, s) = storage.get_object(&id).await.unwrap().unwrap(); + let body = stream::read_to_vec(s).await.unwrap(); + assert_eq!(body, payload); + assert_eq!(got_meta.content_type, "application/octet-stream"); + + // HV should have a tombstone, LT should have the object at the physical key. + let tombstone = hv.get(&id).expect_tombstone(); + assert!( + tombstone.target.key().starts_with(id.key()), + "tombstone target should be a revision key" + ); + lt.get(&tombstone.target).expect_object(); + } + + #[tokio::test] + async fn multipart_upload() { + let (storage, _hv, _lt, _) = make_tiered_storage(); + let id = make_id("multipart"); + + let upload_id = storage + .initiate_multipart(&id, &Default::default()) + .await + .unwrap(); + + let part1 = vec![0xAAu8; 512 * 1024]; + let part2 = vec![0xBBu8; 512 * 1024]; + let part3 = vec![0xCCu8; 512 * 1024]; + + let etag3 = storage + .upload_part( + &id, + &upload_id, + 3, + part3.len() as u64, + None, + stream::single(part3.clone()), + ) + .await + .unwrap(); + let etag2 = storage + .upload_part( + &id, + &upload_id, + 2, + part2.len() as u64, + None, + stream::single(part2.clone()), + ) + .await + .unwrap(); + let etag1 = storage + .upload_part( + &id, + &upload_id, + 1, + part1.len() as u64, + None, + stream::single(part1.clone()), + ) + .await + .unwrap(); + + let error = storage + .complete_multipart( + &id, + &upload_id, + vec![ + CompletedPart { + part_number: 1, + etag: etag1, + }, + CompletedPart { + part_number: 2, + etag: etag2, + }, + CompletedPart { + part_number: 3, + etag: etag3, + }, + ], + ) + .await + .unwrap(); + assert!(error.is_none()); + + let (_, s) = storage.get_object(&id).await.unwrap().unwrap(); + let body = stream::read_to_vec(s).await.unwrap(); + + let mut expected = Vec::new(); + expected.extend_from_slice(&part1); + expected.extend_from_slice(&part2); + expected.extend_from_slice(&part3); + assert_eq!(body, expected); + } + + #[tokio::test] + async fn multipart_abort() { + let (storage, hv, _lt, _) = make_tiered_storage(); + let id = make_id("mp-abort"); + + let upload_id = storage + .initiate_multipart(&id, &Default::default()) + .await + .unwrap(); + + // Upload a part then abort. + let payload = vec![0xABu8; 100]; + storage + .upload_part( + &id, + &upload_id, + 1, + payload.len() as u64, + None, + stream::single(payload), + ) + .await + .unwrap(); + + storage.abort_multipart(&id, &upload_id).await.unwrap(); + + // No tombstone should have been written. + hv.get(&id).expect_not_found(); + + // The object should not be reachable. + assert!(storage.get_object(&id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn multipart_overwrites_existing_tombstone() { + let (storage, hv, lt, _) = make_tiered_storage(); + let id = make_id("mp-overwrite"); + + // Put a large object via the normal path. + let payload1 = vec![0xAAu8; 2 * 1024 * 1024]; + storage + .put_object(&id, &Default::default(), stream::single(payload1)) + .await + .unwrap(); + let old_lt_id = hv.get(&id).expect_tombstone().target; + + // Overwrite via multipart. + let upload_id = storage + .initiate_multipart(&id, &Default::default()) + .await + .unwrap(); + + let payload2 = vec![0xBBu8; 2 * 1024 * 1024]; + let etag = storage + .upload_part( + &id, + &upload_id, + 1, + payload2.len() as u64, + None, + stream::single(payload2.clone()), + ) + .await + .unwrap(); + + // The multipart upload is not finalized, so the tombstone still points to the old + // revision. + let lt_id = hv.get(&id).expect_tombstone().target; + assert_eq!(old_lt_id, lt_id); + + let error = storage + .complete_multipart( + &id, + &upload_id, + vec![CompletedPart { + part_number: 1, + etag, + }], + ) + .await + .unwrap(); + assert!(error.is_none()); + + // Now the upload has been finalized, so the new tombstone points to the new revision. + let new_lt_id = hv.get(&id).expect_tombstone().target; + assert_ne!(old_lt_id, new_lt_id); + + // Wait for background cleanup. + storage.join().await; + + // Old revision should be cleaned up. + lt.get(&old_lt_id).expect_not_found(); + lt.get(&new_lt_id).expect_object(); + + // Assert the contents of the new revision. + let (_, s) = storage.get_object(&id).await.unwrap().unwrap(); + let body = stream::read_to_vec(s).await.unwrap(); + assert_eq!(body, payload2); + } + + #[tokio::test] + async fn multipart_list_parts() { + let (storage, _hv, _lt, _) = make_tiered_storage(); + let id = make_id("mp-list"); + + let upload_id = storage + .initiate_multipart(&id, &Default::default()) + .await + .unwrap(); + + let part1 = vec![0xAAu8; 100]; + let part2 = vec![0xBBu8; 200]; + storage + .upload_part( + &id, + &upload_id, + 1, + part1.len() as u64, + None, + stream::single(part1), + ) + .await + .unwrap(); + storage + .upload_part( + &id, + &upload_id, + 2, + part2.len() as u64, + None, + stream::single(part2), + ) + .await + .unwrap(); + + let resp = storage + .list_parts(&id, &upload_id, None, None) + .await + .unwrap(); + assert_eq!(resp.parts.len(), 2); + assert_eq!(resp.parts[0].part_number, 1); + assert_eq!(resp.parts[0].size, 100); + assert_eq!(resp.parts[1].part_number, 2); + assert_eq!(resp.parts[1].size, 200); + } } diff --git a/objectstore-test/src/server.rs b/objectstore-test/src/server.rs index 9b705b80..6b0036fd 100644 --- a/objectstore-test/src/server.rs +++ b/objectstore-test/src/server.rs @@ -16,7 +16,9 @@ use std::net::{SocketAddr, TcpListener}; use std::path::PathBuf; use std::sync::LazyLock; -use objectstore_server::config::{AuthZVerificationKey, Config, StorageConfig}; +use objectstore_server::config::{ + AuthZVerificationKey, Config, MultipartUploadStorageConfig, StorageConfig, +}; use objectstore_server::state::Services; use objectstore_server::web::App; use objectstore_types::auth::Permission; @@ -134,7 +136,11 @@ fn replace_fs_paths(config: &mut StorageConfig, tempdirs: &mut Vec) { tempdirs.push(dir); } StorageConfig::Tiered(c) => { - replace_fs_paths(&mut c.long_term, tempdirs); + if let MultipartUploadStorageConfig::FileSystem(fs) = &mut c.long_term { + let dir = tempfile::tempdir().unwrap(); + fs.path = dir.path().into(); + tempdirs.push(dir); + } } StorageConfig::S3Compatible(_) | StorageConfig::Gcs(_) | StorageConfig::BigTable(_) => {} }