feat(odm): merge the source listing into ListObjectsV2 (#7112)

* feat(odm): merge the source listing into ListObjectsV2

Adds policy.list_through: ListObjectsV2 merges the local and source
listings into one ordered page so clients see the whole namespace during
an on-demand migration. Local entries win a key both sides hold,
CommonPrefixes are unioned under a delimiter, and the continuation token
is an opaque versioned envelope carrying both cursors.

A source listing failure or an open breaker follows policy.source_error:
propagate answers 424, not_found answers from local state and marks the
response x-rustfs-on-demand-migration-list: local_only. Source listings
are capped at 10 per second per bucket.

* test(odm): refresh the e2e-full darwin selection digest

The list-through e2e module adds seven cases to the merge lane.
This commit is contained in:
Zhengchao An
2026-09-04 09:08:35 +08:00
committed by GitHub
parent 80c88a9031
commit 7dfc2ee5f0
22 changed files with 1914 additions and 33 deletions
+7 -2
View File
@@ -166,6 +166,11 @@ pub mod bucket {
WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
idle_guarded_body,
};
pub use crate::bucket::on_demand_migration::{
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
};
pub mod backfill {
pub use crate::bucket::on_demand_migration::backfill::{
BACKFILL_CHECKPOINT_FILE, BACKFILL_CHECKPOINT_FORMAT_VERSION, BACKFILL_FAILED_KEYS_CAPACITY, BACKFILL_LEASE,
@@ -179,8 +184,8 @@ pub mod bucket {
}
pub mod source_client {
pub use crate::bucket::on_demand_migration::source_client::{
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceObject, SourcePage, SourceProbe,
SourceProvider, SourceSse, SourceTimeouts, USER_AGENT_SUFFIX, is_multipart_etag, range_header_value,
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceListRequest, SourceObject, SourcePage,
SourceProbe, SourceProvider, SourceSse, SourceTimeouts, USER_AGENT_SUFFIX, is_multipart_etag, range_header_value,
resolve_path_style,
};
}
@@ -1717,6 +1717,7 @@ mod tests {
objects,
is_truncated,
next_continuation_token: is_truncated.then(|| end.to_string()),
..Default::default()
})
}
@@ -238,6 +238,11 @@ pub struct PolicyConfig {
pub range_get: RangeGetPolicy,
#[serde(default)]
pub source_error: SourceErrorPolicy,
/// Merge the source listing into `ListObjectsV2` so clients see the whole
/// namespace during the migration (rustfs/backlog#2164). Off by default:
/// it puts the source in the path of every listing.
#[serde(default)]
pub list_through: bool,
#[serde(default = "default_true")]
pub respect_local_delete_marker: bool,
#[serde(default = "default_true")]
@@ -268,6 +273,7 @@ impl Default for PolicyConfig {
head: HeadPolicy::default(),
range_get: RangeGetPolicy::default(),
source_error: SourceErrorPolicy::default(),
list_through: false,
respect_local_delete_marker: true,
preserve_etag: true,
copy_tags: false,
@@ -615,6 +621,7 @@ mod tests {
"head": "proxy",
"range_get": "serve_and_backfill",
"source_error": "propagate",
"list_through": false,
"respect_local_delete_marker": true,
"preserve_etag": true,
"copy_tags": false,
@@ -0,0 +1,824 @@
// 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.
//! Optional `ListObjectsV2` list-through (`policy.list_through`,
//! rustfs/backlog#2164): the local listing and the source listing are merged
//! into one ordered page so clients see the whole namespace while a bucket is
//! migrating.
//!
//! Everything here is pure. The handler owns the I/O and the payloads; this
//! module owns the ordering, the page boundary, and the opaque continuation
//! token that carries both cursors.
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
/// The only continuation-token envelope version this build reads and writes.
pub const LIST_THROUGH_TOKEN_VERSION: u32 = 1;
/// Envelope marker. A bucket that is *not* merging hands out the local
/// listing's own marker, so the decoder needs a positive signal before it
/// treats an opaque token as a merged one.
const LIST_THROUGH_TOKEN_TAG: &str = "odm-list";
/// Pages fetched per side per request: the first page, plus at most one refill
/// when the first one was mostly consumed by the previous page. Two pages of
/// `max_keys` always cover a full merged page, so this is a bound, not a
/// heuristic.
pub const MAX_LIST_FETCHES_PER_SIDE: usize = 2;
/// Per-bucket ceiling on source `ListObjectsV2` calls, in calls per second.
pub const SOURCE_LIST_RATE_PER_SEC: u32 = 10;
/// How long a listing may wait for a source rate-limit slot before it gives up
/// and answers from local state alone.
pub const SOURCE_LIST_MAX_RATE_WAIT: Duration = Duration::from_secs(1);
/// One listing entry as the merge orders it: an object key, or — under a
/// delimiter — a rolled-up common prefix. Both sort by `name` alone, which is
/// how S3 interleaves `Contents` and `CommonPrefixes` on the wire.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListEntryKey {
pub name: String,
pub is_prefix: bool,
}
impl ListEntryKey {
pub fn object(name: impl Into<String>) -> Self {
Self {
name: name.into(),
is_prefix: false,
}
}
pub fn prefix(name: impl Into<String>) -> Self {
Self {
name: name.into(),
is_prefix: true,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MergeSide {
Local,
Source,
}
/// One entry of the merged page: the side it came from and its index in that
/// side's buffer, in push order. The caller keeps the payloads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MergePick {
pub side: MergeSide,
pub index: usize,
}
/// The continuation-token envelope. Opaque to clients: it is serialized as
/// JSON and then base64-encoded by the same helper that encodes a plain local
/// marker, so the wire shape is `base64(json)`.
///
/// A `null` cursor with `done = false` means "list that side from the start";
/// `done = true` means the side is finished and must not be listed again.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListThroughToken {
/// Envelope marker, always [`LIST_THROUGH_TOKEN_TAG`].
pub t: String,
pub v: u32,
#[serde(default)]
pub local: Option<String>,
#[serde(default)]
pub local_done: bool,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub source_done: bool,
/// Last entry the previous page consumed. A side whose page was only
/// partially consumed is re-listed from the same cursor and everything at
/// or below this key is dropped, which is delimiter-safe: a rolled-up
/// common prefix compares as itself, never as its members.
#[serde(default)]
pub last_key: Option<String>,
}
impl ListThroughToken {
fn new(local: SideCursor, source: SideCursor, last_key: Option<String>) -> Self {
Self {
t: LIST_THROUGH_TOKEN_TAG.to_string(),
v: LIST_THROUGH_TOKEN_VERSION,
local: local.token,
local_done: local.done,
source: source.token,
source_done: source.done,
last_key,
}
}
pub fn encode(&self) -> String {
// The envelope is built here from owned strings, so serialization
// cannot fail; the fallback keeps the signature infallible.
serde_json::to_string(self).unwrap_or_default()
}
}
/// What a decoded (base64-stripped) continuation token turned out to be.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ListThroughCursor {
/// A plain local listing marker: the bucket was not merging when the token
/// was issued, or the client is paginating a non-merged listing.
Local(String),
Merged(Box<ListThroughToken>),
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum ListThroughTokenError {
#[error("continuation token version {0} is not supported")]
UnsupportedVersion(u32),
/// The message never echoes the token: it is client-controlled input.
#[error("continuation token is malformed")]
Malformed,
}
/// Classifies an already base64-decoded continuation token.
///
/// Only a JSON object carrying the envelope marker is read as a merged token;
/// anything else is a local marker, so a bucket that turns `list_through` off
/// keeps paginating with the tokens it handed out. A token that *is* an
/// envelope but was tampered with (unknown version, unknown field, truncated
/// JSON) is an error, never a silent fallback.
pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
if !decoded.starts_with('{') {
return Ok(ListThroughCursor::Local(decoded.to_string()));
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(decoded) else {
// Not JSON at all: an object key may legitimately start with '{'.
return Ok(ListThroughCursor::Local(decoded.to_string()));
};
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
return Ok(ListThroughCursor::Local(decoded.to_string()));
}
match value.get("v").and_then(serde_json::Value::as_u64) {
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {}
Some(version) => return Err(ListThroughTokenError::UnsupportedVersion(version.min(u64::from(u32::MAX)) as u32)),
None => return Err(ListThroughTokenError::Malformed),
}
serde_json::from_value::<ListThroughToken>(value)
.map(|token| ListThroughCursor::Merged(Box::new(token)))
.map_err(|_| ListThroughTokenError::Malformed)
}
/// How the source must be listed for a request, given `filter.prefix`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SourceListPlan {
/// The request prefix and `filter.prefix` are disjoint: the source holds
/// nothing this listing could show.
Skip,
/// Ordinary paged listing under `prefix`, rolled up with the request's
/// delimiter — the source's own roll-up boundary matches the request's.
Page { prefix: String },
/// `filter.prefix` reaches past a delimiter, so every key the source could
/// contribute rolls into this one common prefix. One bounded probe listing
/// decides whether it exists; there is nothing to paginate.
Folded { probe_prefix: String, common_prefix: String },
}
/// Intersects the request prefix with `filter.prefix` and decides how (or
/// whether) the source is listed.
pub fn source_list_plan(request_prefix: &str, filter_prefix: Option<&str>, delimiter: Option<&str>) -> SourceListPlan {
let filter = filter_prefix.unwrap_or_default();
let source_prefix = if filter.starts_with(request_prefix) {
filter
} else if request_prefix.starts_with(filter) {
request_prefix
} else {
return SourceListPlan::Skip;
};
let Some(delimiter) = delimiter.filter(|delimiter| !delimiter.is_empty()) else {
return SourceListPlan::Page {
prefix: source_prefix.to_string(),
};
};
// `source_prefix` always starts with `request_prefix`, so this slice is on
// a character boundary.
let extra = &source_prefix[request_prefix.len()..];
match extra.find(delimiter) {
Some(at) => SourceListPlan::Folded {
probe_prefix: source_prefix.to_string(),
common_prefix: format!("{request_prefix}{}", &extra[..at + delimiter.len()]),
},
None => SourceListPlan::Page {
prefix: source_prefix.to_string(),
},
}
}
/// Where one side resumes.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SideCursor {
pub token: Option<String>,
pub done: bool,
}
/// One page a side actually fetched this round.
#[derive(Clone, Debug, PartialEq, Eq)]
struct FetchedPage {
/// Token it was fetched with; `None` means from the start of the listing.
token: Option<String>,
/// Entries it contributed to the buffer, after the `last_key` filter.
count: usize,
/// Cursor for the page after it, `None` when it was the last one.
next_token: Option<String>,
}
/// Where a side resumes after `consumed` of its buffered entries were taken.
///
/// A fully consumed page advances to its successor; a partially consumed one
/// is re-listed from the same cursor next time and re-filtered by `last_key`.
fn advance_cursor(pages: &[FetchedPage], consumed: usize) -> SideCursor {
let mut remaining = consumed;
let mut cursor = SideCursor { token: None, done: true };
for page in pages {
if remaining >= page.count {
remaining -= page.count;
cursor = match &page.next_token {
Some(next) => SideCursor {
token: Some(next.clone()),
done: false,
},
None => SideCursor { token: None, done: true },
};
} else {
cursor = SideCursor {
token: page.token.clone(),
done: false,
};
break;
}
}
cursor
}
/// A page the merge driver still needs.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FetchRequest {
pub side: MergeSide,
pub token: Option<String>,
}
#[derive(Debug, Default)]
struct SideState {
start: SideCursor,
pages: Vec<FetchedPage>,
entries: Vec<ListEntryKey>,
more: bool,
disabled: bool,
}
impl SideState {
fn from_cursor(token: Option<String>, done: bool) -> Self {
Self {
start: SideCursor { token, done },
..Default::default()
}
}
fn needs_page(&self, max_keys: usize) -> Option<Option<String>> {
if self.disabled || self.start.done {
return None;
}
match self.pages.last() {
None => Some(self.start.token.clone()),
Some(last) => {
let room = self.entries.len() < max_keys;
let capped = self.pages.len() >= MAX_LIST_FETCHES_PER_SIDE;
(self.more && room && !capped).then(|| last.next_token.clone())
}
}
}
}
/// The merged page, once both sides have handed over everything they will.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MergeOutcome {
/// Entries of the merged page, in wire order; indices point into each
/// side's buffer in push order.
pub picks: Vec<MergePick>,
pub is_truncated: bool,
/// `Some` exactly when `is_truncated`.
pub next_token: Option<ListThroughToken>,
}
/// Drives one merged page: the caller asks [`Self::next_fetch`] what to list,
/// hands the page back with [`Self::push_page`], and finishes with
/// [`Self::finish`]. Nothing here does I/O, so the same driver is exercised by
/// the property test and by the handler.
#[derive(Debug)]
pub struct ListThroughMerger {
max_keys: usize,
last_key: Option<String>,
local: SideState,
source: SideState,
}
impl ListThroughMerger {
/// `token` is the envelope from the client's continuation token, absent on
/// the first page of a listing.
pub fn new(max_keys: usize, token: Option<&ListThroughToken>) -> Self {
let (local, source, last_key) = match token {
Some(token) => (
SideState::from_cursor(token.local.clone(), token.local_done),
SideState::from_cursor(token.source.clone(), token.source_done),
token.last_key.clone(),
),
None => (SideState::default(), SideState::default(), None),
};
Self {
max_keys,
last_key,
local,
source,
}
}
/// Whether an entry the listing returned still belongs to this page: a
/// re-listed page repeats what the previous page already consumed.
pub fn accepts(&self, name: &str) -> bool {
self.last_key.as_deref().is_none_or(|bound| name > bound)
}
/// The source contributes nothing to this page: it failed, is rate-limited,
/// or `filter.prefix` excludes it.
pub fn disable_source(&mut self) {
self.source.disabled = true;
}
pub fn next_fetch(&self) -> Option<FetchRequest> {
for (side, state) in [(MergeSide::Local, &self.local), (MergeSide::Source, &self.source)] {
if let Some(token) = state.needs_page(self.max_keys) {
return Some(FetchRequest { side, token });
}
}
None
}
/// Records one fetched page. `entries` must be sorted by `name` and already
/// filtered with [`Self::accepts`]; the caller keeps the matching payloads
/// in the same order.
pub fn push_page(&mut self, side: MergeSide, entries: Vec<ListEntryKey>, is_truncated: bool, next_token: Option<String>) {
let state = match side {
MergeSide::Local => &mut self.local,
MergeSide::Source => &mut self.source,
};
let token = match state.pages.last() {
Some(last) => last.next_token.clone(),
None => state.start.token.clone(),
};
// A truncated page without a cursor cannot be continued; treating the
// side as finished is the only alternative to looping on it forever.
state.more = is_truncated && next_token.is_some();
state.pages.push(FetchedPage {
token,
count: entries.len(),
next_token: is_truncated.then_some(next_token).flatten(),
});
state.entries.extend(entries);
}
pub fn finish(self) -> MergeOutcome {
let Self {
max_keys,
last_key,
local,
source,
} = self;
// A side with more pages behind it can only be trusted up to the last
// key it handed over: past that horizon the other side's entries could
// still be deduplicated by one we have not seen, which is what keeps
// "local wins on equal keys" true across page boundaries.
let horizon = [
local
.more
.then(|| local.entries.last().map_or("", |entry| entry.name.as_str())),
source
.more
.then(|| source.entries.last().map_or("", |entry| entry.name.as_str())),
]
.into_iter()
.flatten()
.min();
let mut picks = Vec::with_capacity(max_keys.min(local.entries.len() + source.entries.len()));
let mut consumed_local = 0usize;
let mut consumed_source = 0usize;
let mut consumed_key: Option<String> = None;
while picks.len() < max_keys {
let next_local = local.entries.get(consumed_local).map(|entry| entry.name.as_str());
let next_source = source.entries.get(consumed_source).map(|entry| entry.name.as_str());
let name = match (next_local, next_source) {
(None, None) => break,
(Some(name), None) | (None, Some(name)) => name,
(Some(left), Some(right)) => left.min(right),
};
if horizon.is_some_and(|horizon| name > horizon) {
break;
}
let take_local = next_local == Some(name);
let take_source = next_source == Some(name);
consumed_key = Some(name.to_string());
if take_local {
picks.push(MergePick {
side: MergeSide::Local,
index: consumed_local,
});
consumed_local += 1;
} else {
picks.push(MergePick {
side: MergeSide::Source,
index: consumed_source,
});
}
if take_source {
consumed_source += 1;
}
}
let local_cursor = advance_cursor(&local.pages, consumed_local);
let source_cursor = if source.disabled {
// Keep the source where it was so a recovered source resumes there;
// this page is answered from local state alone.
source.start.clone()
} else {
advance_cursor(&source.pages, consumed_source)
};
let local_left = !local_cursor.done || consumed_local < local.entries.len();
let source_left = !source.disabled && (!source_cursor.done || consumed_source < source.entries.len());
let is_truncated = local_left || source_left;
let last_key = consumed_key.or(last_key);
MergeOutcome {
picks,
is_truncated,
next_token: is_truncated.then(|| ListThroughToken::new(local_cursor, source_cursor, last_key)),
}
}
}
/// Token bucket capping source `ListObjectsV2` calls for one bucket.
///
/// A caller that cannot be served inside its budget is refused rather than
/// queued: a listing degrades to local state instead of holding the request
/// open behind other tenants' listings.
#[derive(Debug)]
pub struct SourceListRateLimiter {
rate_per_sec: f64,
burst: f64,
state: Mutex<RateLimiterState>,
}
#[derive(Debug)]
struct RateLimiterState {
tokens: f64,
updated_at: Instant,
}
impl SourceListRateLimiter {
pub fn new(rate_per_sec: u32) -> Self {
let rate_per_sec = f64::from(rate_per_sec.max(1));
Self {
rate_per_sec,
burst: rate_per_sec,
state: Mutex::new(RateLimiterState {
tokens: rate_per_sec,
updated_at: Instant::now(),
}),
}
}
/// Reserves one call, returning how long the caller must wait before making
/// it, or `None` when that wait would exceed `max_wait` (nothing is
/// reserved then).
pub fn reserve(&self, max_wait: Duration) -> Option<Duration> {
self.reserve_at(Instant::now(), max_wait)
}
pub fn reserve_at(&self, now: Instant, max_wait: Duration) -> Option<Duration> {
let mut state = self.state.lock();
let elapsed = now.saturating_duration_since(state.updated_at).as_secs_f64();
state.tokens = (state.tokens + elapsed * self.rate_per_sec).min(self.burst);
state.updated_at = now;
if state.tokens >= 1.0 {
state.tokens -= 1.0;
return Some(Duration::ZERO);
}
let wait = Duration::from_secs_f64((1.0 - state.tokens) / self.rate_per_sec);
if wait > max_wait {
return None;
}
state.tokens -= 1.0;
Some(wait)
}
}
impl Default for SourceListRateLimiter {
fn default() -> Self {
Self::new(SOURCE_LIST_RATE_PER_SEC)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::collections::BTreeSet;
/// One `ListObjectsV2` page over a sorted key set, with the S3 rules the
/// merge relies on: delimiter roll-up, `max_keys`, and a continuation
/// token that resumes after the last entry the page returned.
fn reference_page(
keys: &[String],
prefix: &str,
delimiter: Option<&str>,
after: Option<&str>,
max_keys: usize,
) -> (Vec<ListEntryKey>, bool, Option<String>) {
let mut entries: Vec<ListEntryKey> = Vec::new();
for key in keys.iter().filter(|key| key.starts_with(prefix)) {
let entry = match delimiter.and_then(|delimiter| key[prefix.len()..].find(delimiter).map(|at| (delimiter, at))) {
Some((delimiter, at)) => ListEntryKey::prefix(&key[..prefix.len() + at + delimiter.len()]),
None => ListEntryKey::object(key.clone()),
};
if entries.last().is_none_or(|last| last.name != entry.name) {
entries.push(entry);
}
}
if let Some(after) = after {
entries.retain(|entry| entry.name.as_str() > after);
}
let truncated = entries.len() > max_keys;
entries.truncate(max_keys);
let next = truncated.then(|| entries.last().map(|entry| entry.name.clone())).flatten();
(entries, truncated && next.is_some(), next)
}
/// Full pagination through the merger, returning every entry it emitted and
/// the page sizes it produced.
fn walk(
local: &[String],
source: &[String],
prefix: &str,
delimiter: Option<&str>,
max_keys: usize,
) -> (Vec<(ListEntryKey, MergeSide)>, Vec<usize>) {
let mut emitted = Vec::new();
let mut page_sizes = Vec::new();
let mut token: Option<ListThroughToken> = None;
for _ in 0..10_000 {
let mut merger = ListThroughMerger::new(max_keys, token.as_ref());
let mut buffers = [Vec::<ListEntryKey>::new(), Vec::<ListEntryKey>::new()];
while let Some(fetch) = merger.next_fetch() {
let keys = match fetch.side {
MergeSide::Local => local,
MergeSide::Source => source,
};
let (entries, truncated, next) = reference_page(keys, prefix, delimiter, fetch.token.as_deref(), max_keys);
let kept: Vec<ListEntryKey> = entries.into_iter().filter(|entry| merger.accepts(&entry.name)).collect();
buffers[usize::from(fetch.side == MergeSide::Source)].extend(kept.iter().cloned());
merger.push_page(fetch.side, kept, truncated, next);
}
let outcome = merger.finish();
page_sizes.push(outcome.picks.len());
for pick in &outcome.picks {
let entry = buffers[usize::from(pick.side == MergeSide::Source)][pick.index].clone();
emitted.push((entry, pick.side));
}
if !outcome.is_truncated {
return (emitted, page_sizes);
}
token = outcome.next_token;
}
panic!("merged pagination did not terminate");
}
fn expected(local: &[String], source: &[String], prefix: &str, delimiter: Option<&str>) -> Vec<ListEntryKey> {
let mut all: Vec<String> = local.iter().chain(source.iter()).cloned().collect();
all.sort();
all.dedup();
let (entries, _, _) = reference_page(&all, prefix, delimiter, None, usize::MAX);
entries
}
#[test]
fn reference_page_rolls_up_and_paginates() {
let keys = vec!["a/1".to_string(), "a/2".to_string(), "b".to_string(), "c/1".to_string()];
let (entries, truncated, next) = reference_page(&keys, "", Some("/"), None, 2);
assert_eq!(entries, vec![ListEntryKey::prefix("a/"), ListEntryKey::object("b")]);
assert!(truncated);
assert_eq!(next.as_deref(), Some("b"));
}
#[test]
fn merged_pages_are_ordered_and_local_wins_on_equal_keys() {
let local = vec!["a".to_string(), "c".to_string()];
let source = vec!["b".to_string(), "c".to_string(), "d".to_string()];
let (emitted, sizes) = walk(&local, &source, "", None, 2);
let names: Vec<&str> = emitted.iter().map(|(entry, _)| entry.name.as_str()).collect();
assert_eq!(names, vec!["a", "b", "c", "d"]);
assert_eq!(emitted[2].1, MergeSide::Local, "the shared key must come from local");
assert!(sizes.iter().all(|size| *size <= 2), "{sizes:?}");
}
#[test]
fn source_only_listing_paginates_without_a_local_side() {
let source: Vec<String> = (0..7).map(|index| format!("k{index}")).collect();
let (emitted, _) = walk(&[], &source, "", None, 3);
assert_eq!(emitted.len(), 7);
assert!(emitted.iter().all(|(_, side)| *side == MergeSide::Source));
}
#[test]
fn a_disabled_source_answers_from_local_alone() {
let mut merger = ListThroughMerger::new(10, None);
merger.disable_source();
assert_eq!(
merger.next_fetch(),
Some(FetchRequest {
side: MergeSide::Local,
token: None
})
);
merger.push_page(MergeSide::Local, vec![ListEntryKey::object("a")], false, None);
assert_eq!(merger.next_fetch(), None);
let outcome = merger.finish();
assert_eq!(outcome.picks.len(), 1);
assert!(!outcome.is_truncated);
assert!(outcome.next_token.is_none());
}
#[test]
fn a_degraded_page_keeps_the_source_cursor_for_the_next_one() {
let resume = ListThroughToken {
t: LIST_THROUGH_TOKEN_TAG.to_string(),
v: LIST_THROUGH_TOKEN_VERSION,
local: Some("local-1".to_string()),
local_done: false,
source: Some("source-1".to_string()),
source_done: false,
last_key: Some("a".to_string()),
};
let mut merger = ListThroughMerger::new(1, Some(&resume));
merger.disable_source();
merger.push_page(
MergeSide::Local,
vec![ListEntryKey::object("b"), ListEntryKey::object("c")],
true,
Some("local-2".to_string()),
);
let outcome = merger.finish();
assert!(outcome.is_truncated);
let token = outcome.next_token.expect("truncated page carries a token");
assert_eq!(token.source.as_deref(), Some("source-1"), "the source cursor must not move");
assert!(!token.source_done);
assert_eq!(token.last_key.as_deref(), Some("b"));
assert_eq!(token.local.as_deref(), Some("local-1"), "a partly read page is re-listed");
}
#[test]
fn token_round_trips_and_rejects_tampering() {
let token = ListThroughToken::new(
SideCursor {
token: Some("l".to_string()),
done: false,
},
SideCursor { token: None, done: true },
Some("k".to_string()),
);
let encoded = token.encode();
assert_eq!(decode_continuation_token(&encoded), Ok(ListThroughCursor::Merged(Box::new(token))));
let bumped = encoded.replace("\"v\":1", "\"v\":2");
assert_eq!(decode_continuation_token(&bumped), Err(ListThroughTokenError::UnsupportedVersion(2)));
let extra = encoded.replace("{", "{\"x\":1,");
assert_eq!(decode_continuation_token(&extra), Err(ListThroughTokenError::Malformed));
let truncated = &encoded[..encoded.len() - 3];
assert_eq!(decode_continuation_token(truncated), Ok(ListThroughCursor::Local(truncated.to_string())));
let no_version = "{\"t\":\"odm-list\"}";
assert_eq!(decode_continuation_token(no_version), Err(ListThroughTokenError::Malformed));
}
#[test]
fn a_plain_local_marker_stays_local() {
assert_eq!(
decode_continuation_token("photos/2024/01.jpg"),
Ok(ListThroughCursor::Local("photos/2024/01.jpg".to_string()))
);
assert_eq!(
decode_continuation_token("{not json"),
Ok(ListThroughCursor::Local("{not json".to_string()))
);
assert_eq!(
decode_continuation_token("{\"t\":\"other\"}"),
Ok(ListThroughCursor::Local("{\"t\":\"other\"}".to_string()))
);
}
#[test]
fn source_list_plan_intersects_the_filter_prefix() {
assert_eq!(source_list_plan("", None, None), SourceListPlan::Page { prefix: String::new() });
assert_eq!(
source_list_plan("photos/2024/", Some("photos/"), None),
SourceListPlan::Page {
prefix: "photos/2024/".to_string()
}
);
assert_eq!(
source_list_plan("photos/", Some("photos/2024/"), None),
SourceListPlan::Page {
prefix: "photos/2024/".to_string()
}
);
assert_eq!(source_list_plan("videos/", Some("photos/"), None), SourceListPlan::Skip);
assert_eq!(
source_list_plan("", Some("photos/2024/"), Some("/")),
SourceListPlan::Folded {
probe_prefix: "photos/2024/".to_string(),
common_prefix: "photos/".to_string(),
}
);
assert_eq!(
source_list_plan("pho", Some("photos"), Some("/")),
SourceListPlan::Page {
prefix: "photos".to_string()
},
"a filter prefix that adds no delimiter keeps the source's own roll-up"
);
}
#[test]
fn rate_limiter_spends_its_burst_then_paces_and_refuses() {
let limiter = SourceListRateLimiter::new(10);
let start = Instant::now();
for _ in 0..10 {
assert_eq!(limiter.reserve_at(start, Duration::from_secs(1)), Some(Duration::ZERO));
}
let paced = limiter.reserve_at(start, Duration::from_secs(1)).expect("within the budget");
assert!(paced > Duration::ZERO && paced <= Duration::from_millis(101), "{paced:?}");
assert_eq!(limiter.reserve_at(start, Duration::ZERO), None, "a zero budget refuses");
// A full second of refill restores the whole burst.
assert_eq!(limiter.reserve_at(start + Duration::from_secs(5), Duration::ZERO), Some(Duration::ZERO));
}
fn key_set() -> impl Strategy<Value = Vec<String>> {
proptest::collection::btree_set(
proptest::sample::select(vec!["a", "a/", "a/1", "a/2", "a/b/1", "b", "b/1", "c", "c/1", "c/2", "d", "d/e/f"])
.prop_map(str::to_string),
0..=12,
)
.prop_map(|set: BTreeSet<String>| set.into_iter().collect())
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Full pagination of a merged listing equals the sorted, deduplicated
/// union of both sides, with every shared key served by local, and no
/// page longer than `max_keys`.
#[test]
fn merged_pagination_equals_the_deduplicated_union(
local in key_set(),
source in key_set(),
max_keys in 1usize..=5,
with_delimiter in any::<bool>(),
prefix in proptest::sample::select(vec!["", "a", "a/", "c/"]),
) {
let delimiter = with_delimiter.then_some("/");
let (emitted, sizes) = walk(&local, &source, prefix, delimiter, max_keys);
let got: Vec<ListEntryKey> = emitted.iter().map(|(entry, _)| entry.clone()).collect();
prop_assert_eq!(got, expected(&local, &source, prefix, delimiter));
prop_assert!(sizes.iter().all(|size| *size <= max_keys), "{:?}", sizes);
for (entry, side) in &emitted {
if !entry.is_prefix && local.iter().any(|key| key == &entry.name) {
prop_assert_eq!(*side, MergeSide::Local, "local must win for {}", entry.name);
}
}
}
}
}
@@ -23,6 +23,7 @@
pub mod backfill;
pub mod breaker;
pub mod config;
pub mod list_through;
pub mod negative_cache;
pub mod pull;
pub mod source_client;
@@ -38,6 +39,11 @@ pub use config::{
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy, SourceConfig,
SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
};
pub use list_through::{
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
};
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,
@@ -511,10 +511,28 @@ pub struct SourceObject {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SourcePage {
pub objects: Vec<SourceObject>,
/// Rolled-up prefixes, in the local namespace; always empty when the
/// request carried no delimiter.
pub common_prefixes: Vec<String>,
pub is_truncated: bool,
pub next_continuation_token: Option<String>,
}
/// One `ListObjectsV2` page request against the source. Keys are given in the
/// local namespace; `SourceClient` maps them through `source_prefix`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SourceListRequest<'a> {
pub prefix: Option<&'a str>,
/// Rolls the source's own listing up the same way the local one is rolled
/// up, so a page under a delimiter stays bounded.
pub delimiter: Option<&'a str>,
/// Ignored by S3 when `continuation_token` is set, so the caller must pass
/// at most one of the two.
pub start_after: Option<&'a str>,
pub continuation_token: Option<&'a str>,
pub max_keys: i32,
}
/// Result of [`SourceClient::probe`]: the bucket answered HEAD and a
/// one-key listing succeeded.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -669,13 +687,35 @@ impl SourceClient {
continuation_token: Option<&str>,
max_keys: i32,
) -> Result<SourcePage, SourceError> {
self.list_page(&SourceListRequest {
prefix,
continuation_token,
max_keys,
..Default::default()
})
.await
}
/// [`Self::list_objects_v2`] with the delimiter and start-after the
/// list-through merge needs (rustfs/backlog#2164).
pub async fn list_page(&self, request: &SourceListRequest<'_>) -> Result<SourcePage, SourceError> {
// `start_after` is silently ignored by S3 once a continuation token is
// present; refuse the ambiguous pair rather than list from the wrong
// position.
if request.continuation_token.is_some() && request.start_after.is_some() {
return Err(SourceError::Other(
"source listing takes either a continuation token or start-after, not both".to_string(),
));
}
let output = self
.client
.list_objects_v2()
.bucket(&self.bucket)
.prefix(self.source_key(prefix.unwrap_or_default()))
.set_continuation_token(continuation_token.map(str::to_string))
.max_keys(max_keys)
.prefix(self.source_key(request.prefix.unwrap_or_default()))
.set_delimiter(request.delimiter.map(str::to_string))
.set_start_after(request.start_after.map(|after| self.source_key(after)))
.set_continuation_token(request.continuation_token.map(str::to_string))
.max_keys(request.max_keys)
.send()
.await
.map_err(classify_sdk_error)?;
@@ -693,9 +733,16 @@ impl SourceClient {
.into_iter()
.filter_map(|object| self.source_object(object))
.collect();
let common_prefixes = output
.common_prefixes
.unwrap_or_default()
.into_iter()
.filter_map(|prefix| Some(self.local_key(prefix.prefix.as_deref()?)?.to_string()))
.collect();
Ok(SourcePage {
objects,
common_prefixes,
is_truncated,
next_continuation_token,
})
@@ -44,6 +44,7 @@ use super::breaker::{Breaker, BreakerState, BreakerTransition, BreakerVerdict};
use super::config::{
ON_DEMAND_MIGRATION_CONFIG_HOOK, OnDemandMigrationConfig, PathStyle as ConfigPathStyle, Provider, SourceConfig,
};
use super::list_through::{SOURCE_LIST_RATE_PER_SEC, SourceListRateLimiter};
use super::negative_cache::NegativeCache;
use super::pull::{OdmWriteBack, PullQueue};
use super::source_client::{SourceClient, SourceClientSpec, SourceError, SourceProvider, SourceTimeouts};
@@ -280,6 +281,8 @@ pub struct BucketOdmState {
write_back: Option<Arc<dyn OdmWriteBack>>,
/// Started by `pull::BucketOdmState::pull_queue` on first enqueue.
pub(super) pull_queue: OnceLock<Arc<PullQueue>>,
/// Caps source listings for this bucket under `policy.list_through`.
list_rate_limiter: SourceListRateLimiter,
}
impl fmt::Debug for BucketOdmState {
@@ -328,6 +331,7 @@ impl BucketOdmState {
last_source_error_logged_at: Mutex::new(None),
write_back,
pull_queue: OnceLock::new(),
list_rate_limiter: SourceListRateLimiter::new(SOURCE_LIST_RATE_PER_SEC),
})
}
@@ -360,6 +364,12 @@ impl BucketOdmState {
&self.negative_cache
}
/// Per-bucket rate limit on source `ListObjectsV2` calls, consulted by the
/// list-through merge (rustfs/backlog#2164).
pub fn list_rate_limiter(&self) -> &SourceListRateLimiter {
&self.list_rate_limiter
}
pub fn stats(&self) -> &Arc<OdmStats> {
&self.stats
}