mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-07 04:25:54 +00:00
8fc1c9281e
* refactor(odm): move migration orchestration into application * style(odm): format relocated listing test imports
1279 lines
59 KiB
Rust
1279 lines
59 KiB
Rust
// 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.
|
|
|
|
//! `ListObjectsV2` list-through for on-demand migration (rustfs/backlog#2164):
|
|
//! when a bucket sets `policy.list_through`, the local listing and the source
|
|
//! listing are merged into one ordered page so clients see the whole namespace
|
|
//! while the migration runs.
|
|
//!
|
|
//! Only `ListObjectsV2` merges. `ListObjects` (v1) and `ListObjectVersions`
|
|
//! stay local: v1 has no opaque continuation token to carry two cursors, and a
|
|
//! version listing has no meaning for a source whose versions were never
|
|
//! pulled.
|
|
|
|
use super::storage_api::bucket_usecase::ECStore;
|
|
use super::storage_api::bucket_usecase::StorageObjectInfo as ObjectInfo;
|
|
use super::storage_api::bucket_usecase::StorageObjectOptions;
|
|
use super::storage_api::bucket_usecase::bucket::versioning_sys::BucketVersioningSys;
|
|
use super::storage_api::bucket_usecase::contract::list::{ListObjectsV2Info as StorageListObjectsV2Info, ListOperations as _};
|
|
use super::storage_api::bucket_usecase::contract::object::ObjectOperations as _;
|
|
use super::storage_api::bucket_usecase::s3::{S3Error, S3ErrorCode, S3Result};
|
|
use super::storage_api::bucket_usecase::s3_api::bucket::ListObjectsV2Params;
|
|
use crate::app::object::shared::{odm_source_unavailable_error, odm_state_error_class};
|
|
use crate::error::ApiError;
|
|
use crate::on_demand_migration::{
|
|
BucketOdmState, ListEntryKey, ListPageError, ListThroughCursor, ListThroughMerger, ListThroughToken, ListThroughTokenError,
|
|
MergeSide, OnDemandMigrationSys, SOURCE_LIST_MAX_RATE_WAIT, SourceClient, SourceError, SourceErrorPolicy, SourceListPlan,
|
|
SourceListRequest, SourceObject, SourcePage, decode_continuation_token, source_list_plan,
|
|
};
|
|
use futures::StreamExt;
|
|
use http::HeaderMap;
|
|
use rustfs_utils::http::{SUFFIX_SOURCE_PROXY_REQUEST, get_header};
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
use time::OffsetDateTime;
|
|
use tracing::debug;
|
|
|
|
type ListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
|
|
|
|
/// Storage class every source entry is reported with: the object is not local
|
|
/// yet, so the only class RustFS can vouch for is the default one.
|
|
const SOURCE_STORAGE_CLASS: &str = "STANDARD";
|
|
|
|
/// Enable only after every node serving continuation requests can read v2.
|
|
const ENV_LIST_PROGRESS_TOKENS: &str = "RUSTFS_ON_DEMAND_MIGRATION_LIST_V2_TOKENS";
|
|
|
|
/// Concurrent local metadata probes when a versioned bucket has to check
|
|
/// source-only keys for a shadowing delete marker.
|
|
const DELETE_MARKER_PROBE_CONCURRENCY: usize = 32;
|
|
|
|
/// Where the local side of a listing resumes.
|
|
pub(crate) enum LocalListCursor {
|
|
/// Continuation token for the local store, `None` for the first page.
|
|
Token(Option<String>),
|
|
/// The local side of a merged listing was already exhausted, so a listing
|
|
/// that no longer merges has nothing left to return.
|
|
Exhausted,
|
|
}
|
|
|
|
/// Reads the (already base64-decoded) continuation token.
|
|
///
|
|
/// This runs whether or not the bucket merges: a token handed out while
|
|
/// `list_through` was on must keep paginating the local side after it is
|
|
/// turned off, and a tampered envelope must be rejected either way.
|
|
pub(crate) fn decode_list_cursor(decoded: Option<&str>) -> S3Result<Option<ListThroughToken>> {
|
|
match decoded.map(decode_continuation_token).transpose() {
|
|
Ok(Some(ListThroughCursor::Merged(token))) => Ok(Some(*token)),
|
|
Ok(_) => Ok(None),
|
|
Err(err) => Err(invalid_continuation_token(&err)),
|
|
}
|
|
}
|
|
|
|
/// The local cursor to use when the request is answered locally, given the
|
|
/// decoded token.
|
|
pub(crate) fn local_cursor(decoded: Option<&str>, merged: Option<&ListThroughToken>) -> LocalListCursor {
|
|
match merged {
|
|
Some(token) if token.local_done => LocalListCursor::Exhausted,
|
|
Some(token) => LocalListCursor::Token(token.local.clone()),
|
|
None => LocalListCursor::Token(decoded.map(str::to_string)),
|
|
}
|
|
}
|
|
|
|
fn invalid_continuation_token(err: &ListThroughTokenError) -> S3Error {
|
|
debug!(error = %err, "rejected an on-demand migration list continuation token");
|
|
S3Error::with_message(S3ErrorCode::InvalidArgument, "Invalid continuation token".to_string())
|
|
}
|
|
|
|
/// The bucket's live migration state when this request must merge the source.
|
|
///
|
|
/// `None` keeps the caller on the plain local listing: the module is off, the
|
|
/// bucket has no source, `list_through` is off, or the request carries the
|
|
/// `source-proxy-request` anti-loop marker and therefore comes from a peer
|
|
/// that must be answered locally.
|
|
pub(crate) fn list_through_state(bucket: &str, headers: &HeaderMap) -> Option<Arc<BucketOdmState>> {
|
|
if get_header(headers, SUFFIX_SOURCE_PROXY_REQUEST).is_some() {
|
|
return None;
|
|
}
|
|
let sys = OnDemandMigrationSys::get();
|
|
if !sys.is_module_enabled() {
|
|
return None;
|
|
}
|
|
let state = sys.state(bucket)?;
|
|
state.config().policy.list_through.then_some(state)
|
|
}
|
|
|
|
/// A merged page plus whether the source had to be left out of it.
|
|
pub(crate) struct ListThroughOutcome {
|
|
pub(crate) info: ListObjectsV2Info,
|
|
/// The source could not be consulted; the answer is local state only and
|
|
/// carries `x-rustfs-on-demand-migration-list: local_only`.
|
|
pub(crate) degraded: bool,
|
|
}
|
|
|
|
/// One entry of either side's buffer, in listing order.
|
|
enum SideEntry {
|
|
Object(Box<ObjectInfo>),
|
|
Prefix(String),
|
|
}
|
|
|
|
impl SideEntry {
|
|
fn key(&self) -> ListEntryKey {
|
|
match self {
|
|
SideEntry::Object(info) => ListEntryKey::object(info.name.clone()),
|
|
SideEntry::Prefix(prefix) => ListEntryKey::prefix(prefix.clone()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Interleaves a listing page's objects and common prefixes into the single
|
|
/// ordered sequence S3 paginates over. Both inputs are already sorted.
|
|
fn interleave(objects: Vec<ObjectInfo>, prefixes: Vec<String>) -> Vec<SideEntry> {
|
|
let mut merged = Vec::with_capacity(objects.len() + prefixes.len());
|
|
let mut objects = objects.into_iter().peekable();
|
|
let mut prefixes = prefixes.into_iter().peekable();
|
|
loop {
|
|
let take_object = match (objects.peek(), prefixes.peek()) {
|
|
(None, None) => break,
|
|
(Some(_), None) => true,
|
|
(None, Some(_)) => false,
|
|
(Some(object), Some(prefix)) => object.name.as_str() < prefix.as_str(),
|
|
};
|
|
if take_object {
|
|
merged.push(SideEntry::Object(Box::new(objects.next().expect("peeked"))));
|
|
} else {
|
|
merged.push(SideEntry::Prefix(prefixes.next().expect("peeked")));
|
|
}
|
|
}
|
|
merged
|
|
}
|
|
|
|
/// One source listing entry in the local namespace. The ETag, size and
|
|
/// last-modified are the source's own; the storage class is `STANDARD` and the
|
|
/// owner (added by the output builder) is this bucket's, because the object is
|
|
/// not local yet and RustFS can vouch for nothing else.
|
|
fn source_object_info(bucket: &str, object: SourceObject) -> ObjectInfo {
|
|
ObjectInfo {
|
|
bucket: bucket.to_string(),
|
|
name: object.key,
|
|
mod_time: object.last_modified.map(OffsetDateTime::from),
|
|
size: i64::try_from(object.size).unwrap_or(i64::MAX),
|
|
etag: object.etag,
|
|
storage_class: Some(SOURCE_STORAGE_CLASS.to_string()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn source_page_entries(bucket: &str, page: SourcePage) -> Vec<SideEntry> {
|
|
let objects = page
|
|
.objects
|
|
.into_iter()
|
|
.filter(|object| !object.key.is_empty())
|
|
.map(|object| source_object_info(bucket, object))
|
|
.collect();
|
|
interleave(objects, page.common_prefixes)
|
|
}
|
|
|
|
/// Runs one merged `ListObjectsV2` page.
|
|
///
|
|
/// Cost: at most two listings per side per request — the first page of each
|
|
/// side, plus one refill when the previous page had already consumed most of
|
|
/// what that side buffered. A full walk of N merged keys at `max_keys = K`
|
|
/// therefore costs ceil(N/K) requests and between ceil(N/K) and 2*ceil(N/K)
|
|
/// source listings.
|
|
pub(crate) async fn merged_list_objects_v2(
|
|
store: &Arc<ECStore>,
|
|
state: &Arc<BucketOdmState>,
|
|
bucket: &str,
|
|
params: &ListObjectsV2Params,
|
|
fetch_owner: bool,
|
|
incl_deleted: bool,
|
|
token: Option<&ListThroughToken>,
|
|
) -> S3Result<ListThroughOutcome> {
|
|
let policy = &state.config().policy;
|
|
let max_keys = usize::try_from(params.max_keys).unwrap_or(0);
|
|
let mut merger = ListThroughMerger::new(max_keys, token);
|
|
let mut buffers: [Vec<Option<SideEntry>>; 2] = [Vec::new(), Vec::new()];
|
|
let mut degraded = false;
|
|
|
|
let plan = source_list_plan(¶ms.prefix, state.config().filter.prefix.as_deref(), params.delimiter.as_deref());
|
|
let mut client: Option<Arc<SourceClient>> = None;
|
|
match &plan {
|
|
// Nothing the source holds can appear under this prefix; that is a
|
|
// filter decision, not a degradation.
|
|
SourceListPlan::Skip => merger.disable_source(),
|
|
_ => match state.client() {
|
|
Ok(ready) if state.breaker().allow_request() => client = Some(Arc::clone(ready)),
|
|
Ok(_) => degrade_or_fail(&mut merger, &mut degraded, policy.source_error, "breaker_open")?,
|
|
Err(error) => degrade_or_fail(&mut merger, &mut degraded, policy.source_error, odm_state_error_class(error))?,
|
|
},
|
|
}
|
|
|
|
while let Some(fetch) = merger.next_fetch() {
|
|
let page = match fetch.side {
|
|
MergeSide::Local => {
|
|
let info = Arc::clone(store)
|
|
.list_objects_v2(
|
|
bucket,
|
|
¶ms.prefix,
|
|
fetch.token.clone(),
|
|
params.delimiter.clone(),
|
|
params.max_keys,
|
|
fetch_owner,
|
|
params.start_after_for_query.clone(),
|
|
incl_deleted,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let objects = info.objects.into_iter().filter(|object| !object.name.is_empty()).collect();
|
|
(interleave(objects, info.prefixes), info.is_truncated, info.next_continuation_token)
|
|
}
|
|
MergeSide::Source => {
|
|
let client = client.as_ref().expect("the source side is disabled without a client");
|
|
match fetch_source_page(state, client, bucket, params, &plan, fetch.token.as_deref()).await {
|
|
Ok(page) => page,
|
|
Err(class) => {
|
|
degrade_or_fail(&mut merger, &mut degraded, policy.source_error, class)?;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
let (entries, is_truncated, next_token) = page;
|
|
let kept: Vec<SideEntry> = entries
|
|
.into_iter()
|
|
.filter(|entry| merger.accepts(&entry.key().name))
|
|
.collect();
|
|
let keys: Vec<ListEntryKey> = kept.iter().map(SideEntry::key).collect();
|
|
if let Err(error) = merger.push_page(fetch.side, keys, is_truncated, next_token) {
|
|
match fetch.side {
|
|
MergeSide::Source => {
|
|
degrade_or_fail(&mut merger, &mut degraded, policy.source_error, "invalid_pagination")?;
|
|
continue;
|
|
}
|
|
MergeSide::Local => return Err(S3Error::with_message(S3ErrorCode::InternalError, error.to_string())),
|
|
}
|
|
}
|
|
buffers[usize::from(fetch.side == MergeSide::Source)].extend(kept.into_iter().map(Some));
|
|
}
|
|
|
|
let issue_progress_tokens = rustfs_utils::get_env_bool(ENV_LIST_PROGRESS_TOKENS, false);
|
|
let outcome = match merger.finish(issue_progress_tokens) {
|
|
Ok(outcome) => outcome,
|
|
Err(ListPageError::NoProgress(MergeSide::Source)) => {
|
|
degrade_or_fail(&mut merger, &mut degraded, policy.source_error, "invalid_pagination")?;
|
|
merger
|
|
.finish(issue_progress_tokens)
|
|
.map_err(|error| S3Error::with_message(S3ErrorCode::InternalError, error.to_string()))?
|
|
}
|
|
Err(error) => return Err(S3Error::with_message(S3ErrorCode::InternalError, error.to_string())),
|
|
};
|
|
drop(merger);
|
|
let mut objects = Vec::with_capacity(outcome.picks.len());
|
|
let mut prefixes = Vec::new();
|
|
let mut source_only_keys = Vec::new();
|
|
for pick in &outcome.picks {
|
|
let buffer = &mut buffers[usize::from(pick.side == MergeSide::Source)];
|
|
// Every pick names a distinct buffered entry, so taking it is safe.
|
|
match buffer[pick.index].take().expect("a merged pick names a buffered entry") {
|
|
SideEntry::Object(info) => {
|
|
if pick.side == MergeSide::Source {
|
|
source_only_keys.push(info.name.clone());
|
|
}
|
|
objects.push(*info);
|
|
}
|
|
SideEntry::Prefix(prefix) => prefixes.push(prefix),
|
|
}
|
|
}
|
|
|
|
if !source_only_keys.is_empty() && policy.respect_local_delete_marker && bucket_keeps_delete_markers(bucket).await {
|
|
let shadowed = local_delete_markers(store, bucket, &source_only_keys).await;
|
|
objects.retain(|object| !shadowed.contains(&object.name));
|
|
}
|
|
|
|
Ok(ListThroughOutcome {
|
|
info: ListObjectsV2Info {
|
|
is_truncated: outcome.is_truncated,
|
|
continuation_token: None,
|
|
next_continuation_token: outcome.next_token.map(|token| token.encode()),
|
|
objects,
|
|
prefixes,
|
|
},
|
|
degraded,
|
|
})
|
|
}
|
|
|
|
/// Applies `policy.source_error` to a source failure: `propagate` fails the
|
|
/// listing with 424, `not_found` answers from local state alone.
|
|
fn degrade_or_fail(
|
|
merger: &mut ListThroughMerger,
|
|
degraded: &mut bool,
|
|
policy: SourceErrorPolicy,
|
|
class: &'static str,
|
|
) -> S3Result<()> {
|
|
match policy {
|
|
SourceErrorPolicy::Propagate => Err(odm_source_unavailable_error(class)),
|
|
SourceErrorPolicy::NotFound => {
|
|
merger.disable_source();
|
|
*degraded = true;
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
type SourceFetch = (Vec<SideEntry>, bool, Option<String>);
|
|
|
|
/// One source listing, rate-limited per bucket. The error is the class label
|
|
/// for the caller's `source_error` decision; the source's own message is never
|
|
/// surfaced.
|
|
async fn fetch_source_page(
|
|
state: &Arc<BucketOdmState>,
|
|
client: &SourceClient,
|
|
bucket: &str,
|
|
params: &ListObjectsV2Params,
|
|
plan: &SourceListPlan,
|
|
token: Option<&str>,
|
|
) -> Result<SourceFetch, &'static str> {
|
|
let Some(wait) = state.list_rate_limiter().reserve(SOURCE_LIST_MAX_RATE_WAIT) else {
|
|
return Err("rate_limited");
|
|
};
|
|
if !wait.is_zero() {
|
|
tokio::time::sleep(wait).await;
|
|
}
|
|
|
|
let request = match plan {
|
|
SourceListPlan::Skip => return Ok((Vec::new(), false, None)),
|
|
SourceListPlan::Page { prefix } => SourceListRequest {
|
|
prefix: Some(prefix.as_str()),
|
|
delimiter: params.delimiter.as_deref(),
|
|
// S3 ignores start-after once a continuation token is present, so
|
|
// the client's own start-after only applies to the first page.
|
|
start_after: token.is_none().then_some(params.start_after_for_query.as_deref()).flatten(),
|
|
continuation_token: token,
|
|
max_keys: params.max_keys,
|
|
},
|
|
// Everything under `filter.prefix` rolls into one common prefix. An
|
|
// empty truncated probe must still follow its cursor before declaring
|
|
// that prefix absent.
|
|
SourceListPlan::Folded { probe_prefix, .. } => SourceListRequest {
|
|
prefix: Some(probe_prefix.as_str()),
|
|
continuation_token: token,
|
|
max_keys: 1,
|
|
..Default::default()
|
|
},
|
|
};
|
|
|
|
let started = Instant::now();
|
|
let result = client.list_page(&request).await;
|
|
// A 404 on a listing is the bucket, not a key: keep it out of the negative
|
|
// cache but let the breaker see everything else (same rule as the backfill).
|
|
match &result {
|
|
Err(SourceError::NotFound) => {}
|
|
other => state.observe_source(started.elapsed(), "", other.as_ref().err()),
|
|
}
|
|
let page = result.map_err(|err| err.class_label())?;
|
|
|
|
match plan {
|
|
SourceListPlan::Folded { common_prefix, .. } => {
|
|
let exists = !page.objects.is_empty() || !page.common_prefixes.is_empty();
|
|
Ok((
|
|
if exists {
|
|
vec![SideEntry::Prefix(common_prefix.clone())]
|
|
} else {
|
|
Vec::new()
|
|
},
|
|
!exists && page.is_truncated,
|
|
if exists { None } else { page.next_continuation_token },
|
|
))
|
|
}
|
|
_ => {
|
|
let is_truncated = page.is_truncated;
|
|
let next = page.next_continuation_token.clone();
|
|
Ok((source_page_entries(bucket, page), is_truncated, next))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether a delete on this bucket leaves a marker behind. Only such a bucket
|
|
/// can shadow a source key the way the read path does.
|
|
async fn bucket_keeps_delete_markers(bucket: &str) -> bool {
|
|
BucketVersioningSys::enabled(bucket).await || BucketVersioningSys::suspended(bucket).await
|
|
}
|
|
|
|
/// The subset of `keys` whose latest local version is a delete marker.
|
|
///
|
|
/// The local `ListObjectsV2` never returns delete markers, so a versioned
|
|
/// bucket has to probe the source-only keys of the page: at most `max_keys`
|
|
/// metadata reads, run with bounded concurrency and without the namespace lock
|
|
/// — a listing is not a linearizable read, and the alternative is one lock per
|
|
/// key per page. A lookup that fails for any other reason leaves the key
|
|
/// visible rather than hiding data on a transient error.
|
|
async fn local_delete_markers(store: &Arc<ECStore>, bucket: &str, keys: &[String]) -> std::collections::HashSet<String> {
|
|
futures::stream::iter(keys.iter().cloned())
|
|
.map(|key| {
|
|
let store = Arc::clone(store);
|
|
let bucket = bucket.to_string();
|
|
async move {
|
|
let options = StorageObjectOptions {
|
|
no_lock: true,
|
|
..Default::default()
|
|
};
|
|
let info = store.get_object_info(&bucket, &key, &options).await;
|
|
matches!(info, Ok(info) if info.delete_marker).then_some(key)
|
|
}
|
|
})
|
|
.buffer_unordered(DELETE_MARKER_PROBE_CONCURRENCY)
|
|
.filter_map(|shadowed| async move { shadowed })
|
|
.collect()
|
|
.await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app::bucket_usecase::DefaultBucketUsecase;
|
|
use crate::app::gating_test_env::{run_large_stack_test, shared_gating_ecstore};
|
|
use crate::app::storage_api::bucket_usecase::s3::{ListObjectsV2Input, ListObjectsV2Output, S3Request, S3Response};
|
|
use crate::app::storage_api::test::StoragePutObjReader;
|
|
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
|
use crate::app::storage_api::test::contract::object::ObjectIO as _;
|
|
use crate::on_demand_migration::{
|
|
FilterConfig, MAX_LIST_NO_PROGRESS_PAGES, OnDemandMigrationConfig, PathStyle, PolicyConfig, Provider, SourceConfig,
|
|
SourceCredentials, TlsConfig,
|
|
};
|
|
use s3s::dto::ListObjectsInput;
|
|
use std::time::Duration;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
fn token(local: Option<&str>, local_done: bool) -> ListThroughToken {
|
|
ListThroughToken {
|
|
t: "odm-list".to_string(),
|
|
v: 1,
|
|
local: local.map(str::to_string),
|
|
local_done,
|
|
source: Some("source-2".to_string()),
|
|
source_done: false,
|
|
last_key: Some("k".to_string()),
|
|
no_progress: None,
|
|
}
|
|
}
|
|
|
|
fn info(name: &str) -> ObjectInfo {
|
|
ObjectInfo {
|
|
name: name.to_string(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn names(entries: &[SideEntry]) -> Vec<String> {
|
|
entries.iter().map(|entry| entry.key().name).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn interleave_orders_objects_and_prefixes_as_one_sequence() {
|
|
let entries = interleave(vec![info("a"), info("b0"), info("c")], vec!["a/".to_string(), "b/".to_string()]);
|
|
assert_eq!(names(&entries), vec!["a", "a/", "b/", "b0", "c"]);
|
|
assert!(matches!(entries[1], SideEntry::Prefix(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn source_entries_report_the_source_facts_with_the_default_storage_class() {
|
|
let page = SourcePage {
|
|
objects: vec![SourceObject {
|
|
key: "photos/a.jpg".to_string(),
|
|
etag: Some("abc".to_string()),
|
|
size: 7,
|
|
last_modified: Some(std::time::SystemTime::UNIX_EPOCH),
|
|
storage_class: Some("GLACIER".to_string()),
|
|
is_multipart_etag: false,
|
|
}],
|
|
common_prefixes: vec!["photos/2024/".to_string()],
|
|
is_truncated: false,
|
|
next_continuation_token: None,
|
|
};
|
|
let entries = source_page_entries("photos", page);
|
|
assert_eq!(names(&entries), vec!["photos/2024/", "photos/a.jpg"]);
|
|
let SideEntry::Object(object) = &entries[1] else {
|
|
panic!("expected an object entry");
|
|
};
|
|
assert_eq!(object.etag.as_deref(), Some("abc"));
|
|
assert_eq!(object.size, 7);
|
|
assert_eq!(object.mod_time, Some(OffsetDateTime::UNIX_EPOCH));
|
|
assert_eq!(
|
|
object.storage_class.as_deref(),
|
|
Some(SOURCE_STORAGE_CLASS),
|
|
"a source storage class is never echoed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_merged_token_survives_list_through_being_turned_off() {
|
|
let resume = token(Some("local-2"), false);
|
|
let encoded = resume.encode();
|
|
let decoded = decode_list_cursor(Some(&encoded)).expect("a valid envelope decodes");
|
|
assert_eq!(decoded.as_ref(), Some(&resume));
|
|
assert!(matches!(
|
|
local_cursor(Some(&encoded), decoded.as_ref()),
|
|
LocalListCursor::Token(Some(local)) if local == "local-2"
|
|
));
|
|
|
|
let encoded = token(None, true).encode();
|
|
let decoded = decode_list_cursor(Some(&encoded)).expect("a valid envelope decodes");
|
|
assert!(matches!(local_cursor(Some(&encoded), decoded.as_ref()), LocalListCursor::Exhausted));
|
|
}
|
|
|
|
#[test]
|
|
fn a_v2_token_keeps_the_local_cursor_when_list_through_is_turned_off() {
|
|
let mut resume = token(Some("local-2"), false);
|
|
resume.v = 2;
|
|
resume.no_progress = Some(MAX_LIST_NO_PROGRESS_PAGES - 1);
|
|
let encoded = resume.encode();
|
|
let decoded = decode_list_cursor(Some(&encoded)).expect("a v2 envelope decodes");
|
|
assert_eq!(decoded.as_ref(), Some(&resume));
|
|
assert!(matches!(
|
|
local_cursor(Some(&encoded), decoded.as_ref()),
|
|
LocalListCursor::Token(Some(local)) if local == "local-2"
|
|
));
|
|
resume.local_done = true;
|
|
let encoded = resume.encode();
|
|
let decoded = decode_list_cursor(Some(&encoded)).expect("v2 with local EOF decodes");
|
|
assert!(matches!(local_cursor(Some(&encoded), decoded.as_ref()), LocalListCursor::Exhausted));
|
|
}
|
|
|
|
#[test]
|
|
fn a_plain_local_token_is_passed_through_and_a_tampered_one_is_rejected() {
|
|
let json_key = r#"{"t":"odm-list","v":1,"local_done":true}"#;
|
|
assert!(decode_list_cursor(Some(json_key)).expect("valid local key").is_none());
|
|
assert!(matches!(local_cursor(Some(json_key), None), LocalListCursor::Token(Some(local)) if local == json_key));
|
|
assert!(
|
|
decode_list_cursor(Some("photos/a.jpg"))
|
|
.expect("plain markers decode")
|
|
.is_none()
|
|
);
|
|
assert!(matches!(
|
|
local_cursor(Some("photos/a.jpg"), None),
|
|
LocalListCursor::Token(Some(local)) if local == "photos/a.jpg"
|
|
));
|
|
|
|
let tampered = token(Some("local-2"), false).encode().replace("\"v\":1", "\"v\":9");
|
|
let err = decode_list_cursor(Some(&tampered)).expect_err("a bumped version is rejected");
|
|
assert_eq!(*err.code(), S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[test]
|
|
fn degrade_or_fail_follows_the_source_error_policy() {
|
|
let mut merger = ListThroughMerger::new(10, None);
|
|
let mut degraded = false;
|
|
assert!(
|
|
degrade_or_fail(&mut merger, &mut degraded, SourceErrorPolicy::Propagate, "server_error").is_err(),
|
|
"propagate must surface the failure"
|
|
);
|
|
assert!(!degraded);
|
|
|
|
degrade_or_fail(&mut merger, &mut degraded, SourceErrorPolicy::NotFound, "server_error")
|
|
.expect("not_found degrades instead of failing");
|
|
assert!(degraded);
|
|
assert_eq!(merger.next_fetch().map(|fetch| fetch.side), Some(MergeSide::Local));
|
|
}
|
|
|
|
/// Serves exactly the scripted S3 pages and joins every connection before
|
|
/// returning. A source retry or unexpected operation fails the test.
|
|
async fn scripted_list_source(pages: Vec<String>) -> (String, tokio_util::task::AbortOnDropHandle<Vec<String>>) {
|
|
let (endpoint, server, _) = list_source(pages.into_iter()).await;
|
|
(endpoint, server)
|
|
}
|
|
|
|
async fn list_source(
|
|
pages: impl Iterator<Item = String> + Send + 'static,
|
|
) -> (
|
|
String,
|
|
tokio_util::task::AbortOnDropHandle<Vec<String>>,
|
|
tokio_util::sync::CancellationToken,
|
|
) {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind listing source");
|
|
let address = listener.local_addr().expect("listing source address");
|
|
let stop = tokio_util::sync::CancellationToken::new();
|
|
let server_stop = stop.clone();
|
|
let server = tokio::spawn(async move {
|
|
let mut requests = Vec::new();
|
|
for body in pages {
|
|
let (mut stream, _) = tokio::select! {
|
|
_ = server_stop.cancelled() => break,
|
|
accepted = listener.accept() => accepted.expect("accept source listing"),
|
|
};
|
|
let mut request = Vec::new();
|
|
let mut chunk = [0; 4096];
|
|
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
let count = stream.read(&mut chunk).await.expect("read signed listing request");
|
|
assert!(count > 0, "source request must include complete headers");
|
|
request.extend_from_slice(&chunk[..count]);
|
|
assert!(request.len() <= 32 * 1024, "listing request headers must be bounded");
|
|
}
|
|
let first_line = String::from_utf8_lossy(&request)
|
|
.lines()
|
|
.next()
|
|
.expect("request line")
|
|
.to_string();
|
|
// The SDK joins the bucket endpoint with the LIST operation's `/` path.
|
|
assert!(
|
|
first_line.starts_with("GET /source-bucket/?"),
|
|
"expected a path-style bucket-root LIST request, got {first_line:?}"
|
|
);
|
|
assert!(first_line.contains("list-type=2"), "expected a ListObjectsV2 query, got {first_line:?}");
|
|
requests.push(first_line);
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-type: application/xml\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
stream.write_all(response.as_bytes()).await.expect("write source page");
|
|
stream.shutdown().await.expect("finish source response");
|
|
}
|
|
requests
|
|
});
|
|
(format!("http://{address}"), tokio_util::task::AbortOnDropHandle::new(server), stop)
|
|
}
|
|
|
|
fn source_xml(next: Option<&str>, truncated: bool, key: Option<&str>) -> String {
|
|
let next = next
|
|
.map(|token| format!("<NextContinuationToken>{token}</NextContinuationToken>"))
|
|
.unwrap_or_default();
|
|
let contents = key
|
|
.map(|key| format!("<Contents><Key>{key}</Key><Size>1</Size></Contents>"))
|
|
.unwrap_or_default();
|
|
format!(
|
|
"<ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><IsTruncated>{truncated}</IsTruncated>{next}{contents}</ListBucketResult>"
|
|
)
|
|
}
|
|
|
|
struct ListThroughTestState {
|
|
bucket: String,
|
|
module_enabled: bool,
|
|
}
|
|
|
|
impl Drop for ListThroughTestState {
|
|
fn drop(&mut self) {
|
|
let sys = OnDemandMigrationSys::get();
|
|
sys.remove(&self.bucket);
|
|
sys.set_module_enabled(self.module_enabled);
|
|
}
|
|
}
|
|
|
|
async fn source_policy_input(
|
|
endpoint: String,
|
|
policy: SourceErrorPolicy,
|
|
resume_source: Option<&str>,
|
|
filter_prefix: Option<&str>,
|
|
) -> (ListThroughTestState, ListObjectsV2Input) {
|
|
let store = shared_gating_ecstore().await;
|
|
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
|
|
let bucket = format!("odm-list-{}", uuid::Uuid::new_v4().simple());
|
|
store
|
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("create list-through bucket");
|
|
store
|
|
.put_object(
|
|
&bucket,
|
|
"z-local",
|
|
&mut StoragePutObjReader::from_vec(vec![1]),
|
|
&StorageObjectOptions::default(),
|
|
)
|
|
.await
|
|
.expect("seed real local listing");
|
|
let sys = OnDemandMigrationSys::get();
|
|
let state_guard = ListThroughTestState {
|
|
bucket: bucket.clone(),
|
|
module_enabled: sys.is_module_enabled(),
|
|
};
|
|
sys.set_module_enabled(true);
|
|
let config = OnDemandMigrationConfig {
|
|
version: 1,
|
|
enabled: true,
|
|
source: SourceConfig {
|
|
provider: Provider::Minio,
|
|
endpoint: Some(endpoint),
|
|
region: "us-east-1".into(),
|
|
bucket: "source-bucket".into(),
|
|
path_style: PathStyle::Path,
|
|
credentials: Some(SourceCredentials {
|
|
access_key: "test-access".into(),
|
|
secret_key: "test-secret".into(),
|
|
session_token: None,
|
|
}),
|
|
tls: TlsConfig::default(),
|
|
azure: None,
|
|
gcs: None,
|
|
},
|
|
filter: FilterConfig {
|
|
prefix: filter_prefix.map(str::to_string),
|
|
..Default::default()
|
|
},
|
|
policy: PolicyConfig {
|
|
list_through: true,
|
|
source_error: policy,
|
|
..Default::default()
|
|
},
|
|
};
|
|
sys.apply(&bucket, Some(&config)).await;
|
|
assert!(
|
|
sys.state(&bucket).expect("ODM state installed").client().is_ok(),
|
|
"fake source client must build"
|
|
);
|
|
let continuation_token = resume_source.map(|source| {
|
|
let token = ListThroughToken {
|
|
t: "odm-list".into(),
|
|
v: 1,
|
|
local: None,
|
|
local_done: false,
|
|
source: Some(source.into()),
|
|
source_done: false,
|
|
last_key: None,
|
|
no_progress: None,
|
|
};
|
|
base64_simd::STANDARD.encode_to_string(token.encode().as_bytes())
|
|
});
|
|
let input = ListObjectsV2Input {
|
|
bucket,
|
|
max_keys: Some(2),
|
|
continuation_token,
|
|
delimiter: filter_prefix.map(|_| "/".to_string()),
|
|
encoding_type: None,
|
|
expected_bucket_owner: None,
|
|
fetch_owner: None,
|
|
optional_object_attributes: None,
|
|
prefix: None,
|
|
request_payer: None,
|
|
start_after: None,
|
|
};
|
|
(state_guard, input)
|
|
}
|
|
|
|
async fn execute_source_list(input: ListObjectsV2Input) -> S3Result<S3Response<ListObjectsV2Output>> {
|
|
let request = S3Request {
|
|
input,
|
|
method: http::Method::GET,
|
|
uri: http::Uri::from_static("/?list-type=2"),
|
|
headers: HeaderMap::new(),
|
|
extensions: http::Extensions::new(),
|
|
credentials: None,
|
|
region: None,
|
|
service: None,
|
|
trailing_headers: None,
|
|
};
|
|
tokio::time::timeout(
|
|
Duration::from_secs(10),
|
|
DefaultBucketUsecase::from_global().execute_list_objects_v2(request),
|
|
)
|
|
.await
|
|
.expect("listing must complete within its bounded source budget")
|
|
}
|
|
|
|
async fn source_policy_request(
|
|
pages: Vec<String>,
|
|
policy: SourceErrorPolicy,
|
|
resume_source: Option<&str>,
|
|
filter_prefix: Option<&str>,
|
|
) -> (S3Result<S3Response<ListObjectsV2Output>>, Vec<String>) {
|
|
let (endpoint, server) = scripted_list_source(pages).await;
|
|
let (_state_guard, input) = source_policy_input(endpoint, policy, resume_source, filter_prefix).await;
|
|
let result = execute_source_list(input).await;
|
|
let requests = tokio::time::timeout(Duration::from_secs(5), server)
|
|
.await
|
|
.expect("source connections must finish")
|
|
.expect("source server must not panic");
|
|
(result, requests)
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn list_objects_v1_stays_local_with_xml_safe_key_markers() {
|
|
run_large_stack_test("list-through-v1-local-markers", || async {
|
|
temp_env::async_with_vars(
|
|
[
|
|
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
|
|
("HTTP_PROXY", None),
|
|
("HTTPS_PROXY", None),
|
|
("ALL_PROXY", None),
|
|
("http_proxy", None),
|
|
("https_proxy", None),
|
|
("all_proxy", None),
|
|
("NO_PROXY", Some("*")),
|
|
("no_proxy", Some("*")),
|
|
],
|
|
async {
|
|
let (endpoint, server, stop) =
|
|
list_source(std::iter::repeat(source_xml(None, false, Some("a-source")))).await;
|
|
let (_state_guard, source_input) =
|
|
source_policy_input(endpoint, SourceErrorPolicy::Propagate, None, None).await;
|
|
let store = shared_gating_ecstore().await;
|
|
store
|
|
.put_object(
|
|
&source_input.bucket,
|
|
"a&local",
|
|
&mut StoragePutObjReader::from_vec(vec![1]),
|
|
&StorageObjectOptions::default(),
|
|
)
|
|
.await
|
|
.expect("seed a second local object");
|
|
|
|
for delimiter in [None, Some("/".to_string())] {
|
|
let mut input = ListObjectsInput {
|
|
bucket: source_input.bucket.clone(),
|
|
max_keys: Some(1),
|
|
delimiter,
|
|
..Default::default()
|
|
};
|
|
for (index, expected_key) in ["a&local", "z-local"].into_iter().enumerate() {
|
|
let request_marker = input.marker.clone().unwrap_or_default();
|
|
let response = tokio::time::timeout(
|
|
Duration::from_secs(10),
|
|
DefaultBucketUsecase::from_global().execute_list_objects(S3Request {
|
|
input: input.clone(),
|
|
method: http::Method::GET,
|
|
uri: http::Uri::from_static("/"),
|
|
headers: HeaderMap::new(),
|
|
extensions: http::Extensions::new(),
|
|
credentials: None,
|
|
region: None,
|
|
service: None,
|
|
trailing_headers: None,
|
|
}),
|
|
)
|
|
.await
|
|
.expect("v1 pagination must finish")
|
|
.expect("list-through must not change v1 listing");
|
|
assert!(!response.headers.contains_key("x-rustfs-on-demand-migration-list"));
|
|
let output = response.output;
|
|
let contents = output.contents.as_ref().expect("local page contents");
|
|
assert_eq!(contents.len(), 1);
|
|
assert_eq!(contents[0].key.as_deref(), Some(expected_key));
|
|
assert_eq!(output.marker.as_deref(), Some(request_marker.as_str()));
|
|
assert_eq!(output.is_truncated, Some(index == 0));
|
|
assert_eq!(output.next_marker.as_deref(), (index == 0).then_some(expected_key));
|
|
|
|
let mut xml = Vec::new();
|
|
s3s::xml::Serialize::serialize(&output, &mut s3s::xml::Serializer::new(&mut xml))
|
|
.expect("serialize the real v1 response");
|
|
assert!(!xml.contains(&0), "XML 1.0 forbids NUL in NextMarker");
|
|
let mut reader = quick_xml::Reader::from_reader(xml.as_slice());
|
|
loop {
|
|
if reader.read_event().expect("v1 response must be well-formed XML")
|
|
== quick_xml::events::Event::Eof
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
input.marker = output.next_marker;
|
|
}
|
|
}
|
|
stop.cancel();
|
|
let requests = server.await.expect("source server must not panic");
|
|
assert!(requests.is_empty(), "ListObjects v1 must issue no remote LIST requests: {requests:?}");
|
|
},
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn list_through_invalid_source_pagination_obeys_policy_on_the_handler_path() {
|
|
run_large_stack_test("list-through-source-policy", || async {
|
|
temp_env::async_with_vars(
|
|
[
|
|
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
|
|
("HTTP_PROXY", None),
|
|
("HTTPS_PROXY", None),
|
|
("ALL_PROXY", None),
|
|
("http_proxy", None),
|
|
("https_proxy", None),
|
|
("all_proxy", None),
|
|
("NO_PROXY", Some("*")),
|
|
("no_proxy", Some("*")),
|
|
],
|
|
async {
|
|
for policy in [SourceErrorPolicy::Propagate, SourceErrorPolicy::NotFound] {
|
|
for next in [None, Some(""), Some("stuck")] {
|
|
for key in [None, Some("a-source")] {
|
|
let (result, requests) =
|
|
source_policy_request(vec![source_xml(next, true, key)], policy, Some("stuck"), None).await;
|
|
assert_eq!(requests.len(), 1, "a malformed source page must not be retried");
|
|
assert!(requests[0].contains("continuation-token=stuck"));
|
|
assert_source_policy_result(result, policy);
|
|
}
|
|
}
|
|
let (result, requests) = source_policy_request(
|
|
vec![
|
|
source_xml(Some("stuck"), true, Some("a-source")),
|
|
source_xml(Some("stuck"), true, None),
|
|
],
|
|
policy,
|
|
None,
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(requests.len(), 2, "the failure must occur during a real refill");
|
|
assert!(!requests[0].contains("continuation-token="));
|
|
assert!(requests[1].contains("continuation-token=stuck"));
|
|
assert_source_policy_result(result, policy);
|
|
}
|
|
},
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn list_through_empty_advancing_source_pages_reach_eof_on_the_handler_path() {
|
|
run_large_stack_test("list-through-empty-source-pages", || async {
|
|
temp_env::async_with_vars(
|
|
[
|
|
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
|
|
("HTTP_PROXY", None),
|
|
("HTTPS_PROXY", None),
|
|
("ALL_PROXY", None),
|
|
("http_proxy", None),
|
|
("https_proxy", None),
|
|
("all_proxy", None),
|
|
("NO_PROXY", Some("*")),
|
|
("no_proxy", Some("*")),
|
|
],
|
|
async {
|
|
for filter_prefix in [None, Some("photos/2024/")] {
|
|
let source_key = if filter_prefix.is_some() {
|
|
"photos/2024/a-source"
|
|
} else {
|
|
"a-source"
|
|
};
|
|
let (result, requests) = source_policy_request(
|
|
vec![
|
|
source_xml(Some("opaque-next"), true, None),
|
|
source_xml(None, false, Some(source_key)),
|
|
],
|
|
SourceErrorPolicy::Propagate,
|
|
None,
|
|
filter_prefix,
|
|
)
|
|
.await;
|
|
assert_eq!(requests.len(), 2, "an empty truncated source page must reach its successor");
|
|
assert!(requests[1].contains("continuation-token=opaque-next"));
|
|
let response = result.expect("empty progressing source page is valid");
|
|
assert!(!response.headers.contains_key("x-rustfs-on-demand-migration-list"));
|
|
let output = response.output;
|
|
let objects: Vec<_> = output
|
|
.contents
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|object| object.key.expect("listed object key"))
|
|
.collect();
|
|
if filter_prefix.is_some() {
|
|
assert_eq!(objects, vec!["z-local"]);
|
|
assert_eq!(
|
|
output
|
|
.common_prefixes
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|prefix| prefix.prefix.expect("rolled-up prefix"))
|
|
.collect::<Vec<_>>(),
|
|
vec!["photos/"]
|
|
);
|
|
} else {
|
|
assert_eq!(objects, vec!["a-source", "z-local"]);
|
|
assert!(output.common_prefixes.unwrap_or_default().is_empty());
|
|
}
|
|
assert_eq!(output.key_count, Some(2));
|
|
assert_eq!(output.is_truncated, Some(false));
|
|
assert!(output.next_continuation_token.is_none());
|
|
}
|
|
},
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn list_through_cross_request_empty_cursor_cycle_obeys_policy() {
|
|
run_large_stack_test("list-through-cross-request-cursor-cycle", || async {
|
|
temp_env::async_with_vars(
|
|
[
|
|
(ENV_LIST_PROGRESS_TOKENS, Some("true")),
|
|
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
|
|
("HTTP_PROXY", None),
|
|
("HTTPS_PROXY", None),
|
|
("ALL_PROXY", None),
|
|
("http_proxy", None),
|
|
("https_proxy", None),
|
|
("all_proxy", None),
|
|
("NO_PROXY", Some("*")),
|
|
("no_proxy", Some("*")),
|
|
],
|
|
async {
|
|
for policy in [SourceErrorPolicy::Propagate, SourceErrorPolicy::NotFound] {
|
|
let pages = ["B", "C", "A"].map(|next| source_xml(Some(next), true, None));
|
|
let (endpoint, server, stop) = list_source(pages.into_iter().cycle()).await;
|
|
let (_state_guard, mut input) = source_policy_input(endpoint, policy, Some("A"), None).await;
|
|
let mut seen = std::collections::HashSet::from([input
|
|
.continuation_token
|
|
.clone()
|
|
.expect("the first request resumes source cursor A")]);
|
|
let mut client_requests = 0;
|
|
let mut empty_pages = 0;
|
|
let terminal = tokio::time::timeout(Duration::from_secs(30), async {
|
|
loop {
|
|
client_requests += 1;
|
|
let response = match execute_source_list(input.clone()).await {
|
|
Ok(response) => response,
|
|
Err(error) => break Err(error),
|
|
};
|
|
if response.headers.contains_key("x-rustfs-on-demand-migration-list") {
|
|
break Ok(response);
|
|
}
|
|
let output = response.output;
|
|
assert!(output.contents.as_ref().is_none_or(Vec::is_empty));
|
|
assert!(output.common_prefixes.as_ref().is_none_or(Vec::is_empty));
|
|
assert_eq!(output.key_count, Some(0));
|
|
assert_eq!(output.is_truncated, Some(true));
|
|
let next = output
|
|
.next_continuation_token
|
|
.expect("a truncated page must carry its cursor");
|
|
assert!(
|
|
seen.insert(next.clone()),
|
|
"a cross-request source cursor cycle must not return an identical empty merged token"
|
|
);
|
|
empty_pages += 1;
|
|
input.continuation_token = Some(next);
|
|
}
|
|
})
|
|
.await
|
|
.expect("a source cursor cycle must terminate within a bounded client pagination chain");
|
|
assert_eq!(empty_pages, usize::from(MAX_LIST_NO_PROGRESS_PAGES - 1));
|
|
assert_eq!(client_requests, usize::from(MAX_LIST_NO_PROGRESS_PAGES));
|
|
assert_source_policy_result(terminal, policy);
|
|
stop.cancel();
|
|
let requests = tokio::time::timeout(Duration::from_secs(5), server)
|
|
.await
|
|
.expect("cyclic source server must stop")
|
|
.expect("cyclic source server must not panic");
|
|
assert_eq!(requests.len(), 2 * client_requests, "the sixteenth empty page exhausts the budget");
|
|
for (index, request) in requests.iter().enumerate() {
|
|
let source_cursor = ["A", "B", "C"][index % 3];
|
|
assert!(
|
|
request.contains(&format!("continuation-token={source_cursor}")),
|
|
"the real SDK must follow the returned source cursor: {request}"
|
|
);
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn list_through_default_rollout_continues_v2_without_issuing_it_from_v1() {
|
|
run_large_stack_test("list-through-reader-first-rollout", || async {
|
|
temp_env::async_with_vars(
|
|
[
|
|
(ENV_LIST_PROGRESS_TOKENS, None),
|
|
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
|
|
("HTTP_PROXY", None),
|
|
("HTTPS_PROXY", None),
|
|
("ALL_PROXY", None),
|
|
("http_proxy", None),
|
|
("https_proxy", None),
|
|
("all_proxy", None),
|
|
("NO_PROXY", Some("*")),
|
|
("no_proxy", Some("*")),
|
|
],
|
|
async {
|
|
for policy in [SourceErrorPolicy::Propagate, SourceErrorPolicy::NotFound] {
|
|
let pages = ["B", "C", "A"].map(|next| source_xml(Some(next), true, None));
|
|
let (endpoint, server, stop) = list_source(pages.into_iter().cycle()).await;
|
|
let (_state_guard, mut input) = source_policy_input(endpoint, policy, Some("A"), None).await;
|
|
let original = input.continuation_token.clone();
|
|
for _ in 0..3 {
|
|
let response = execute_source_list(input.clone()).await.expect("reader-only v1 behavior");
|
|
assert!(!response.headers.contains_key("x-rustfs-on-demand-migration-list"));
|
|
assert_eq!(response.output.key_count, Some(0));
|
|
assert_eq!(response.output.is_truncated, Some(true));
|
|
let next = response.output.next_continuation_token.expect("resumable empty page");
|
|
let raw = base64_simd::STANDARD.decode_to_vec(&next).expect("base64 continuation token");
|
|
let decoded = std::str::from_utf8(&raw).expect("JSON token");
|
|
let token = decode_list_cursor(Some(decoded)).expect("v1 reader").expect("merged token");
|
|
assert_eq!(token.v, 1, "the default rollout cannot begin issuing v2");
|
|
assert_eq!(token.no_progress, None);
|
|
assert!(!decoded.contains("no_progress"), "ordinary v1 wire shape stays unchanged");
|
|
input.continuation_token = Some(next);
|
|
}
|
|
assert_eq!(input.continuation_token, original, "default rollout retains the known v1 limitation");
|
|
|
|
let raw = base64_simd::STANDARD
|
|
.decode_to_vec(input.continuation_token.as_ref().expect("v1 token"))
|
|
.expect("base64 continuation token");
|
|
let mut token = decode_list_cursor(Some(std::str::from_utf8(&raw).expect("JSON token")))
|
|
.expect("v1 reader")
|
|
.expect("merged token");
|
|
token.v = 2;
|
|
token.no_progress = Some(MAX_LIST_NO_PROGRESS_PAGES - 2);
|
|
input.continuation_token = Some(base64_simd::STANDARD.encode_to_string(token.encode().as_bytes()));
|
|
let response = execute_source_list(input.clone()).await.expect("reader-only node resumes v2");
|
|
assert_eq!(response.output.key_count, Some(0));
|
|
assert_eq!(response.output.is_truncated, Some(true));
|
|
let next = response.output.next_continuation_token.expect("last allowed empty cursor");
|
|
let raw = base64_simd::STANDARD.decode_to_vec(&next).expect("base64 continuation token");
|
|
let token = decode_list_cursor(Some(std::str::from_utf8(&raw).expect("JSON token")))
|
|
.expect("v2 reader")
|
|
.expect("merged token");
|
|
assert_eq!(token.v, 2);
|
|
assert_eq!(token.no_progress, Some(MAX_LIST_NO_PROGRESS_PAGES - 1));
|
|
input.continuation_token = Some(next);
|
|
assert_source_policy_result(execute_source_list(input).await, policy);
|
|
stop.cancel();
|
|
let requests = tokio::time::timeout(Duration::from_secs(5), server)
|
|
.await
|
|
.expect("cyclic source server must stop")
|
|
.expect("source server must not panic");
|
|
assert_eq!(requests.len(), 10, "five handler requests each fetched two source pages");
|
|
for (index, request) in requests.iter().enumerate() {
|
|
let cursor = ["A", "B", "C"][index % 3];
|
|
assert!(request.contains(&format!("continuation-token={cursor}")), "{request}");
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn list_through_empty_advancing_pages_resume_across_handler_requests() {
|
|
run_large_stack_test("list-through-resumable-empty-pages", || async {
|
|
temp_env::async_with_vars(
|
|
[
|
|
(ENV_LIST_PROGRESS_TOKENS, Some("true")),
|
|
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
|
|
("HTTP_PROXY", None),
|
|
("HTTPS_PROXY", None),
|
|
("ALL_PROXY", None),
|
|
("http_proxy", None),
|
|
("https_proxy", None),
|
|
("all_proxy", None),
|
|
("NO_PROXY", Some("*")),
|
|
("no_proxy", Some("*")),
|
|
],
|
|
async {
|
|
for filter_prefix in [None, Some("photos/2024/")] {
|
|
let source_key = filter_prefix.map_or("a-source", |_| "photos/2024/a-source");
|
|
let (endpoint, server) = scripted_list_source(vec![
|
|
source_xml(Some("A"), true, None),
|
|
source_xml(Some("B"), true, None),
|
|
source_xml(Some("C"), true, None),
|
|
source_xml(None, false, Some(source_key)),
|
|
])
|
|
.await;
|
|
let (_state_guard, mut input) =
|
|
source_policy_input(endpoint, SourceErrorPolicy::Propagate, None, filter_prefix).await;
|
|
let first = execute_source_list(input.clone())
|
|
.await
|
|
.expect("valid empty pages must remain resumable");
|
|
assert!(!first.headers.contains_key("x-rustfs-on-demand-migration-list"));
|
|
assert!(first.output.contents.as_ref().is_none_or(Vec::is_empty));
|
|
assert!(first.output.common_prefixes.as_ref().is_none_or(Vec::is_empty));
|
|
assert_eq!(first.output.key_count, Some(0));
|
|
assert_eq!(first.output.is_truncated, Some(true));
|
|
input.continuation_token = Some(first.output.next_continuation_token.expect("empty advancing cursor"));
|
|
|
|
let second = execute_source_list(input)
|
|
.await
|
|
.expect("a progressing empty chain must reach its data");
|
|
assert!(!second.headers.contains_key("x-rustfs-on-demand-migration-list"));
|
|
let output = second.output;
|
|
let objects = output
|
|
.contents
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|object| object.key.expect("listed object key"))
|
|
.collect::<Vec<_>>();
|
|
let prefixes = output
|
|
.common_prefixes
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|prefix| prefix.prefix.expect("listed common prefix"))
|
|
.collect::<Vec<_>>();
|
|
if filter_prefix.is_some() {
|
|
assert_eq!(objects, vec!["z-local"]);
|
|
assert_eq!(prefixes, vec!["photos/"]);
|
|
} else {
|
|
assert_eq!(objects, vec!["a-source", "z-local"]);
|
|
assert!(prefixes.is_empty());
|
|
}
|
|
assert_eq!(output.key_count, Some(2));
|
|
assert_eq!(output.is_truncated, Some(false));
|
|
assert!(output.next_continuation_token.is_none());
|
|
let requests = tokio::time::timeout(Duration::from_secs(5), server)
|
|
.await
|
|
.expect("finite source connections must finish")
|
|
.expect("finite source server must not panic");
|
|
assert_eq!(requests.len(), 4);
|
|
assert!(!requests[0].contains("continuation-token="));
|
|
for (request, cursor) in requests[1..].iter().zip(["A", "B", "C"]) {
|
|
assert!(request.contains(&format!("continuation-token={cursor}")), "{request}");
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
fn assert_source_policy_result(result: S3Result<S3Response<ListObjectsV2Output>>, policy: SourceErrorPolicy) {
|
|
match policy {
|
|
SourceErrorPolicy::Propagate => {
|
|
let error = result.expect_err("propagate must expose malformed pagination");
|
|
assert_eq!(error.status_code(), Some(http::StatusCode::FAILED_DEPENDENCY));
|
|
assert_eq!(error.code(), &S3ErrorCode::Custom("SourceUnavailable".into()));
|
|
assert_eq!(error.message(), Some("invalid_pagination"));
|
|
}
|
|
SourceErrorPolicy::NotFound => {
|
|
let response = result.expect("not_found must preserve the local listing");
|
|
assert_eq!(
|
|
response
|
|
.headers
|
|
.get("x-rustfs-on-demand-migration-list")
|
|
.expect("local_only header"),
|
|
"local_only"
|
|
);
|
|
let output = response.output;
|
|
assert_eq!(
|
|
output
|
|
.contents
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|object| object.key.expect("local key"))
|
|
.collect::<Vec<_>>(),
|
|
vec!["z-local"]
|
|
);
|
|
assert_eq!(output.is_truncated, Some(false));
|
|
assert_eq!(output.key_count, Some(1));
|
|
assert!(output.next_continuation_token.is_none());
|
|
}
|
|
}
|
|
}
|
|
}
|