feat(ecstore): add on-demand migration write-back pipeline (#7079)

* feat(ecstore): add on-demand migration pull queue and write-back pipeline

Background pull queue per bucket (bounded by pull_queue_capacity, concurrency via the state's pull slot), OdmWriteBack/PullSource traits, single-part and multipart write-back with a pumped body that enforces idle timeout, cancel and content length, retry policy for retryable source errors, inline commit helper, and stats accounting (rustfs/backlog#2153).

* feat(object): implement on-demand migration write-back over internal put

OdmWriteBack impl mapping source heads onto InternalPutContext (content-header allowlist, x-amz-meta copy, tags, dual-prefix odm-* provenance, ETag policy), injected into OnDemandMigrationSys at startup; removes the dead-code gates left by ODM-06a (rustfs/backlog#2153).
This commit is contained in:
Zhengchao An
2026-09-03 02:45:24 +08:00
committed by GitHub
parent 5be9d255e6
commit 0fe6cc3641
10 changed files with 2630 additions and 12 deletions
+5
View File
@@ -159,6 +159,11 @@ pub mod bucket {
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy,
SourceConfig, SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
};
pub use crate::bucket::on_demand_migration::{
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS,
PullCompletion, PullQueue, PullReason, PullSource, SourceBody, WriteBackBody, WriteBackError, WriteBackOutcome,
WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
};
pub mod source_client {
pub use crate::bucket::on_demand_migration::source_client::{
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceObject, SourcePage, SourceProbe,
@@ -23,6 +23,7 @@
pub mod breaker;
pub mod config;
pub mod negative_cache;
pub mod pull;
pub mod source_client;
pub mod stats;
pub mod sys;
@@ -37,6 +38,11 @@ pub use config::{
SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
};
pub use negative_cache::{NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache};
pub use pull::{
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS, PullCompletion,
PullQueue, PullReason, PullSource, SourceBody, WriteBackBody, WriteBackError, WriteBackOutcome, WriteBackPart,
WriteBackRequest, commit_inline, commit_inline_with,
};
pub use stats::{
GaugeGuard, LastSourceError, LatencyBucketSnapshot, OdmOp, OdmOutcome, OdmStats, OdmStatsSnapshot, PullFailureReason,
PullPath, SOURCE_LATENCY_BUCKET_BOUNDS_MS, SourceLatencySnapshot,
File diff suppressed because it is too large Load Diff
@@ -35,12 +35,16 @@
//! The module switch (`RUSTFS_ON_DEMAND_MIGRATION_ENABLED`, default off) is
//! injected by the `rustfs` binary through [`OnDemandMigrationSys::set_module_enabled`]
//! before bucket metadata loads; this crate never reads the environment.
//! The same startup step injects the [`OdmWriteBack`] the pull pipeline
//! (`pull.rs`) stores objects with; each bucket state captures it at build
//! time together with its lazily started [`PullQueue`].
use super::breaker::{Breaker, BreakerState, BreakerTransition, BreakerVerdict};
use super::config::{
ON_DEMAND_MIGRATION_CONFIG_HOOK, OnDemandMigrationConfig, PathStyle as ConfigPathStyle, Provider, SourceConfig,
};
use super::negative_cache::NegativeCache;
use super::pull::{OdmWriteBack, PullQueue};
use super::source_client::{SourceClient, SourceClientSpec, SourceError, SourceProvider, SourceTimeouts};
use super::stats::{GaugeGuard, OdmStats, OdmStatsSnapshot, PullFailureReason};
use crate::bucket::remote_s3_client::{PathStyle as ClientPathStyle, RemoteCredentials, RemoteS3ClientError};
@@ -269,6 +273,9 @@ pub struct BucketOdmState {
stats: Arc<OdmStats>,
cancel: CancellationToken,
last_source_error_logged_at: Mutex<Option<Instant>>,
write_back: Option<Arc<dyn OdmWriteBack>>,
/// Started by `pull::BucketOdmState::pull_queue` on first enqueue.
pub(super) pull_queue: OnceLock<Arc<PullQueue>>,
}
impl fmt::Debug for BucketOdmState {
@@ -286,7 +293,12 @@ impl fmt::Debug for BucketOdmState {
}
impl BucketOdmState {
async fn build(bucket: &str, config: &OnDemandMigrationConfig, stats: Arc<OdmStats>) -> Arc<Self> {
async fn build(
bucket: &str,
config: &OnDemandMigrationConfig,
stats: Arc<OdmStats>,
write_back: Option<Arc<dyn OdmWriteBack>>,
) -> Arc<Self> {
let spec = source_client_spec(config);
let client = if config.source.credentials.is_none() {
Err(OdmStateError::AnonymousUnsupported)
@@ -310,6 +322,8 @@ impl BucketOdmState {
stats,
cancel: CancellationToken::new(),
last_source_error_logged_at: Mutex::new(None),
write_back,
pull_queue: OnceLock::new(),
})
}
@@ -346,6 +360,11 @@ impl BucketOdmState {
&self.stats
}
/// The write-back injected by the binary when this state was built.
pub fn write_back(&self) -> Option<&Arc<dyn OdmWriteBack>> {
self.write_back.as_ref()
}
/// Fires when this state is replaced or removed; background pulls
/// started for it must exit.
pub fn cancel_token(&self) -> CancellationToken {
@@ -601,6 +620,7 @@ pub struct OnDemandMigrationSys {
module_enabled: AtomicBool,
buckets: RwLock<HashMap<String, BucketSlot>>,
generation: AtomicU64,
write_back: RwLock<Option<Arc<dyn OdmWriteBack>>>,
}
impl fmt::Debug for OnDemandMigrationSys {
@@ -625,6 +645,7 @@ impl OnDemandMigrationSys {
module_enabled: AtomicBool::new(false),
buckets: RwLock::new(HashMap::new()),
generation: AtomicU64::new(0),
write_back: RwLock::new(None),
}
}
@@ -641,6 +662,17 @@ impl OnDemandMigrationSys {
self.module_enabled.load(Ordering::Relaxed)
}
/// Installs the local write path used by every bucket state built from
/// now on (states built earlier keep what they captured). The binary
/// calls this before bucket metadata loads.
pub fn set_write_back(&self, write_back: Arc<dyn OdmWriteBack>) {
*self.write_back.write() = Some(write_back);
}
pub fn write_back(&self) -> Option<Arc<dyn OdmWriteBack>> {
self.write_back.read().clone()
}
/// Registers `publish` as the bucket-metadata publish hook. Returns
/// `false` when a hook was already registered.
pub fn register_config_hook(&'static self) -> bool {
@@ -701,7 +733,7 @@ impl OnDemandMigrationSys {
return ApplyOutcome::Unchanged;
}
let stats = self.state(bucket).map(|state| Arc::clone(&state.stats)).unwrap_or_default();
let state = BucketOdmState::build(bucket, config, stats).await;
let state = BucketOdmState::build(bucket, config, stats, self.write_back()).await;
let (outcome, previous) = {
let mut buckets = self.buckets.write();
+1 -1
View File
@@ -163,7 +163,7 @@ impl InternalPutObjectEvent {
})
}
fn builder(event_name: EventName, bucket: &str, key: &str, principal_id: &'static str) -> EventArgsBuilder {
pub(super) fn builder(event_name: EventName, bucket: &str, key: &str, principal_id: &'static str) -> EventArgsBuilder {
// The object is a placeholder until `object()` supplies the committed
// ObjectInfo, matching the S3 helper.
let placeholder = ObjectInfo {
+2 -3
View File
@@ -191,10 +191,8 @@ mod delete;
mod extract;
mod get;
mod head;
// Consumed by the on-demand migration write-back (rustfs/backlog#2153); until
// that lands only tests construct the internal entry points.
#[cfg_attr(not(test), expect(dead_code, reason = "wired by the on-demand migration write-back"))]
mod internal_put;
mod on_demand_migration_put;
mod put;
mod restore;
mod shared;
@@ -207,6 +205,7 @@ pub(crate) use self::delete::*;
pub(crate) use self::extract::*;
pub(crate) use self::get::*;
pub(crate) use self::internal_put::*;
pub(crate) use self::on_demand_migration_put::*;
use self::put::*;
pub(crate) use self::put::{guard_put_object_body_read_timeout, put_object_body_read_timeout};
pub(crate) use self::shared::*;
@@ -0,0 +1,863 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! On-demand migration write-back (rustfs/backlog#2153): the app-layer
//! [`OdmWriteBack`] the ecstore pull pipeline stores source objects with.
//!
//! Every write goes through the internal put entry points, so a pulled
//! object is indistinguishable from a client PUT: bucket default SSE, quota,
//! versioning, Object Lock defaults, replication scheduling and creation
//! events all apply. This module only maps a [`WriteBackRequest`] onto an
//! [`InternalPutContext`]: the content-header allowlist, the `x-amz-meta-*`
//! copy, optional tags, the five `odm-*` provenance keys (dual prefix), and
//! the ETag policy.
//!
//! ETag policy: a single-part source ETag (32 hex digits) of an unencrypted
//! source object is the plaintext MD5 and doubles as the integrity check;
//! `policy.preserve_etag` keeps the source ETag (multipart ETags included,
//! display only) unless the bucket encrypts by default, where the override
//! is dropped and the SSE write path decides the ETag, exactly like
//! replication receive. The source ETag is always recorded under
//! `odm-source-etag`.
use super::*;
use crate::app::storage_api::multipart_usecase::contract::multipart::CompletePart;
use crate::app::storage_api::object_usecase::on_demand_migration::{
LocalObject, OdmWriteBack, SourceHead, WriteBackBody, WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest,
is_multipart_etag,
};
use rustfs_utils::http::{
SUFFIX_ODM_PULLED_AT, SUFFIX_ODM_SOURCE, SUFFIX_ODM_SOURCE_ETAG, SUFFIX_ODM_SOURCE_LAST_MODIFIED,
SUFFIX_ODM_SOURCE_VERSION_ID,
};
/// `userIdentity.principalId` of every creation event a write-back emits.
pub(crate) const ON_DEMAND_MIGRATION_PRINCIPAL_ID: &str = "rustfs-on-demand-migration";
/// [`OdmWriteBack`] over [`DefaultObjectUsecase`]'s internal put entry
/// points. The ambient app context is resolved per call: the write-back is
/// installed at startup, before the context is published.
#[derive(Debug, Default)]
pub(crate) struct OnDemandMigrationWriteBack;
impl OnDemandMigrationWriteBack {
pub(crate) fn new() -> Self {
Self
}
fn usecase(&self) -> DefaultObjectUsecase {
DefaultObjectUsecase::from_global()
}
fn store(&self) -> Result<Arc<ECStore>, WriteBackError> {
self.usecase()
.object_store()
.ok_or_else(|| WriteBackError::Local("object store is not initialized".to_string()))
}
}
fn rfc3339(time: OffsetDateTime) -> String {
time.format(&Rfc3339).unwrap_or_default()
}
/// Standard object headers copied from the source. `storage_class` and the
/// source `Last-Modified` are deliberately absent: the local class follows
/// the bucket and the local mtime is the write time.
pub(super) fn content_headers(head: &SourceHead) -> HashMap<String, String> {
let mut headers = HashMap::with_capacity(6);
for (name, value) in [
("Content-Type", &head.content_type),
("Content-Encoding", &head.content_encoding),
("Content-Disposition", &head.content_disposition),
("Content-Language", &head.content_language),
("Cache-Control", &head.cache_control),
("Expires", &head.expires),
] {
if let Some(value) = value.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
headers.insert(name.to_string(), value.to_string());
}
}
headers
}
/// The five `odm-*` provenance keys under both internal prefixes. Absent
/// source values are stored as empty strings so the key set is constant.
pub(super) fn provenance_metadata(request: &WriteBackRequest) -> HashMap<String, String> {
let head = &request.head;
let mut metadata = HashMap::with_capacity(10);
insert_str(&mut metadata, SUFFIX_ODM_SOURCE, request.source_label.clone());
insert_str(&mut metadata, SUFFIX_ODM_SOURCE_ETAG, head.etag.clone().unwrap_or_default());
insert_str(
&mut metadata,
SUFFIX_ODM_SOURCE_LAST_MODIFIED,
head.last_modified.map(OffsetDateTime::from).map(rfc3339).unwrap_or_default(),
);
insert_str(&mut metadata, SUFFIX_ODM_SOURCE_VERSION_ID, head.version_id.clone().unwrap_or_default());
insert_str(&mut metadata, SUFFIX_ODM_PULLED_AT, rfc3339(request.pulled_at));
metadata
}
/// The source ETag as the expected plaintext MD5: only a bare 32-digit hex
/// ETag of an unencrypted source object is one.
pub(super) fn expected_md5_hex(head: &SourceHead) -> Option<String> {
if head.sse.is_some() {
return None;
}
let etag = head.etag.as_deref()?;
if etag.len() != 32 || is_multipart_etag(etag) || !etag.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
Some(etag.to_ascii_lowercase())
}
/// `x-amz-tagging` form of the source tags, sorted by key for a stable
/// stored value.
pub(super) fn encode_tags(tags: &HashMap<String, String>) -> Option<String> {
if tags.is_empty() {
return None;
}
let mut pairs: Vec<(&String, &String)> = tags.iter().collect();
pairs.sort();
let mut encoded = url::form_urlencoded::Serializer::new(String::new());
for (key, value) in pairs {
encoded.append_pair(key, value);
}
Some(encoded.finish())
}
async fn bucket_encrypts_by_default(bucket: &str) -> bool {
metadata_sys::get_sse_config(bucket).await.is_ok_and(|(config, _)| {
config
.rules
.iter()
.any(|rule| rule.apply_server_side_encryption_by_default.is_some())
})
}
/// Builds the internal put context of a write-back. `single_part` enables
/// the MD5 integrity check, which only the single-object path can honor.
pub(super) async fn write_back_context(request: &WriteBackRequest, single_part: bool) -> InternalPutContext {
let head = &request.head;
let preserve_etag = if request.preserve_etag && head.etag.is_some() && !bucket_encrypts_by_default(&request.bucket).await {
head.etag.clone()
} else {
None
};
InternalPutContext {
bucket: request.bucket.clone(),
key: request.key.clone(),
size: Some(head.size),
expected_md5_hex: single_part.then(|| expected_md5_hex(head)).flatten(),
preserve_etag,
content_headers: content_headers(head),
user_metadata: head.user_metadata.clone(),
tags: request.tags.as_ref().and_then(encode_tags),
internal_metadata: provenance_metadata(request),
emit_events: request.emit_events,
principal_id: ON_DEMAND_MIGRATION_PRINCIPAL_ID,
}
}
/// Maps an internal put failure onto the write-back error classes. A
/// digest mismatch is the only integrity signal; both quota producers
/// (admission and the durable reservation) say "quota exceeded".
pub(super) fn write_back_error(err: ApiError) -> WriteBackError {
if err.code == S3ErrorCode::BadDigest {
return WriteBackError::Integrity;
}
if err.message.to_ascii_lowercase().contains("quota exceeded") {
return WriteBackError::Quota(err.message);
}
WriteBackError::Local(format!("{}: {}", err.code.as_str(), err.message))
}
fn outcome(info: ObjectInfo) -> WriteBackOutcome {
WriteBackOutcome {
etag: info.etag,
size: u64::try_from(info.size).unwrap_or(0),
version_id: info.version_id.map(|version_id| version_id.to_string()),
}
}
#[async_trait::async_trait]
impl OdmWriteBack for OnDemandMigrationWriteBack {
async fn local_object(&self, bucket: &str, key: &str) -> Result<Option<LocalObject>, WriteBackError> {
let store = self.store()?;
match store.get_object_info(bucket, key, &ObjectOptions::default()).await {
Ok(info) => Ok(Some(LocalObject {
etag: info.etag.clone(),
size: u64::try_from(info.size).unwrap_or(0),
delete_marker: info.delete_marker,
})),
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => Ok(None),
Err(err) => Err(WriteBackError::Local(err.to_string())),
}
}
async fn put_object(&self, request: &WriteBackRequest, body: WriteBackBody) -> Result<WriteBackOutcome, WriteBackError> {
let ctx = write_back_context(request, true).await;
self.usecase()
.internal_put_object(ctx, body)
.await
.map(outcome)
.map_err(write_back_error)
}
async fn create_multipart_upload(&self, request: &WriteBackRequest) -> Result<String, WriteBackError> {
let ctx = write_back_context(request, false).await;
self.usecase()
.internal_create_multipart_upload(&ctx)
.await
.map_err(write_back_error)
}
async fn upload_part(
&self,
request: &WriteBackRequest,
upload_id: &str,
part_number: usize,
size: u64,
body: WriteBackBody,
) -> Result<WriteBackPart, WriteBackError> {
let ctx = write_back_context(request, false).await;
let part = self
.usecase()
.internal_upload_part(&ctx, upload_id, part_number, size, None, body)
.await
.map_err(write_back_error)?;
Ok(WriteBackPart {
part_number: part.part_num,
etag: part.etag.unwrap_or_default(),
})
}
async fn complete_multipart_upload(
&self,
request: &WriteBackRequest,
upload_id: &str,
parts: Vec<WriteBackPart>,
) -> Result<WriteBackOutcome, WriteBackError> {
let ctx = write_back_context(request, false).await;
let parts = parts
.into_iter()
.map(|part| CompletePart {
part_num: part.part_number,
etag: Some(part.etag),
..Default::default()
})
.collect();
self.usecase()
.internal_complete_multipart_upload(&ctx, upload_id, parts)
.await
.map(outcome)
.map_err(write_back_error)
}
async fn abort_multipart_upload(&self, bucket: &str, key: &str, upload_id: &str) -> Result<(), WriteBackError> {
self.usecase()
.internal_abort_multipart_upload(bucket, key, upload_id)
.await
.map_err(write_back_error)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::storage_api::multipart_usecase::contract::multipart::MultipartOperations as _;
use crate::app::storage_api::object_usecase::on_demand_migration::{PullFailureReason, SourceSse};
use crate::app::storage_api::test::bucket::utils::serialize;
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::app::storage_api::test::{get_global_bucket_metadata_sys, set_bucket_metadata};
use http::Method;
use rustfs_utils::http::{MINIO_INTERNAL_PREFIX, RUSTFS_INTERNAL_PREFIX, get_str};
use s3s::dto::{
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, Destination, ReplicationConfiguration,
ReplicationRule, ReplicationRuleStatus, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration,
ServerSideEncryptionRule, VersioningConfiguration,
};
use sha2::{Digest as Sha256Digest, Sha256};
use std::time::SystemTime;
use tokio::io::AsyncReadExt;
const SOURCE_LABEL: &str = "s3:legacy-bucket";
fn md5_hex(body: &[u8]) -> String {
hex_simd::encode_to_string(Md5::digest(body), hex_simd::AsciiCase::Lower)
}
fn sha256_hex(body: &[u8]) -> String {
hex_simd::encode_to_string(Sha256::digest(body), hex_simd::AsciiCase::Lower)
}
fn stream(chunks: Vec<io::Result<Bytes>>) -> WriteBackBody {
Box::pin(futures::stream::iter(chunks))
}
fn body_stream(body: &[u8]) -> WriteBackBody {
stream(body.chunks(1 << 20).map(|chunk| Ok(Bytes::copy_from_slice(chunk))).collect())
}
fn source_head(body: &[u8]) -> SourceHead {
SourceHead {
etag: Some(md5_hex(body)),
size: body.len() as u64,
last_modified: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
content_type: Some("text/plain; charset=utf-8".to_string()),
cache_control: Some("max-age=60".to_string()),
content_language: Some("en".to_string()),
user_metadata: HashMap::from([("origin".to_string(), "legacy".to_string())]),
version_id: Some("src-v1".to_string()),
storage_class: Some("STANDARD_IA".to_string()),
..Default::default()
}
}
fn request(bucket: &str, key: &str, head: SourceHead) -> WriteBackRequest {
WriteBackRequest {
bucket: bucket.to_string(),
key: key.to_string(),
head,
source_label: SOURCE_LABEL.to_string(),
pulled_at: OffsetDateTime::from_unix_timestamp(1_756_800_000).expect("valid timestamp"),
preserve_etag: true,
emit_events: true,
tags: Some(HashMap::from([
("team".to_string(), "storage".to_string()),
("env".to_string(), "prod".to_string()),
])),
}
}
async fn write_back_test_bucket(prefix: &str, versioned: bool) -> (Arc<ECStore>, String) {
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
let bucket = format!("{prefix}-{}", Uuid::new_v4().simple());
store
.make_bucket(
&bucket,
&MakeBucketOptions {
versioning_enabled: versioned,
..Default::default()
},
)
.await
.expect("create write-back test bucket");
(store, bucket)
}
async fn stored_object(store: &Arc<ECStore>, bucket: &str, key: &str) -> ObjectInfo {
store
.get_object_info(bucket, key, &ObjectOptions::default())
.await
.expect("write-back must leave a readable object")
}
async fn assert_nothing_left(store: &Arc<ECStore>, bucket: &str, key: &str) {
let lookup = store.get_object_info(bucket, key, &ObjectOptions::default()).await;
assert!(
lookup.as_ref().is_err_and(is_err_object_not_found),
"a failed write-back must not leave an object: {lookup:?}"
);
let uploads = store
.list_multipart_uploads(bucket, key, None, None, None, 100)
.await
.expect("list multipart uploads");
assert!(
uploads.uploads.is_empty(),
"a failed write-back must not leave uploads: {:?}",
uploads.uploads
);
}
async fn raw_object_bytes(store: &Arc<ECStore>, bucket: &str, key: &str) -> Vec<u8> {
let mut reader = (**store)
.get_object_reader(bucket, key, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("read object");
let mut buf = Vec::new();
reader.stream.read_to_end(&mut buf).await.expect("drain object reader");
buf
}
async fn get_via_app(bucket: &str, key: &str) -> Vec<u8> {
let input = GetObjectInput::builder()
.bucket(bucket.to_string())
.key(key.to_string())
.build()
.expect("GET input must build");
let req = build_request(input, Method::GET);
let mut response = DefaultObjectUsecase::from_global()
.execute_get_object(req)
.await
.expect("app-layer GET must succeed");
let mut body = response.output.body.take().expect("GET response must include a body");
let mut actual = Vec::new();
while let Some(chunk) = body.next().await {
actual.extend_from_slice(&chunk.expect("GET body chunk"));
}
actual
}
fn assert_provenance(metadata: &HashMap<String, String>, head: &SourceHead) {
for suffix in [
SUFFIX_ODM_SOURCE,
SUFFIX_ODM_SOURCE_ETAG,
SUFFIX_ODM_SOURCE_LAST_MODIFIED,
SUFFIX_ODM_SOURCE_VERSION_ID,
SUFFIX_ODM_PULLED_AT,
] {
assert!(
metadata.contains_key(&format!("{RUSTFS_INTERNAL_PREFIX}{suffix}")),
"missing rustfs {suffix}"
);
assert!(
metadata.contains_key(&format!("{MINIO_INTERNAL_PREFIX}{suffix}")),
"missing minio {suffix}"
);
}
assert_eq!(get_str(metadata, SUFFIX_ODM_SOURCE).as_deref(), Some(SOURCE_LABEL));
assert_eq!(get_str(metadata, SUFFIX_ODM_SOURCE_ETAG), head.etag);
assert_eq!(
get_str(metadata, SUFFIX_ODM_SOURCE_LAST_MODIFIED).as_deref(),
Some("2023-11-14T22:13:20Z")
);
assert_eq!(get_str(metadata, SUFFIX_ODM_SOURCE_VERSION_ID).as_deref(), Some("src-v1"));
assert_eq!(get_str(metadata, SUFFIX_ODM_PULLED_AT).as_deref(), Some("2025-09-02T08:00:00Z"));
}
#[tokio::test]
#[serial_test::serial]
async fn write_back_commits_the_source_object_with_provenance_and_source_etag() {
let (store, bucket) = write_back_test_bucket("odm-wb", false).await;
let write_back = OnDemandMigrationWriteBack::new();
assert!(
write_back
.local_object(&bucket, "dir/obj.txt")
.await
.expect("lookup")
.is_none()
);
let body = b"pulled from the legacy bucket".to_vec();
let head = source_head(&body);
let outcome = write_back
.put_object(&request(&bucket, "dir/obj.txt", head.clone()), body_stream(&body))
.await
.expect("write-back must commit");
assert_eq!(outcome.etag, head.etag, "single-part source ETag is preserved");
assert_eq!(outcome.size, body.len() as u64);
let stored = stored_object(&store, &bucket, "dir/obj.txt").await;
assert_eq!(stored.etag, head.etag);
assert_eq!(stored.size, body.len() as i64);
let metadata = &stored.user_defined;
assert_provenance(metadata, &head);
assert_eq!(metadata.get("content-type").map(String::as_str), Some("text/plain; charset=utf-8"));
assert_eq!(metadata.get("cache-control").map(String::as_str), Some("max-age=60"));
assert_eq!(metadata.get("content-language").map(String::as_str), Some("en"));
assert_eq!(metadata.get("origin").map(String::as_str), Some("legacy"));
assert!(
!metadata.keys().any(|key| key.eq_ignore_ascii_case("x-amz-storage-class")),
"source storage class is not copied: {metadata:?}"
);
assert_eq!(stored.user_tags.as_str(), "env=prod&team=storage");
assert_eq!(raw_object_bytes(&store, &bucket, "dir/obj.txt").await, body);
let local = write_back
.local_object(&bucket, "dir/obj.txt")
.await
.expect("lookup")
.expect("object now exists");
assert_eq!(local.etag, head.etag);
assert_eq!(local.size, body.len() as u64);
assert!(!local.delete_marker);
}
#[tokio::test]
#[serial_test::serial]
async fn write_back_integrity_failure_leaves_nothing_behind() {
let (store, bucket) = write_back_test_bucket("odm-wb-etag", false).await;
let body = b"the source lied about this body".to_vec();
let mut head = source_head(&body);
head.etag = Some(md5_hex(b"a different body"));
let err = OnDemandMigrationWriteBack::new()
.put_object(&request(&bucket, "wrong.bin", head), body_stream(&body))
.await
.expect_err("an ETag mismatch must fail the write-back");
assert_eq!(err, WriteBackError::Integrity);
assert_eq!(err.reason(), PullFailureReason::EtagMismatch);
assert_nothing_left(&store, &bucket, "wrong.bin").await;
}
#[tokio::test]
#[serial_test::serial]
async fn write_back_truncated_stream_leaves_nothing_behind() {
let (store, bucket) = write_back_test_bucket("odm-wb-trunc", false).await;
let body = vec![0x5a; 200 * 1024];
let head = source_head(&body);
// The tee secondary reports the source failure mid-stream.
let torn = stream(vec![
Ok(Bytes::copy_from_slice(&body[..64 * 1024])),
Err(io::Error::new(io::ErrorKind::BrokenPipe, "tee primary dropped before EOF")),
]);
let err = OnDemandMigrationWriteBack::new()
.put_object(&request(&bucket, "torn.bin", head.clone()), torn)
.await
.expect_err("a broken stream must fail the write-back");
assert_ne!(err, WriteBackError::Integrity, "{err}");
assert_nothing_left(&store, &bucket, "torn.bin").await;
// A clean EOF short of the advertised size is just as fatal.
let short = stream(vec![Ok(Bytes::copy_from_slice(&body[..64 * 1024]))]);
let err = OnDemandMigrationWriteBack::new()
.put_object(&request(&bucket, "short.bin", head), short)
.await
.expect_err("a short body must fail the write-back");
assert!(matches!(err, WriteBackError::Local(_) | WriteBackError::Integrity), "{err}");
assert_nothing_left(&store, &bucket, "short.bin").await;
}
#[tokio::test]
#[serial_test::serial]
async fn write_back_multipart_path_matches_the_source_digest() {
const PART_SIZE: usize = 5 * 1024 * 1024;
let (store, bucket) = write_back_test_bucket("odm-wb-mpu", false).await;
let body: Vec<u8> = (0..PART_SIZE + 4096).map(|i| (i % 253) as u8).collect();
let mut head = source_head(&body);
head.etag = Some(format!("{}-2", md5_hex(&body)));
head.is_multipart_etag = true;
let request = request(&bucket, "big/object.bin", head.clone());
let write_back = OnDemandMigrationWriteBack::new();
let upload_id = write_back.create_multipart_upload(&request).await.expect("create");
let mut parts = Vec::new();
for (index, chunk) in body.chunks(PART_SIZE).enumerate() {
let part = write_back
.upload_part(&request, &upload_id, index + 1, chunk.len() as u64, body_stream(chunk))
.await
.expect("stage part");
assert_eq!(part.part_number, index + 1);
assert!(!part.etag.is_empty());
parts.push(part);
}
let outcome = write_back
.complete_multipart_upload(&request, &upload_id, parts)
.await
.expect("complete");
assert_eq!(outcome.size, body.len() as u64);
assert_eq!(outcome.etag, head.etag, "multipart source ETag is preserved for display");
let stored = stored_object(&store, &bucket, "big/object.bin").await;
assert_eq!(stored.parts.len(), 2, "HEAD parts_count");
assert_eq!(stored.size, body.len() as i64);
assert_eq!(stored.etag, head.etag);
assert_provenance(&stored.user_defined, &head);
assert_eq!(
stored.user_defined.get("content-type").map(String::as_str),
Some("text/plain; charset=utf-8")
);
assert_eq!(sha256_hex(&raw_object_bytes(&store, &bucket, "big/object.bin").await), sha256_hex(&body));
// A failed upload is aborted and leaves no residue.
let aborted = write_back.create_multipart_upload(&request).await.expect("create");
write_back
.upload_part(&request, &aborted, 1, 4096, body_stream(&body[..4096]))
.await
.expect("stage part");
write_back
.abort_multipart_upload(&bucket, "big/object.bin", &aborted)
.await
.expect("abort");
let uploads = store
.list_multipart_uploads(&bucket, "big/object.bin", None, None, None, 100)
.await
.expect("list uploads");
assert!(uploads.uploads.iter().all(|upload| upload.upload_id != aborted));
}
async fn install_bucket_default_sse(bucket: &str) {
let sys = get_global_bucket_metadata_sys().expect("bucket metadata system");
let metadata = {
let sys = sys.read().await;
sys.get(bucket).await.expect("bucket metadata cached")
};
let mut metadata = (*metadata).clone();
let config = ServerSideEncryptionConfiguration {
rules: vec![ServerSideEncryptionRule {
apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault {
sse_algorithm: ServerSideEncryption::from_static(ServerSideEncryption::AES256),
kms_master_key_id: None,
}),
blocked_encryption_types: None,
bucket_key_enabled: None,
}],
};
metadata.encryption_config_xml = serialize(&config).expect("sse config serializes");
metadata.sse_config = Some(config);
set_bucket_metadata(bucket.to_string(), metadata)
.await
.expect("install bucket default SSE");
}
#[tokio::test]
#[serial_test::serial]
async fn write_back_under_bucket_default_sse_stores_ciphertext_and_records_source_etag() {
let local_sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
temp_env::async_with_vars([("RUSTFS_SSE_S3_MASTER_KEY", Some(local_sse_master_key))], async {
let (store, bucket) = write_back_test_bucket("odm-wb-sse", false).await;
// The gating store adopts the bootstrap context; server startup is
// what normally installs the read-side decryption resolver on it.
let _ = crate::app::storage_api::test::bootstrap_instance_ctx();
install_bucket_default_sse(&bucket).await;
assert!(bucket_encrypts_by_default(&bucket).await);
let body = b"plaintext that must be encrypted at rest".to_vec();
let head = source_head(&body);
let request = request(&bucket, "secret.txt", head.clone());
// The source ETag is not forced onto an encrypted object; the
// local ETag is whatever the SSE write path computes.
assert_eq!(write_back_context(&request, true).await.preserve_etag, None);
let outcome = OnDemandMigrationWriteBack::new()
.put_object(&request, body_stream(&body))
.await
.expect("write-back under SSE must commit");
assert!(outcome.etag.is_some());
let stored = stored_object(&store, &bucket, "secret.txt").await;
assert_eq!(stored.etag, outcome.etag);
assert!(
stored.user_defined.contains_key("x-amz-server-side-encryption"),
"{:?}",
stored.user_defined
);
assert_eq!(get_str(&stored.user_defined, SUFFIX_ODM_SOURCE_ETAG), head.etag);
assert_provenance(&stored.user_defined, &head);
assert!(
stored
.user_defined
.keys()
.any(|key| key.starts_with("x-rustfs-encryption-") || key.starts_with("x-minio-encryption-")),
"disk holds ciphertext under a managed key: {:?}",
stored.user_defined
);
assert_eq!(get_via_app(&bucket, "secret.txt").await, body, "GET returns the plaintext");
})
.await;
}
#[tokio::test]
#[serial_test::serial]
async fn write_back_reports_a_full_bucket_quota() {
let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("odm-wb-quota", 64).await;
let body = vec![0x71; 4096];
let err = OnDemandMigrationWriteBack::new()
.put_object(&request(&bucket, "over.bin", source_head(&body)), body_stream(&body))
.await
.expect_err("a full quota must reject the write-back");
assert!(matches!(err, WriteBackError::Quota(_)), "{err}");
// ODM-05 fixed the failure label set without a quota label; quota
// failures are accounted as local writes until it grows one.
assert_eq!(err.reason(), PullFailureReason::LocalWrite);
assert_nothing_left(&store, &bucket, "over.bin").await;
}
async fn install_replication_rule(bucket: &str) {
let sys = get_global_bucket_metadata_sys().expect("bucket metadata system");
let metadata = {
let sys = sys.read().await;
sys.get(bucket).await.expect("bucket metadata cached")
};
let mut metadata = (*metadata).clone();
metadata.versioning_config_xml = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec();
metadata.versioning_config = Some(VersioningConfiguration {
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
..Default::default()
});
let config = ReplicationConfiguration {
role: String::new(),
rules: vec![ReplicationRule {
delete_marker_replication: Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::DISABLED)),
}),
delete_replication: None,
destination: Destination {
bucket: "arn:aws:s3:::target-bucket".to_string(),
..Default::default()
},
existing_object_replication: None,
filter: None,
id: Some("odm".to_string()),
prefix: Some(String::new()),
priority: Some(1),
source_selection_criteria: None,
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}],
};
metadata.replication_config_xml = serialize(&config).expect("replication config serializes");
metadata.replication_config = Some(config);
set_bucket_metadata(bucket.to_string(), metadata)
.await
.expect("install replication rule");
}
#[tokio::test]
#[serial_test::serial]
async fn write_back_schedules_replication_and_names_the_migration_principal() {
let (store, bucket) = write_back_test_bucket("odm-wb-repl", true).await;
install_replication_rule(&bucket).await;
let body = b"replicate me".to_vec();
let head = source_head(&body);
let request = request(&bucket, "replicated.txt", head);
let ctx = write_back_context(&request, true).await;
assert!(ctx.emit_events, "policy.emit_events reaches the creation event");
assert_eq!(ctx.principal_id, ON_DEMAND_MIGRATION_PRINCIPAL_ID);
assert_eq!(ctx.principal_id, "rustfs-on-demand-migration");
let event =
InternalPutObjectEvent::builder(EventName::ObjectCreatedPut, &bucket, "replicated.txt", ctx.principal_id).build();
assert_eq!(event.event_name, EventName::ObjectCreatedPut);
assert_eq!(
event.req_params.get("principalId").map(String::as_str),
Some("rustfs-on-demand-migration")
);
let outcome = OnDemandMigrationWriteBack::new()
.put_object(&request, body_stream(&body))
.await
.expect("write-back must commit");
assert!(outcome.version_id.is_some(), "versioned bucket yields a version id");
let stored = stored_object(&store, &bucket, "replicated.txt").await;
// Stored per target as `<arn>=<status>;`; the S3 header is derived from it.
let status = get_str(&stored.user_defined, SUFFIX_REPLICATION_STATUS).unwrap_or_default();
assert!(
status.contains("arn:aws:s3:::target-bucket=PENDING;") || status.contains("arn:aws:s3:::target-bucket=COMPLETED;"),
"write-back must enter the replication queue: {status:?}"
);
}
#[test]
fn content_headers_follow_the_allowlist() {
let head = SourceHead {
content_type: Some("image/png".to_string()),
content_encoding: Some("gzip".to_string()),
content_disposition: Some("attachment".to_string()),
content_language: Some(" ".to_string()),
cache_control: None,
expires: Some("Thu, 01 Jan 2026 00:00:00 GMT".to_string()),
storage_class: Some("GLACIER".to_string()),
..Default::default()
};
let headers = content_headers(&head);
assert_eq!(
headers,
HashMap::from([
("Content-Type".to_string(), "image/png".to_string()),
("Content-Encoding".to_string(), "gzip".to_string()),
("Content-Disposition".to_string(), "attachment".to_string()),
("Expires".to_string(), "Thu, 01 Jan 2026 00:00:00 GMT".to_string()),
])
);
}
#[test]
fn expected_md5_only_for_bare_single_part_unencrypted_etags() {
let mut head = source_head(b"abc");
assert_eq!(expected_md5_hex(&head), Some(md5_hex(b"abc")));
head.etag = Some(md5_hex(b"abc").to_ascii_uppercase());
assert_eq!(expected_md5_hex(&head), Some(md5_hex(b"abc")), "normalized to lowercase");
head.etag = Some(format!("{}-2", md5_hex(b"abc")));
assert_eq!(expected_md5_hex(&head), None, "multipart ETag");
head.etag = Some("not-hex-not-hex-not-hex-not-hex-".to_string());
assert_eq!(expected_md5_hex(&head), None, "non-hex");
head.etag = Some(md5_hex(b"abc"));
head.sse = Some(SourceSse::S3);
assert_eq!(expected_md5_hex(&head), None, "encrypted source");
head.sse = None;
head.etag = None;
assert_eq!(expected_md5_hex(&head), None);
}
#[test]
fn provenance_and_tags_are_stable() {
let mut request = request("b", "k", source_head(b"x"));
let metadata = provenance_metadata(&request);
assert_eq!(metadata.len(), 10, "five keys under two prefixes");
assert_provenance(&metadata, &request.head);
request.head.etag = None;
request.head.version_id = None;
request.head.last_modified = None;
let metadata = provenance_metadata(&request);
assert_eq!(metadata.len(), 10, "absent values keep the key set constant");
assert_eq!(get_str(&metadata, SUFFIX_ODM_SOURCE_ETAG).as_deref(), Some(""));
assert_eq!(get_str(&metadata, SUFFIX_ODM_SOURCE_VERSION_ID).as_deref(), Some(""));
assert_eq!(get_str(&metadata, SUFFIX_ODM_SOURCE_LAST_MODIFIED).as_deref(), Some(""));
assert_eq!(encode_tags(&HashMap::new()), None);
let tags = HashMap::from([("b key".to_string(), "v&2".to_string()), ("a".to_string(), "1".to_string())]);
assert_eq!(encode_tags(&tags).as_deref(), Some("a=1&b+key=v%262"));
}
#[tokio::test]
async fn write_back_context_applies_the_etag_and_event_policy() {
let body = b"context".to_vec();
let mut request = request("no-such-bucket", "k", source_head(&body));
let ctx = write_back_context(&request, true).await;
assert_eq!(ctx.expected_md5_hex, Some(md5_hex(&body)));
assert_eq!(ctx.preserve_etag, Some(md5_hex(&body)));
assert_eq!(ctx.size, Some(body.len() as u64));
assert_eq!(ctx.tags.as_deref(), Some("env=prod&team=storage"));
assert_eq!(ctx.user_metadata.get("origin").map(String::as_str), Some("legacy"));
assert!(ctx.emit_events);
assert_eq!(ctx.principal_id, ON_DEMAND_MIGRATION_PRINCIPAL_ID);
let multipart = write_back_context(&request, false).await;
assert_eq!(multipart.expected_md5_hex, None, "parts cannot be checked against the object ETag");
assert_eq!(multipart.preserve_etag, Some(md5_hex(&body)));
request.preserve_etag = false;
request.emit_events = false;
request.tags = None;
let ctx = write_back_context(&request, true).await;
assert_eq!(ctx.preserve_etag, None);
assert_eq!(
ctx.expected_md5_hex,
Some(md5_hex(&body)),
"integrity check is independent of preservation"
);
assert!(!ctx.emit_events);
assert_eq!(ctx.tags, None);
}
#[test]
fn write_back_error_classes_follow_the_api_error() {
let bad_digest = ApiError {
code: S3ErrorCode::BadDigest,
message: "digest".to_string(),
source: None,
};
assert_eq!(write_back_error(bad_digest), WriteBackError::Integrity);
let quota = ApiError::invalid_request("Bucket quota exceeded. Current usage: 1 bytes, limit: 1 bytes");
assert!(matches!(write_back_error(quota), WriteBackError::Quota(_)));
let other = ApiError {
code: S3ErrorCode::InternalError,
message: "disk".to_string(),
source: None,
};
assert_eq!(write_back_error(other), WriteBackError::Local("InternalError: disk".to_string()));
}
}
-2
View File
@@ -933,7 +933,6 @@ pub(super) enum PutObjectContentMd5 {
/// `Content-MD5` request header value.
Base64(String),
/// Lowercase hex digest, as an internal caller already holds it.
#[cfg_attr(not(test), expect(dead_code, reason = "constructed by the internal put entry point"))]
Hex(String),
}
@@ -950,7 +949,6 @@ pub(super) enum PutObjectOrigin<'a> {
/// request and no credential: managed-SSE authorization treats the write
/// as internal, and the creation event, when requested, names
/// `principal_id` instead of an access key.
#[cfg_attr(not(test), expect(dead_code, reason = "constructed by the internal put entry point"))]
Internal { principal_id: &'static str, emit_events: bool },
}
+14
View File
@@ -1157,6 +1157,19 @@ pub(crate) mod bucket_usecase {
pub(crate) mod object_usecase {
pub(crate) use super::storage_contracts::BUCKET_LIFECYCLE_LOCK_OBJECT;
pub(crate) mod on_demand_migration {
#[cfg(test)]
pub(crate) use crate::storage::storage_api::ecstore_bucket::on_demand_migration::PullFailureReason;
#[cfg(test)]
pub(crate) use crate::storage::storage_api::ecstore_bucket::on_demand_migration::source_client::SourceSse;
pub(crate) use crate::storage::storage_api::ecstore_bucket::on_demand_migration::source_client::{
SourceHead, is_multipart_etag,
};
pub(crate) use crate::storage::storage_api::ecstore_bucket::on_demand_migration::{
LocalObject, OdmWriteBack, WriteBackBody, WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest,
};
}
pub(crate) mod object_cache {
#[cfg(test)]
pub(crate) use crate::storage::storage_api::ecstore_object::GetObjectBodySource;
@@ -1285,6 +1298,7 @@ pub(crate) mod test {
pub(crate) mod data_usage {
pub(crate) use super::super::data_usage::*;
}
pub(crate) use crate::storage::storage_api::bootstrap_instance_ctx;
pub(crate) use crate::storage::storage_api::ecstore_bucket::install_all_v6_fleet_capability_proof;
pub(crate) use crate::storage::storage_api::test_consumer::{get_global_bucket_metadata_sys, set_bucket_metadata};
pub(crate) use crate::storage::storage_api::{
+8 -4
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::app::object::OnDemandMigrationWriteBack;
use crate::module_switches::{on_demand_migration_enabled_from_env, set_on_demand_migration_module_enabled};
use crate::storage_api::startup::bucket_metadata::contract::bucket::{BucketOperations, BucketOptions};
use crate::storage_api::startup::bucket_metadata::{
@@ -90,15 +91,18 @@ pub(crate) async fn init_bucket_metadata_runtime(store: Arc<ECStore>, ctx: Cance
Ok(buckets)
}
/// Publishes the on-demand migration module switch and registers the
/// runtime's config hook before bucket metadata is loaded, so every cache
/// install path (initial load included) reaches `OnDemandMigrationSys`
/// (rustfs/backlog#2152). Idempotent across embedded and server startups.
/// Publishes the on-demand migration module switch, installs the app-layer
/// write-back the pull pipeline stores objects with (rustfs/backlog#2153),
/// and registers the runtime's config hook before bucket metadata is
/// loaded, so every cache install path (initial load included) reaches
/// `OnDemandMigrationSys` with a usable write-back (rustfs/backlog#2152).
/// Idempotent across embedded and server startups.
fn init_on_demand_migration_runtime() {
let enabled = on_demand_migration_enabled_from_env();
set_on_demand_migration_module_enabled(enabled);
let sys = OnDemandMigrationSys::get();
sys.set_module_enabled(enabled);
sys.set_write_back(Arc::new(OnDemandMigrationWriteBack::new()));
let hook_registered = sys.register_config_hook();
tracing::info!(
event = EVENT_ON_DEMAND_MIGRATION_RUNTIME_INITIALIZED,