feat(ecstore): add the on-demand migration backfill job (#7087)

* feat(ecstore): add on-demand migration backfill job core

Add the background backfill job for on-demand migration
(rustfs/backlog#2159): a durable checkpoint under
buckets/<bucket>/on-demand-migration-backfill.json saved by If-Match
compare-and-set every 1000 keys or 10 s, a 60 s owner lease renewed by
every save, a recovery pass that takes over expired leases (or jobs this
node owned before a restart) and cancels jobs whose config changed, and a
main loop over the source ListObjectsV2 pages with the skip_existing
policy, dry runs, bounded outstanding pulls and wait-on-full enqueueing.

The pull queue gains per-job completion reports so the job can count
pulled/failed keys (hashes only), and pull permits become two-tier so an
online miss is never queued behind a backfill pull.

* feat(admin): expose on-demand migration backfill job

Wire the ODM-12 backfill job (rustfs/backlog#2159) to its operators:
POST /v3/on-demand-migration/{bucket}/backfill?op=start|cancel and
GET .../backfill return the checkpoint document, GET .../status gains a
backfill summary, and the recovery loop plus the process-wide runner are
installed at startup. Backfill control reuses
Set/GetBucketOnDemandMigrationAction and is recorded in the route policy,
the registration matrix and the admin route snapshot.

Add the rustfs-madmin wire types and client methods with golden fixtures
shared by the server tests, the backfill_* metric descriptors and their
collector, and three e2e scenarios: a full backfill across list pages,
cancellation, and resuming from the persisted continuation token after a
server restart.
This commit is contained in:
Zhengchao An
2026-09-03 08:52:58 +08:00
committed by GitHub
parent df4fdef1d8
commit 74be040c62
27 changed files with 3846 additions and 58 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
sha256-darwin=09bc29b3e0ed7c5779331740e18e1d5ca05cb240c1363d1ecc95c1ef1e337038
sha256-darwin=efd7357af1e998134df7d01e0a10d7186898e47abbe86ae6ecb6aef1f8c58357
sha256-linux=86e69337ad1440252a2ee20a12063c989ed12442d3b1ddf9e9233acf0f2ec089
@@ -0,0 +1,254 @@
// 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 backfill job scenarios (ODM-12, rustfs/backlog#2159):
//! a full backfill of a small-object source, cancellation, and resuming from
//! the persisted continuation token after a server restart.
use super::common::{BackfillOp, BackfillRequest, ODM_SERVER_ENV, OdmSourceSpec, OdmTestEnv, SeedObject};
use crate::fake_s3_target::Operation;
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const SOURCE_BUCKET: &str = "odm-backfill-source";
const LOCAL_BUCKET: &str = "odm-backfill-local";
const KEY_PREFIX: &str = "cold/";
/// Keys per scenario. The fake source retains at most 4,096 object versions
/// and 4,096 journal entries, and one pull is a HEAD plus a GET, so a
/// scenario that asserts on the journal stays below ~2,000 keys. The job
/// lists 1,000 keys per page, so this still spans several pages and exercises
/// the continuation token, which is what the scenarios are about.
const SEEDED_KEYS: usize = 1500;
fn key(i: usize) -> String {
format!("{KEY_PREFIX}{i:05}")
}
/// Content that identifies the key so a mis-stored object is caught.
fn body(i: usize) -> Bytes {
Bytes::from(format!("object-{i:05}-payload"))
}
fn seed(env: &OdmTestEnv, count: usize) {
let objects: Vec<SeedObject> = (0..count).map(|i| SeedObject::new(key(i), body(i))).collect();
let etags = env.seed_source(SOURCE_BUCKET, &objects);
assert_eq!(etags.len(), count);
}
async fn configure(env: &OdmTestEnv, spec: &OdmSourceSpec) -> TestResult {
let response = env.configure_source(LOCAL_BUCKET, spec).await?;
assert_eq!(response.status, 200, "configure: {}", response.body);
// The probe issued a one-key listing; count only the job's traffic.
env.source.take_requests();
Ok(())
}
fn source_lists(env: &OdmTestEnv) -> Vec<Option<String>> {
env.source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::ListObjectsV2)
.map(|record| record.continuation_token)
.collect()
}
fn source_gets(env: &OdmTestEnv) -> usize {
env.source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::GetObject)
.count()
}
fn counter(job: &serde_json::Value, name: &str) -> u64 {
job[name].as_u64().unwrap_or_else(|| panic!("{name} missing in {job}"))
}
#[tokio::test]
async fn backfill_pulls_every_source_object_across_list_pages() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
configure(&env, &env.fake_source_spec(SOURCE_BUCKET)).await?;
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
let job = started.json()?["job"].clone();
assert_eq!(job["state"], "running");
assert_eq!(job["skip_existing"], "always");
assert_eq!(job["dry_run"], false);
let job_id = job["job_id"].as_str().expect("job id").to_string();
// A second start while the job holds its lease is a conflict.
let again = env
.backfill(LOCAL_BUCKET, BackfillOp::Start(BackfillRequest::default()))
.await?;
assert_eq!(again.status, 409, "second start: {}", again.body);
assert!(again.body.contains("OnDemandMigrationBackfillRunning"), "{}", again.body);
let done = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(240), |job| job["state"] == "completed")
.await?;
assert_eq!(done["job_id"], job_id.as_str());
assert_eq!(counter(&done, "listed"), COUNT as u64);
assert_eq!(counter(&done, "enqueued"), COUNT as u64);
assert_eq!(counter(&done, "pulled"), COUNT as u64);
assert_eq!(counter(&done, "failed"), 0);
assert_eq!(counter(&done, "skipped_existing"), 0);
assert!(done["continuation_token"].is_null(), "a finished job carries no cursor");
assert_eq!(done["last_key"], key(COUNT - 1));
assert!(done["failed_keys"].as_array().is_some_and(Vec::is_empty));
let expected_bytes: u64 = (0..COUNT).map(|i| body(i).len() as u64).sum();
assert_eq!(counter(&done, "bytes"), expected_bytes);
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
for i in [0, 999, 1000, 1200, COUNT - 1] {
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
}
let lists = source_lists(&env);
assert_eq!(lists.len(), COUNT.div_ceil(1000), "{COUNT} keys at 1000 per page: {lists:?}");
assert!(lists[0].is_none(), "the first page starts without a cursor");
assert!(lists[1..].iter().all(Option::is_some), "every later page carries the cursor");
assert_eq!(source_gets(&env), COUNT, "every object is fetched exactly once");
// The status endpoint summarises the same job.
let status = env.status(LOCAL_BUCKET).await?;
assert_eq!(status.status, 200);
let summary = status.json()?["backfill"].clone();
assert_eq!(summary["job_id"], job_id.as_str());
assert_eq!(summary["state"], "completed");
assert_eq!(counter(&summary, "pulled"), COUNT as u64);
Ok(())
}
#[tokio::test]
async fn backfill_cancel_stops_enqueueing_and_persists_cancelled() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
spec.policy.max_concurrent_pulls = 1;
configure(&env, &spec).await?;
// Cancelling before any job exists is a 404, not a silent success.
let nothing = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(nothing.status, 404, "cancel without a job: {}", nothing.body);
assert!(nothing.body.contains("NoSuchBackfillJob"), "{}", nothing.body);
let unread = env.backfill(LOCAL_BUCKET, BackfillOp::Status).await?;
assert_eq!(unread.status, 404, "status without a job: {}", unread.body);
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
env.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(60), |job| {
job["state"] == "running" && counter(job, "enqueued") > 0
})
.await?;
let cancelled = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(cancelled.status, 200, "cancel: {}", cancelled.body);
let job = cancelled.json()?["job"].clone();
assert_eq!(job["state"], "cancelled");
let enqueued_at_cancel = counter(&job, "enqueued");
assert!(enqueued_at_cancel < COUNT as u64, "the job was cancelled mid-way: {job}");
// Nothing is queued after the cancel: the checkpoint and the source
// traffic both stop moving once the few in-flight pulls drain.
tokio::time::sleep(Duration::from_secs(2)).await;
let persisted = env.backfill_job(LOCAL_BUCKET).await?.expect("checkpoint kept for inspection");
assert_eq!(persisted["state"], "cancelled");
assert_eq!(counter(&persisted, "enqueued"), enqueued_at_cancel);
let gets_after_drain = source_gets(&env);
tokio::time::sleep(Duration::from_secs(1)).await;
assert_eq!(source_gets(&env), gets_after_drain, "no source GET after the cancel drained");
assert!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await? < COUNT);
// Cancel is idempotent and the status endpoint reports the final state.
let again = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(again.status, 200, "second cancel: {}", again.body);
assert_eq!(again.json()?["job"]["state"], "cancelled");
let status = env.status(LOCAL_BUCKET).await?;
assert_eq!(status.json()?["backfill"]["state"], "cancelled");
// A cancelled job releases the bucket: a new job can start.
let restarted = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(restarted.status, 200, "restart after cancel: {}", restarted.body);
assert_ne!(restarted.json()?["job"]["job_id"], job["job_id"]);
let _ = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
Ok(())
}
#[tokio::test]
async fn backfill_resumes_from_continuation_token_after_restart() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let mut env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
spec.policy.max_concurrent_pulls = 2;
configure(&env, &spec).await?;
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
let job_id = started.json()?["job"]["job_id"].as_str().expect("job id").to_string();
// Wait for the first page to be committed (cursor persisted), then kill
// the server while the job is still running.
let mid = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(120), |job| {
job["state"] == "running" && job["continuation_token"].is_string()
})
.await?;
assert!(counter(&mid, "listed") >= 1000 && counter(&mid, "listed") < COUNT as u64, "{mid}");
env.source.take_requests();
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
let done = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(300), |job| job["state"] == "completed")
.await?;
assert_eq!(done["job_id"], job_id.as_str(), "the same job continues after the restart");
assert_eq!(counter(&done, "failed"), 0);
assert!(
counter(&done, "listed") >= COUNT as u64,
"the resumed job listed the rest (the interrupted page is listed twice): {done}"
);
// Keys pulled before the crash are re-listed and skipped, never re-pulled;
// a pull whose report died with the old process is counted neither way,
// so only the lower bound and the queue accounting are exact.
assert!(counter(&done, "pulled") + counter(&done, "skipped_existing") >= COUNT as u64, "{done}");
assert!(counter(&done, "pulled") <= counter(&done, "enqueued"), "{done}");
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
for i in [0, 500, 999, 1000, COUNT - 1] {
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
}
let lists = source_lists(&env);
assert!(!lists.is_empty(), "the resumed job listed the source");
assert!(
lists.iter().all(Option::is_some),
"after the restart every source listing carries a continuation-token: {lists:?}"
);
assert!(
lists.len() <= COUNT.div_ceil(1000),
"the listing did not start over from the first page: {lists:?}"
);
Ok(())
}
@@ -40,8 +40,12 @@ pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// turns it on so scenario tests exercise the feature without repeating it.
pub const ODM_MODULE_SWITCH_ENV: &str = "RUSTFS_ON_DEMAND_MIGRATION_ENABLED";
/// The source client shares the replication egress guard, which rejects the
/// fake source's loopback endpoint unless this switch is set.
/// fake source's loopback endpoint unless this switch is set. This is the
/// documented harness opt-in; see
/// `docs/operations/outbound-connection-policy.md`.
pub const ALLOW_LOOPBACK_SOURCE_ENV: &str = "RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET";
/// Server environment every ODM scenario starts (and restarts) with.
pub const ODM_SERVER_ENV: &[(&str, &str)] = &[(ODM_MODULE_SWITCH_ENV, "true"), (ALLOW_LOOPBACK_SOURCE_ENV, "true")];
/// Admin route prefix; the bucket name is appended as one path segment.
pub const ODM_ADMIN_ROUTE: &str = "/rustfs/admin/v3/on-demand-migration";
/// Region the fake source is addressed with (it accepts any SigV4 region).
@@ -324,7 +328,7 @@ impl OdmTestEnv {
pub async fn start_with(options: OdmEnvOptions<'_>) -> Result<Self, BoxError> {
let source = FakeS3Target::start_with_options(options.source).await?;
let mut rustfs = RustFSTestEnvironment::new().await?;
let mut env: Vec<(&str, &str)> = vec![(ODM_MODULE_SWITCH_ENV, "true"), (ALLOW_LOOPBACK_SOURCE_ENV, "true")];
let mut env: Vec<(&str, &str)> = ODM_SERVER_ENV.to_vec();
for (name, value) in &options.env {
match env.iter_mut().find(|(existing, _)| existing == name) {
Some(entry) => entry.1 = value,
@@ -412,6 +416,81 @@ impl OdmTestEnv {
}
}
/// `POST .../{bucket}/backfill?op=start`, retried while the server
/// answers `OnDemandMigrationDisabled`: the bucket state is built
/// asynchronously right after the config `PUT`, so an immediate start can
/// race it. Any other answer is returned as is.
pub async fn start_backfill(&self, bucket: &str, request: BackfillRequest) -> Result<AdminResponse, BoxError> {
let deadline = Instant::now() + Duration::from_secs(15);
loop {
let response = self.backfill(bucket, BackfillOp::Start(request.clone())).await?;
let state_not_ready = response.status == 400 && response.body.contains("OnDemandMigrationDisabled");
if !state_not_ready || Instant::now() >= deadline {
return Ok(response);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// The `job` document of `GET .../{bucket}/backfill`, `None` on 404.
pub async fn backfill_job(&self, bucket: &str) -> Result<Option<serde_json::Value>, BoxError> {
let response = self.backfill(bucket, BackfillOp::Status).await?;
match response.status {
200 => Ok(Some(response.json()?["job"].clone())),
404 => Ok(None),
status => Err(format!("GET backfill answered {status}: {}", response.body).into()),
}
}
/// Polls the backfill checkpoint until `accept` returns true or `timeout`
/// elapses (an error naming the last observed document).
pub async fn wait_for_backfill(
&self,
bucket: &str,
timeout: Duration,
accept: impl Fn(&serde_json::Value) -> bool,
) -> Result<serde_json::Value, BoxError> {
let deadline = Instant::now() + timeout;
let mut last = serde_json::Value::Null;
loop {
if let Some(job) = self.backfill_job(bucket).await? {
if accept(&job) {
return Ok(job);
}
last = job;
}
if Instant::now() >= deadline {
return Err(
format!("backfill of {bucket} did not reach the expected state within {timeout:?}; last: {last}").into(),
);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// Number of keys under `prefix` listed by the RustFS under test (local
/// state only, no migration side effects).
pub async fn local_key_count(&self, bucket: &str, prefix: &str) -> Result<usize, BoxError> {
let mut count = 0;
let mut token: Option<String> = None;
loop {
let page = self
.client
.list_objects_v2()
.bucket(bucket)
.prefix(prefix)
.max_keys(1000)
.set_continuation_token(token.take())
.send()
.await?;
count += page.contents().len();
match page.next_continuation_token() {
Some(next) if page.is_truncated().unwrap_or(false) => token = Some(next.to_string()),
_ => return Ok(count),
}
}
}
async fn admin(
&self,
method: http::Method,
@@ -17,13 +17,15 @@
//! `common` is the shared environment: one RustFS under test, one programmable
//! fake S3 source, admin-API wrappers, seeding and local-state assertions.
//! `harness_self_test` proves the harness itself; `get_basic_test` covers the
//! GET read-through (rustfs/backlog#2156). The fault, concurrency,
//! GET read-through (rustfs/backlog#2156) and `backfill_test` the background
//! backfill job (ODM-12, rustfs/backlog#2159). The fault, concurrency,
//! interaction and real-source matrix is rustfs/backlog#2158; its lane split
//! lives in `.config/nextest.toml` (fault / concurrency / real source run
//! nightly, the rest in the merge lane).
pub mod common;
mod backfill_test;
mod concurrency_test;
mod fault_test;
mod get_basic_test;
+13 -2
View File
@@ -161,9 +161,20 @@ pub mod bucket {
};
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,
PullCompletion, PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, WriteBackBody, WriteBackError,
WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
};
pub mod backfill {
pub use crate::bucket::on_demand_migration::backfill::{
BACKFILL_CHECKPOINT_FILE, BACKFILL_CHECKPOINT_FORMAT_VERSION, BACKFILL_FAILED_KEYS_CAPACITY, BACKFILL_LEASE,
BACKFILL_LEASE_LOCK_PREFIX, BACKFILL_LIST_PAGE_SIZE, BACKFILL_RECOVERY_INTERVAL, BACKFILL_SAVE_EVERY_KEYS,
BACKFILL_SAVE_INTERVAL, BackfillCheckpoint, BackfillContext, BackfillContextFactory, BackfillError,
BackfillLastError, BackfillOwner, BackfillRecoveryStats, BackfillRequest, BackfillRunner, BackfillState,
BucketBackfillContext, LocalBackfillObject, PriorityPullPermits, PullPermit, PullPriority, SkipExisting,
StoredCheckpoint, SysBackfillContexts, global_backfill_runner, install_global_backfill_runner, key_hash,
read_checkpoint, run_backfill_recovery_loop, spawn_backfill_recovery_loop,
};
}
pub mod source_client {
pub use crate::bucket::on_demand_migration::source_client::{
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceObject, SourcePage, SourceProbe,
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,7 @@
//! clients guarded by a breaker, a negative cache, singleflight and a pull
//! concurrency limit (rustfs/backlog#2147).
pub mod backfill;
pub mod breaker;
pub mod config;
pub mod negative_cache;
@@ -40,8 +41,8 @@ pub use config::{
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,
PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, WriteBackBody, WriteBackError, WriteBackOutcome,
WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
};
pub use stats::{
GaugeGuard, LastSourceError, LatencyBucketSnapshot, OdmOp, OdmOutcome, OdmStats, OdmStatsSnapshot, PullFailureReason,
@@ -40,6 +40,7 @@
//! oversized body therefore fails the local write before it can commit,
//! independently of the digest check the write-back performs.
use super::backfill::PullPriority;
use super::source_client::{SourceClient, SourceError, SourceHead};
use super::stats::{PullFailureReason, PullPath};
use super::sys::{BucketOdmState, OnDemandMigrationSys, PullError, PullOutcome, PullSlot};
@@ -57,7 +58,7 @@ use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use time::OffsetDateTime;
use tokio::sync::mpsc::{self, error::TrySendError};
use tokio::sync::watch;
use tokio::sync::{oneshot, watch};
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use tracing::{debug, trace};
@@ -108,6 +109,27 @@ impl PullReason {
PullReason::Backfill => PullPath::Backfill,
}
}
/// Backfill pulls yield pull permits to online requests.
pub fn priority(self) -> PullPriority {
match self {
PullReason::RangeGet | PullReason::LargeObject => PullPriority::Online,
PullReason::Backfill => PullPriority::Backfill,
}
}
}
/// How one queued pull ended, delivered to the requester that asked for a
/// report (the backfill job counts these).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum QueuedPullOutcome {
/// A new local object of `size` bytes was written.
Stored {
size: u64,
},
/// A current local version already existed; nothing was pulled.
AlreadyPresent,
Failed(PullError),
}
/// Result of [`PullQueue::enqueue`].
@@ -731,6 +753,8 @@ pub async fn commit_inline_with(
struct PullJob {
key: String,
reason: PullReason,
/// Dropped without a send when the job is cancelled before it runs.
report: Option<oneshot::Sender<QueuedPullOutcome>>,
}
/// Bounded per-bucket queue of background pulls. Keys are unique while
@@ -809,26 +833,39 @@ impl PullQueue {
}
pub fn enqueue(&self, key: &str, reason: PullReason) -> EnqueueOutcome {
self.enqueue_with_report(key, reason).0
}
/// [`Self::enqueue`] that also hands back the job's report channel when
/// a new job was queued (`Coalesced` pulls report to their first
/// requester only).
pub fn enqueue_with_report(
&self,
key: &str,
reason: PullReason,
) -> (EnqueueOutcome, Option<oneshot::Receiver<QueuedPullOutcome>>) {
if self.cancel.is_cancelled() {
return EnqueueOutcome::Unavailable;
return (EnqueueOutcome::Unavailable, None);
}
let mut pending = self.pending.lock();
if pending.contains(key) {
return EnqueueOutcome::Coalesced;
return (EnqueueOutcome::Coalesced, None);
}
let (report_tx, report_rx) = oneshot::channel();
match self.tx.try_send(PullJob {
key: key.to_string(),
reason,
report: Some(report_tx),
}) {
Ok(()) => {
pending.insert(key.to_string());
EnqueueOutcome::Enqueued
(EnqueueOutcome::Enqueued, Some(report_rx))
}
Err(TrySendError::Full(_)) => {
self.stats.record_pull_failure(PullFailureReason::QueueFull);
EnqueueOutcome::QueueFull
(EnqueueOutcome::QueueFull, None)
}
Err(TrySendError::Closed(_)) => EnqueueOutcome::Unavailable,
Err(TrySendError::Closed(_)) => (EnqueueOutcome::Unavailable, None),
}
}
}
@@ -860,7 +897,7 @@ async fn dispatch(
queue: Arc::clone(&queue),
key: job.key.clone(),
};
let slot = match state.acquire_pull_slot(&job.key).await {
let slot = match state.acquire_pull_slot_with_priority(&job.key, job.reason.priority()).await {
Ok(slot) => slot,
Err(err) => {
let result: Result<PullCompletion, PullError> = Err(err);
@@ -905,11 +942,14 @@ async fn run_job(
) {
let _pending = pending;
let cancel = state.cancel_token();
match slot {
let report = match slot {
PullSlot::Follower(follower) => {
// Someone else (inline GET or an earlier job) is pulling the key;
// its result makes this job redundant.
let _ = follower.wait().await;
match follower.wait().await {
Ok(outcome) => QueuedPullOutcome::Stored { size: outcome.size },
Err(err) => QueuedPullOutcome::Failed(err),
}
}
PullSlot::Leader(leader) => {
let ctx = PullContext {
@@ -921,8 +961,17 @@ async fn run_job(
};
let result = pull_object(&ctx).await;
record_completion(&state, &job.key, job.reason.path(), &result);
let report = match &result {
Ok(PullCompletion::Stored(outcome)) => QueuedPullOutcome::Stored { size: outcome.size },
Ok(PullCompletion::AlreadyPresent(_)) => QueuedPullOutcome::AlreadyPresent,
Err(err) => QueuedPullOutcome::Failed(err.clone()),
};
leader.complete(result.map(|completion| completion.outcome()));
report
}
};
if let Some(tx) = job.report {
let _ = tx.send(report);
}
}
@@ -948,9 +997,18 @@ impl BucketOdmState {
/// Queues a background pull of `key`; see [`EnqueueOutcome`].
pub fn enqueue_pull(self: &Arc<Self>, key: &str, reason: PullReason) -> EnqueueOutcome {
self.enqueue_pull_with_report(key, reason).0
}
/// [`Self::enqueue_pull`] with the job's report channel.
pub fn enqueue_pull_with_report(
self: &Arc<Self>,
key: &str,
reason: PullReason,
) -> (EnqueueOutcome, Option<oneshot::Receiver<QueuedPullOutcome>>) {
match self.pull_queue() {
Some(queue) => queue.enqueue(key, reason),
None => EnqueueOutcome::Unavailable,
Some(queue) => queue.enqueue_with_report(key, reason),
None => (EnqueueOutcome::Unavailable, None),
}
}
}
@@ -39,6 +39,7 @@
//! (`pull.rs`) stores objects with; each bucket state captures it at build
//! time together with its lazily started [`PullQueue`].
use super::backfill::{PriorityPullPermits, PullPermit, PullPriority};
use super::breaker::{Breaker, BreakerState, BreakerTransition, BreakerVerdict};
use super::config::{
ON_DEMAND_MIGRATION_CONFIG_HOOK, OnDemandMigrationConfig, PathStyle as ConfigPathStyle, Provider, SourceConfig,
@@ -57,7 +58,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use time::OffsetDateTime;
use tokio::sync::{OwnedSemaphorePermit, Semaphore, watch};
use tokio::sync::watch;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
@@ -182,7 +183,7 @@ pub struct PullLeader {
state: Arc<BucketOdmState>,
key: String,
tx: watch::Sender<Option<PullResult>>,
_permit: OwnedSemaphorePermit,
_permit: PullPermit,
_inflight: GaugeGuard,
completed: bool,
}
@@ -269,7 +270,8 @@ pub struct BucketOdmState {
breaker: Breaker,
negative_cache: NegativeCache,
inflight: Mutex<HashMap<String, watch::Receiver<Option<PullResult>>>>,
pull_semaphore: Arc<Semaphore>,
/// Online misses first, backfill pulls when nobody waits (ODM-12).
pull_permits: Arc<PriorityPullPermits>,
stats: Arc<OdmStats>,
cancel: CancellationToken,
last_source_error_logged_at: Mutex<Option<Instant>>,
@@ -318,7 +320,7 @@ impl BucketOdmState {
breaker: Breaker::new(),
negative_cache: NegativeCache::new(Duration::from_secs(policy.negative_cache_ttl_secs)),
inflight: Mutex::new(HashMap::new()),
pull_semaphore: Arc::new(Semaphore::new(policy.max_concurrent_pulls.max(1) as usize)),
pull_permits: PriorityPullPermits::new(policy.max_concurrent_pulls.max(1) as usize),
stats,
cancel: CancellationToken::new(),
last_source_error_logged_at: Mutex::new(None),
@@ -477,6 +479,16 @@ impl BucketOdmState {
/// later callers for the same key become followers and never touch the
/// semaphore. Fails with `Canceled` when the state is torn down.
pub async fn acquire_pull_slot(self: &Arc<Self>, key: &str) -> Result<PullSlot, PullError> {
self.acquire_pull_slot_with_priority(key, PullPriority::Online).await
}
/// [`Self::acquire_pull_slot`] at the given permit priority; the pull
/// queue passes `Backfill` for backfill jobs.
pub async fn acquire_pull_slot_with_priority(
self: &Arc<Self>,
key: &str,
priority: PullPriority,
) -> Result<PullSlot, PullError> {
if self.cancel.is_cancelled() {
return Err(PullError::canceled("bucket on-demand migration state was removed"));
}
@@ -499,7 +511,7 @@ impl BucketOdmState {
let permit = {
let _queued = self.stats.queue_guard();
tokio::select! {
permit = Arc::clone(&self.pull_semaphore).acquire_owned() => permit,
permit = self.pull_permits.acquire(priority) => permit,
_ = self.cancel.cancelled() => {
return Err(PullError::canceled("bucket on-demand migration state was removed"));
}
@@ -524,6 +536,10 @@ impl BucketOdmState {
self.inflight.lock().len()
}
pub fn pull_permits(&self) -> &Arc<PriorityPullPermits> {
&self.pull_permits
}
pub fn snapshot(&self) -> OdmBucketSnapshot {
OdmBucketSnapshot {
bucket: self.bucket.clone(),
@@ -0,0 +1 @@
{"bucket":"photos","job":{"format_version":1,"job_id":"11111111-1111-4111-8111-111111111111","state":"running","config_updated_at":"2026-09-02T10:00:00Z","prefix":"photos/","skip_existing":"always","dry_run":false,"continuation_token":"cGhvdG9zLzEwMDA=","listed":2000,"enqueued":1500,"pulled":1400,"skipped_existing":500,"failed":3,"bytes":73400320,"last_key":"photos/2024/02.jpg","last_error":{"class":"source_timeout","key_hash":"9f2c3b0a1d4e5f60","at":"2026-09-02T10:05:00Z"},"failed_keys":["9f2c3b0a1d4e5f60"],"started_at":"2026-09-02T10:00:30Z","updated_at":"2026-09-02T10:05:10Z","owner":{"node":"node-a:9000","lease_until":"2026-09-02T10:06:10Z"}}}
@@ -0,0 +1 @@
{"configured":true,"enabled":true,"module_enabled":true,"provider":"minio","endpoint_host":"source.example.com","breaker":{"state":"half_open","opened_at":null},"counters":{"requests_total":{"get":{"breaker_open":0,"filtered":0,"negative_cached":0,"source_error":0,"source_hit":2,"source_miss":0,"unsupported":0},"head":{"breaker_open":0,"filtered":0,"negative_cached":1,"source_error":0,"source_hit":0,"source_miss":0,"unsupported":0}},"pulled_bytes_total":4096,"pulled_objects_total":{"backfill":0,"background":0,"inline":1},"pull_failures_total":{"canceled":0,"etag_mismatch":0,"local_write":0,"queue_full":0,"source_access_denied":0,"source_connect":0,"source_not_found":0,"source_other":0,"source_server_error":0,"source_throttled":0,"source_timeout":1,"source_unsupported":0},"source_latency":{"buckets":[{"le_ms":5,"count":1},{"le_ms":10,"count":1},{"le_ms":20,"count":1},{"le_ms":50,"count":1},{"le_ms":100,"count":1},{"le_ms":200,"count":1},{"le_ms":500,"count":1},{"le_ms":1000,"count":2},{"le_ms":2000,"count":2},{"le_ms":5000,"count":2},{"le_ms":10000,"count":2},{"le_ms":20000,"count":2},{"le_ms":30000,"count":2},{"le_ms":60000,"count":2}],"count":3,"sum_ms":90753}},"last_source_error":{"class":"server_error","at":"2026-09-02T10:00:00Z"},"inflight_pulls":1,"queue_depth":1,"served_by_source_ratio":null,"updated_at":"2026-09-02T10:00:00Z","backfill":{"job_id":"11111111-1111-4111-8111-111111111111","state":"running","listed":2000,"enqueued":1500,"pulled":1400,"skipped_existing":500,"failed":3,"bytes":73400320,"updated_at":"2026-09-02T10:05:10Z"}}
+1 -1
View File
@@ -400,7 +400,7 @@ impl AdminClient {
}
/// Signed POST returning a decoded JSON body.
async fn post_json<T: for<'de> Deserialize<'de>>(
pub(crate) async fn post_json<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
query: &[(&str, String)],
+270 -3
View File
@@ -14,8 +14,9 @@
//! On-Demand Migration admin API contract (ODM-07, rustfs/backlog#2154).
//!
//! Wire types for `PUT`/`GET`/`DELETE /v3/on-demand-migration/{bucket}` and
//! `GET .../status`, mirroring the server's config model
//! Wire types for `PUT`/`GET`/`DELETE /v3/on-demand-migration/{bucket}`,
//! `GET .../status`, `POST .../backfill?op=start|cancel` and
//! `GET .../backfill` (ODM-12), mirroring the server's config model
//! (`crates/ecstore/src/bucket/on_demand_migration/config.rs`) and handler
//! responses (`rustfs/src/admin/handlers/on_demand_migration.rs`). The SDK
//! owns its own copies, madmin-go style; the fixtures under
@@ -34,6 +35,8 @@ pub const ON_DEMAND_MIGRATION_CONFIG_VERSION: u32 = 1;
/// Query flag that validates and probes a config without saving it.
const DRY_RUN_QUERY: &str = "dry-run";
/// `POST .../backfill?op=` selector.
const BACKFILL_OP_QUERY: &str = "op";
/// Bucket-level on-demand migration configuration (request body of the
/// `PUT`, redacted copy in every response).
@@ -300,7 +303,7 @@ pub struct OnDemandMigrationGetResponse {
/// snapshot of the bucket. The runtime fields are `null` while the bucket has
/// no live state on the answering node (module off, config absent or
/// disabled); `provider` and `endpoint_host` then still describe the saved
/// config, if any.
/// config, if any. `backfill` is present once the bucket had a backfill job.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OnDemandMigrationStatus {
pub configured: bool,
@@ -328,6 +331,10 @@ pub struct OnDemandMigrationStatus {
/// RFC 3339 save time of the config; `None` when not configured.
#[serde(default)]
pub updated_at: Option<String>,
/// Counters of the bucket's latest backfill job; absent until the bucket
/// has had one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backfill: Option<OnDemandMigrationBackfillSummary>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -385,6 +392,124 @@ pub struct OnDemandMigrationSourceError {
pub at: String,
}
/// Lifecycle of a backfill job.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnDemandMigrationBackfillState {
Pending,
Running,
Paused,
Cancelled,
Completed,
CompletedWithFailures,
Failed,
}
impl OnDemandMigrationBackfillState {
/// Whether the job still runs (or is about to).
pub fn is_active(self) -> bool {
matches!(self, Self::Pending | Self::Running)
}
}
/// What to do with a listed key that already exists locally.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnDemandMigrationSkipExisting {
#[default]
Always,
EtagOrSize,
}
/// Body of `POST .../backfill?op=start`; every field is optional.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnDemandMigrationBackfillRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_existing: Option<OnDemandMigrationSkipExisting>,
#[serde(default)]
pub dry_run: bool,
}
/// Last failure recorded by a backfill job; `key_hash` is a hash, never the key.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnDemandMigrationBackfillError {
pub class: String,
#[serde(default)]
pub key_hash: Option<String>,
pub at: String,
}
/// Node running a backfill job and the lease it holds (RFC 3339).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnDemandMigrationBackfillOwner {
pub node: String,
pub lease_until: String,
}
/// The backfill checkpoint as the server stores it. Field order is the
/// on-disk and on-wire contract; unknown fields from newer servers are
/// tolerated.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnDemandMigrationBackfillJob {
pub format_version: u32,
pub job_id: String,
pub state: OnDemandMigrationBackfillState,
pub config_updated_at: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub skip_existing: OnDemandMigrationSkipExisting,
#[serde(default)]
pub dry_run: bool,
#[serde(default)]
pub continuation_token: Option<String>,
#[serde(default)]
pub listed: u64,
#[serde(default)]
pub enqueued: u64,
#[serde(default)]
pub pulled: u64,
#[serde(default)]
pub skipped_existing: u64,
#[serde(default)]
pub failed: u64,
#[serde(default)]
pub bytes: u64,
#[serde(default)]
pub last_key: Option<String>,
#[serde(default)]
pub last_error: Option<OnDemandMigrationBackfillError>,
#[serde(default)]
pub failed_keys: Vec<String>,
pub started_at: String,
pub updated_at: String,
#[serde(default)]
pub owner: Option<OnDemandMigrationBackfillOwner>,
}
/// `POST`/`GET .../backfill` response.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnDemandMigrationBackfillResponse {
pub bucket: String,
pub job: OnDemandMigrationBackfillJob,
}
/// Counters of the latest backfill job, embedded in the status response.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnDemandMigrationBackfillSummary {
pub job_id: String,
pub state: OnDemandMigrationBackfillState,
pub listed: u64,
pub enqueued: u64,
pub pulled: u64,
pub skipped_existing: u64,
pub failed: u64,
pub bytes: u64,
pub updated_at: String,
}
fn config_path(bucket: &str) -> String {
format!("/v3/on-demand-migration/{}", percent_encode_path_segment(bucket))
}
@@ -428,6 +553,43 @@ impl AdminClient {
pub async fn on_demand_migration_status(&self, bucket: &str) -> Result<OnDemandMigrationStatus, AdminClientError> {
self.get_json(&format!("{}/status", config_path(bucket))).await
}
/// Start the backfill job of `bucket`. A job that still holds its lease
/// answers HTTP 409 `OnDemandMigrationBackfillRunning`.
pub async fn start_on_demand_migration_backfill(
&self,
bucket: &str,
request: &OnDemandMigrationBackfillRequest,
) -> Result<OnDemandMigrationBackfillResponse, AdminClientError> {
let body = serde_json::to_vec(request).map_err(|err| AdminClientError::Decode {
message: err.to_string(),
})?;
self.post_json(&backfill_path(bucket), &[(BACKFILL_OP_QUERY, "start".to_string())], body)
.await
}
/// Cancel the backfill job of `bucket`; idempotent on a finished job. A
/// bucket that never had a job answers HTTP 404 `NoSuchBackfillJob`.
pub async fn cancel_on_demand_migration_backfill(
&self,
bucket: &str,
) -> Result<OnDemandMigrationBackfillResponse, AdminClientError> {
self.post_json(&backfill_path(bucket), &[(BACKFILL_OP_QUERY, "cancel".to_string())], Vec::new())
.await
}
/// Read the backfill checkpoint of `bucket` (404 `NoSuchBackfillJob`
/// when none was ever started).
pub async fn on_demand_migration_backfill(
&self,
bucket: &str,
) -> Result<OnDemandMigrationBackfillResponse, AdminClientError> {
self.get_json(&backfill_path(bucket)).await
}
}
fn backfill_path(bucket: &str) -> String {
format!("{}/backfill", config_path(bucket))
}
#[cfg(test)]
@@ -439,6 +601,8 @@ mod tests {
const SET_RESPONSE_FIXTURE: &str = include_str!("../fixtures/on_demand_migration/set_response.json");
const GET_RESPONSE_FIXTURE: &str = include_str!("../fixtures/on_demand_migration/get_response.json");
const STATUS_FIXTURE: &str = include_str!("../fixtures/on_demand_migration/status.json");
const STATUS_WITH_BACKFILL_FIXTURE: &str = include_str!("../fixtures/on_demand_migration/status_with_backfill.json");
const BACKFILL_JOB_FIXTURE: &str = include_str!("../fixtures/on_demand_migration/backfill_job.json");
fn round_trip<T: Serialize + for<'de> Deserialize<'de>>(fixture: &str) -> T {
let value: T = serde_json::from_str(fixture.trim()).expect("fixture decodes");
@@ -505,6 +669,7 @@ mod tests {
assert_eq!(status.queue_depth, 1);
assert_eq!(status.served_by_source_ratio, None, "the ratio is null, never a fabricated 0");
assert_eq!(status.updated_at.as_deref(), Some("2026-09-02T10:00:00Z"));
assert!(status.backfill.is_none(), "a bucket without a job carries no summary");
}
#[test]
@@ -521,6 +686,108 @@ mod tests {
assert_eq!(status.updated_at, None);
}
#[test]
fn backfill_fixtures_round_trip_byte_for_byte() {
let response: OnDemandMigrationBackfillResponse = round_trip(BACKFILL_JOB_FIXTURE);
assert_eq!(response.bucket, "photos");
let job = &response.job;
assert_eq!(job.format_version, 1);
assert_eq!(job.state, OnDemandMigrationBackfillState::Running);
assert!(job.state.is_active());
assert_eq!(job.skip_existing, OnDemandMigrationSkipExisting::Always);
assert_eq!(job.continuation_token.as_deref(), Some("cGhvdG9zLzEwMDA="));
assert_eq!((job.listed, job.enqueued, job.pulled, job.failed), (2000, 1500, 1400, 3));
assert_eq!(job.failed_keys, vec!["9f2c3b0a1d4e5f60".to_string()]);
assert_eq!(job.last_error.as_ref().map(|e| e.class.as_str()), Some("source_timeout"));
assert_eq!(job.owner.as_ref().map(|o| o.node.as_str()), Some("node-a:9000"));
let status: OnDemandMigrationStatus = round_trip(STATUS_WITH_BACKFILL_FIXTURE);
let summary = status.backfill.expect("summary present");
assert_eq!(summary.job_id, job.job_id);
assert_eq!(summary.state, OnDemandMigrationBackfillState::Running);
assert_eq!(summary.bytes, 73_400_320);
// A newer server may add checkpoint fields; the client keeps decoding.
let newer =
BACKFILL_JOB_FIXTURE
.trim()
.replacen("\"listed\":2000", "\"listed\":2000,\"throttle_hint\":{\"mode\":\"soft\"}", 1);
let decoded: OnDemandMigrationBackfillResponse = serde_json::from_str(&newer).expect("unknown fields tolerated");
assert_eq!(decoded.job.listed, 2000);
}
#[test]
fn backfill_request_serializes_only_what_was_set() {
let minimal = serde_json::to_string(&OnDemandMigrationBackfillRequest::default()).expect("serialize");
assert_eq!(minimal, r#"{"dry_run":false}"#);
let full = serde_json::to_string(&OnDemandMigrationBackfillRequest {
prefix: Some("photos/".to_string()),
skip_existing: Some(OnDemandMigrationSkipExisting::EtagOrSize),
dry_run: true,
})
.expect("serialize");
assert_eq!(full, r#"{"prefix":"photos/","skip_existing":"etag_or_size","dry_run":true}"#);
}
#[tokio::test]
async fn backfill_start_cancel_and_get_use_the_registered_route() {
let server = TestServer::spawn(BACKFILL_JOB_FIXTURE, 200).await;
let client = AdminClient::new(&format!("http://{}", server.addr), "ak", "sk").unwrap();
let response = client
.start_on_demand_migration_backfill(
"photos",
&OnDemandMigrationBackfillRequest {
prefix: Some("photos/".to_string()),
..Default::default()
},
)
.await
.expect("start decodes");
assert_eq!(response.job.state, OnDemandMigrationBackfillState::Running);
let request = server.recorded();
assert_eq!(request.method, "POST");
assert_eq!(request.path, "/rustfs/admin/v3/on-demand-migration/photos/backfill");
assert_eq!(request.query, "op=start");
assert_eq!(request.header("content-type").as_deref(), Some("application/json"));
assert_eq!(request.body, r#"{"prefix":"photos/","dry_run":false}"#);
let server = TestServer::spawn(BACKFILL_JOB_FIXTURE, 200).await;
let client = AdminClient::new(&format!("http://{}", server.addr), "ak", "sk").unwrap();
client
.cancel_on_demand_migration_backfill("photos")
.await
.expect("cancel decodes");
let request = server.recorded();
assert_eq!(request.method, "POST");
assert_eq!(request.query, "op=cancel");
assert_eq!(request.body, "", "cancel sends no body");
let server = TestServer::spawn(BACKFILL_JOB_FIXTURE, 200).await;
let client = AdminClient::new(&format!("http://{}", server.addr), "ak", "sk").unwrap();
client.on_demand_migration_backfill("photos").await.expect("get decodes");
let request = server.recorded();
assert_eq!(request.method, "GET");
assert_eq!(request.path, "/rustfs/admin/v3/on-demand-migration/photos/backfill");
let server = TestServer::spawn(
r#"{"code":"OnDemandMigrationBackfillRunning","message":"a backfill job is already running"}"#,
409,
)
.await;
let client = AdminClient::new(&format!("http://{}", server.addr), "ak", "sk").unwrap();
match client
.start_on_demand_migration_backfill("photos", &OnDemandMigrationBackfillRequest::default())
.await
.unwrap_err()
{
AdminClientError::HttpStatus { status, body } => {
assert_eq!(status, 409);
assert!(body.contains("OnDemandMigrationBackfillRunning"));
}
other => panic!("expected HttpStatus, got {other:?}"),
}
}
#[test]
fn minimal_config_expands_to_the_server_defaults() {
let config = OnDemandMigrationConfig::new(OnDemandMigrationSource {
+2 -1
View File
@@ -70,7 +70,8 @@ pub use notification::{NotificationStats, collect_notification_metrics};
pub(crate) use notification_target::{NotificationTargetRuntimeStats, collect_notification_target_runtime_metrics};
pub use notification_target::{NotificationTargetStats, collect_notification_target_metrics};
pub use on_demand_migration::{
OnDemandMigrationBreakerState, OnDemandMigrationBucketStats, collect_on_demand_migration_metrics, source_latency_le_label,
OdmBackfillBucketStats, OdmBackfillRuntimeStats, OnDemandMigrationBreakerState, OnDemandMigrationBucketStats,
collect_on_demand_migration_backfill_metrics, collect_on_demand_migration_metrics, source_latency_le_label,
};
pub use replication::{ReplicationMetricsSnapshot, collect_replication_metrics};
pub(crate) use replication::{ReplicationRuntimeStats, collect_replication_runtime_metrics};
@@ -21,10 +21,12 @@
use crate::metrics::report::PrometheusMetric;
use crate::metrics::schema::on_demand_migration::{
BREAKER_STATE_CLOSED, BREAKER_STATE_HALF_OPEN, BREAKER_STATE_OPEN, BUCKET_L, LE_L, ODM_BREAKER_STATE_MD,
ODM_INFLIGHT_PULLS_MD, ODM_PULL_FAILURES_TOTAL_MD, ODM_PULLED_BYTES_TOTAL_MD, ODM_PULLED_OBJECTS_TOTAL_MD,
ODM_QUEUE_DEPTH_MD, ODM_REQUESTS_TOTAL_MD, ODM_SOURCE_LATENCY_SECONDS_COUNT_MD, ODM_SOURCE_LATENCY_SECONDS_DISTRIBUTION_MD,
ODM_SOURCE_LATENCY_SECONDS_SUM_MD, OP_L, OUTCOME_L, PATH_L, REASON_L,
BREAKER_STATE_CLOSED, BREAKER_STATE_HALF_OPEN, BREAKER_STATE_OPEN, BUCKET_L, LE_L, ODM_BACKFILL_BYTES_MD,
ODM_BACKFILL_ENQUEUED_MD, ODM_BACKFILL_FAILED_MD, ODM_BACKFILL_JOBS_MD, ODM_BACKFILL_LISTED_MD, ODM_BACKFILL_PULLED_MD,
ODM_BACKFILL_SKIPPED_EXISTING_MD, ODM_BREAKER_STATE_MD, ODM_INFLIGHT_PULLS_MD, ODM_PULL_FAILURES_TOTAL_MD,
ODM_PULLED_BYTES_TOTAL_MD, ODM_PULLED_OBJECTS_TOTAL_MD, ODM_QUEUE_DEPTH_MD, ODM_REQUESTS_TOTAL_MD,
ODM_SOURCE_LATENCY_SECONDS_COUNT_MD, ODM_SOURCE_LATENCY_SECONDS_DISTRIBUTION_MD, ODM_SOURCE_LATENCY_SECONDS_SUM_MD, OP_L,
OUTCOME_L, PATH_L, REASON_L, SERVER_L, STATE_L,
};
use std::borrow::Cow;
use std::collections::BTreeMap;
@@ -170,6 +172,48 @@ pub fn collect_on_demand_migration_metrics(stats: &[OnDemandMigrationBucketStats
metrics
}
/// Counters of one bucket's latest backfill job (ODM-12,
/// rustfs/backlog#2159).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OdmBackfillBucketStats {
pub bucket: String,
/// Checkpoint state label (`running`, `completed`, ...).
pub state: String,
pub listed: u64,
pub enqueued: u64,
pub pulled: u64,
pub skipped_existing: u64,
pub failed: u64,
pub bytes: u64,
}
/// Backfill stats of every bucket with a checkpoint, labelled by node.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OdmBackfillRuntimeStats {
pub server: String,
pub buckets: Vec<OdmBackfillBucketStats>,
}
/// Seven series per bucket: the state gauge and six counters.
pub fn collect_on_demand_migration_backfill_metrics(stats: &OdmBackfillRuntimeStats) -> Vec<PrometheusMetric> {
let mut metrics = Vec::with_capacity(stats.buckets.len() * 7);
for bucket in &stats.buckets {
let labelled = |descriptor: &'static std::sync::LazyLock<crate::MetricDescriptor>, value: u64| {
PrometheusMetric::from_descriptor(descriptor, value as f64)
.with_label_owned(SERVER_L, stats.server.clone())
.with_label_owned(BUCKET_L, bucket.bucket.clone())
};
metrics.push(labelled(&ODM_BACKFILL_JOBS_MD, 1).with_label_owned(STATE_L, bucket.state.clone()));
metrics.push(labelled(&ODM_BACKFILL_LISTED_MD, bucket.listed));
metrics.push(labelled(&ODM_BACKFILL_ENQUEUED_MD, bucket.enqueued));
metrics.push(labelled(&ODM_BACKFILL_PULLED_MD, bucket.pulled));
metrics.push(labelled(&ODM_BACKFILL_SKIPPED_EXISTING_MD, bucket.skipped_existing));
metrics.push(labelled(&ODM_BACKFILL_FAILED_MD, bucket.failed));
metrics.push(labelled(&ODM_BACKFILL_BYTES_MD, bucket.bytes));
}
metrics
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
@@ -354,4 +398,81 @@ pub(crate) mod tests {
fn empty_snapshot_emits_nothing() {
assert!(collect_on_demand_migration_metrics(&[]).is_empty());
}
/// One running and one completed job, as the scheduler would see them
/// from the local backfill runner.
pub(crate) fn backfill_golden_stats(server: &str) -> OdmBackfillRuntimeStats {
OdmBackfillRuntimeStats {
server: server.to_string(),
buckets: vec![
OdmBackfillBucketStats {
bucket: "photos".to_string(),
state: "running".to_string(),
listed: 2000,
enqueued: 1500,
pulled: 1400,
skipped_existing: 500,
failed: 3,
bytes: 73_400_320,
},
OdmBackfillBucketStats {
bucket: "docs".to_string(),
state: "completed".to_string(),
..Default::default()
},
],
}
}
fn backfill_series<'a>(metrics: &'a [PrometheusMetric], name: &str, bucket: &str) -> Option<&'a PrometheusMetric> {
metrics.iter().find(|metric| {
metric.name == name
&& metric
.labels
.iter()
.any(|(label, value)| *label == BUCKET_L && value.as_ref() == bucket)
})
}
#[test]
fn backfill_collects_seven_series_per_bucket_with_the_odm_subsystem_prefix() {
let stats = backfill_golden_stats("node1:9000");
let metrics = collect_on_demand_migration_backfill_metrics(&stats);
assert_eq!(metrics.len(), 14);
assert!(
metrics
.iter()
.all(|metric| metric.name.starts_with("rustfs_on_demand_migration_backfill_"))
);
assert!(metrics.iter().all(|metric| {
metric
.labels
.iter()
.any(|(label, value)| *label == SERVER_L && value.as_ref() == "node1:9000")
}));
let jobs = backfill_series(&metrics, &ODM_BACKFILL_JOBS_MD.get_full_metric_name(), "photos").expect("jobs gauge");
assert_eq!(jobs.value, 1.0);
assert!(
jobs.labels
.iter()
.any(|(label, value)| *label == STATE_L && value.as_ref() == "running")
);
let listed = backfill_series(&metrics, &ODM_BACKFILL_LISTED_MD.get_full_metric_name(), "photos").expect("listed");
assert_eq!(listed.value, 2000.0);
let bytes = backfill_series(&metrics, &ODM_BACKFILL_BYTES_MD.get_full_metric_name(), "photos").expect("bytes");
assert_eq!(bytes.value, 73_400_320.0);
let docs_failed = backfill_series(&metrics, &ODM_BACKFILL_FAILED_MD.get_full_metric_name(), "docs").expect("docs failed");
assert_eq!(docs_failed.value, 0.0);
assert_eq!(
ODM_BACKFILL_LISTED_MD.get_full_metric_name(),
"rustfs_on_demand_migration_backfill_listed_total"
);
}
#[test]
fn backfill_no_buckets_means_no_series() {
let metrics = collect_on_demand_migration_backfill_metrics(&OdmBackfillRuntimeStats::default());
assert!(metrics.is_empty());
}
}
+3 -2
View File
@@ -34,6 +34,7 @@ pub(crate) use storage_api::metrics::{
BucketOperations, BucketOptions, ObsBucketBandwidthMonitor, ObsBucketReplicationStatsSnapshot, ObsEcstoreResult, ObsStore,
StorageAdminApi, obs_bucket_replication_stats_snapshot, obs_expiry_state_handle, obs_get_global_bucket_monitor,
obs_get_quota_config, obs_get_total_usable_capacity, obs_get_total_usable_capacity_free, obs_is_disk_compression_enabled,
obs_load_compression_total_from_memory, obs_load_data_usage_from_backend, obs_on_demand_migration_snapshot,
obs_replication_site_stats_snapshot, obs_resolve_object_store_handle, obs_transition_state_handle,
obs_load_compression_total_from_memory, obs_load_data_usage_from_backend, obs_on_demand_migration_backfill_snapshot,
obs_on_demand_migration_snapshot, obs_replication_site_stats_snapshot, obs_resolve_object_store_handle,
obs_transition_state_handle,
};
+136 -5
View File
@@ -33,6 +33,7 @@ use crate::metrics::collectors::{
NotificationStats,
NotificationTargetRuntimeStats,
NotificationTargetStats,
OdmBackfillRuntimeStats,
OnDemandMigrationBucketStats,
// System monitoring collectors (migrated from rustfs-obs::system)
ProcessAttributeError,
@@ -65,6 +66,7 @@ use crate::metrics::collectors::{
collect_node_metrics,
collect_notification_runtime_metrics,
collect_notification_target_runtime_metrics,
collect_on_demand_migration_backfill_metrics,
collect_on_demand_migration_metrics,
collect_process_attributes,
collect_process_cpu_metrics,
@@ -121,12 +123,15 @@ use crate::metrics::schema::notification_target::{
TARGET_ID as NOTIFICATION_TARGET_ID_LABEL, TARGET_TYPE as NOTIFICATION_TARGET_TYPE_LABEL,
};
use crate::metrics::schema::on_demand_migration::{
BUCKET_L as ODM_BUCKET_L, LE_L as ODM_LE_L, ODM_BREAKER_STATE_MD, ODM_INFLIGHT_PULLS_MD, ODM_PULL_FAILURES_TOTAL_MD,
BACKFILL_STATES as ODM_BACKFILL_STATES, BUCKET_L as ODM_BUCKET_L, LE_L as ODM_LE_L, ODM_BACKFILL_BYTES_MD,
ODM_BACKFILL_ENQUEUED_MD, ODM_BACKFILL_FAILED_MD, ODM_BACKFILL_JOBS_MD, ODM_BACKFILL_LISTED_MD, ODM_BACKFILL_PULLED_MD,
ODM_BACKFILL_SKIPPED_EXISTING_MD, ODM_BREAKER_STATE_MD, ODM_INFLIGHT_PULLS_MD, ODM_PULL_FAILURES_TOTAL_MD,
ODM_PULLED_BYTES_TOTAL_MD, ODM_PULLED_OBJECTS_TOTAL_MD, ODM_QUEUE_DEPTH_MD, ODM_REQUESTS_TOTAL_MD,
ODM_SOURCE_LATENCY_SECONDS_COUNT_MD, ODM_SOURCE_LATENCY_SECONDS_DISTRIBUTION_MD, ODM_SOURCE_LATENCY_SECONDS_SUM_MD,
OP_L as ODM_OP_L, OUTCOME_L as ODM_OUTCOME_L, PATH_L as ODM_PATH_L, PULL_FAILURE_REASONS as ODM_PULL_FAILURE_REASONS,
PULL_PATHS as ODM_PULL_PATHS, REASON_L as ODM_REASON_L, REQUEST_OPS as ODM_REQUEST_OPS,
REQUEST_OUTCOMES as ODM_REQUEST_OUTCOMES, SOURCE_LATENCY_LE as ODM_SOURCE_LATENCY_LE,
REQUEST_OUTCOMES as ODM_REQUEST_OUTCOMES, SERVER_L as ODM_SERVER_L, SOURCE_LATENCY_LE as ODM_SOURCE_LATENCY_LE,
STATE_L as ODM_STATE_L,
};
use crate::metrics::schema::scanner::{
BUCKET_LABEL as SCANNER_BUCKET_LABEL, CYCLE_SCOPE_LABEL as SCANNER_CYCLE_SCOPE_LABEL, DRIVE_LABEL as SCANNER_DRIVE_LABEL,
@@ -144,9 +149,9 @@ use crate::metrics::stats_collector::{
collect_bucket_replication_stats_bundle, collect_bucket_stats, collect_cluster_and_health_stats,
collect_cluster_config_stats, collect_cluster_usage_metric_stats, collect_compression_cluster_stats,
collect_disk_and_system_drive_runtime_stats, collect_erasure_set_stats, collect_host_network_stats, collect_iam_stats,
collect_ilm_runtime_metric_stats, collect_internode_network_stats, collect_on_demand_migration_stats,
collect_process_metric_bundle_with, collect_replication_stats, collect_scanner_runtime_metric_stats,
collect_system_cpu_and_memory_stats_with,
collect_ilm_runtime_metric_stats, collect_internode_network_stats, collect_on_demand_migration_backfill_stats,
collect_on_demand_migration_stats, collect_process_metric_bundle_with, collect_replication_stats,
collect_scanner_runtime_metric_stats, collect_system_cpu_and_memory_stats_with,
};
use crate::node_identity::{SERVER_LABEL, current_local_node_identity};
use crate::telemetry::retire_metric_series;
@@ -914,6 +919,10 @@ fn on_demand_migration_bucket_live_keys(stats: &[OnDemandMigrationBucketStats])
stats.iter().map(|stat| stat.bucket.clone()).collect()
}
fn on_demand_migration_backfill_bucket_live_keys(stats: &OdmBackfillRuntimeStats) -> HashSet<BucketKey> {
stats.buckets.iter().map(|stat| stat.bucket.clone()).collect()
}
fn update_series_zero_tombstones<T: Clone + Eq + std::hash::Hash>(
has_seen_valid_snapshot: &mut bool,
prev_live_keys: &mut HashSet<T>,
@@ -1646,6 +1655,42 @@ fn retire_on_demand_migration_metric_series(bucket: &str) -> usize {
.sum()
}
/// Every on-demand migration backfill series one node's job can own. The
/// `state` gauge is enumerated over the checkpoint's fixed lifecycle values
/// because only one of them is emitted per cycle.
fn on_demand_migration_backfill_metric_series(server: &str, bucket: &str) -> Vec<MetricSeriesKey> {
let job_labels = || {
vec![
(ODM_SERVER_L, Cow::Owned(server.to_string())),
(ODM_BUCKET_L, Cow::Owned(bucket.to_string())),
]
};
let mut series = Vec::new();
for state in ODM_BACKFILL_STATES {
let mut labels = job_labels();
labels.push((ODM_STATE_L, Cow::Borrowed(state)));
series.push((ODM_BACKFILL_JOBS_MD.get_full_metric_name(), labels));
}
for descriptor in [
&*ODM_BACKFILL_LISTED_MD,
&*ODM_BACKFILL_ENQUEUED_MD,
&*ODM_BACKFILL_PULLED_MD,
&*ODM_BACKFILL_SKIPPED_EXISTING_MD,
&*ODM_BACKFILL_FAILED_MD,
&*ODM_BACKFILL_BYTES_MD,
] {
series.push((descriptor.get_full_metric_name(), job_labels()));
}
series
}
fn retire_on_demand_migration_backfill_metric_series(server: &str, bucket: &str) -> usize {
on_demand_migration_backfill_metric_series(server, bucket)
.iter()
.map(|(name, labels)| retire_metric_series(name, labels))
.sum()
}
fn retire_repl_backlog_target_metric_series(bucket: &str, target_arn: &str) -> usize {
let labels = [
(BUCKET_L, Cow::Owned(bucket.to_string())),
@@ -2037,6 +2082,8 @@ pub fn init_metrics_runtime(token: CancellationToken) {
let mut has_seen_proxy_bucket_snapshot = false;
let mut prev_on_demand_migration_live_keys: HashSet<BucketKey> = HashSet::new();
let mut has_seen_on_demand_migration_snapshot = false;
let mut prev_on_demand_migration_backfill_live_keys: HashSet<BucketKey> = HashSet::new();
let mut has_seen_on_demand_migration_backfill_snapshot = false;
loop {
tokio::select! {
_ = interval.tick() => {
@@ -2125,6 +2172,24 @@ pub fn init_metrics_runtime(token: CancellationToken) {
prev_on_demand_migration_live_keys = current_on_demand_migration_live_keys;
has_seen_on_demand_migration_snapshot = true;
metrics.extend(collect_on_demand_migration_metrics(&on_demand_migration));
// Backfill jobs come and go independently of the bucket's config,
// so their series are retired on their own key set.
let on_demand_migration_backfill = collect_on_demand_migration_backfill_stats();
let current_on_demand_migration_backfill_live_keys =
on_demand_migration_backfill_bucket_live_keys(&on_demand_migration_backfill);
let retire_on_demand_migration_backfill_buckets = if has_seen_on_demand_migration_backfill_snapshot
{
prev_on_demand_migration_backfill_live_keys
.difference(&current_on_demand_migration_backfill_live_keys)
.cloned()
.collect::<Vec<_>>()
} else {
Vec::new()
};
prev_on_demand_migration_backfill_live_keys = current_on_demand_migration_backfill_live_keys;
has_seen_on_demand_migration_backfill_snapshot = true;
let on_demand_migration_backfill_server = on_demand_migration_backfill.server.clone();
metrics.extend(collect_on_demand_migration_backfill_metrics(&on_demand_migration_backfill));
let replication = collect_replication_stats().await;
metrics.extend(collect_replication_runtime_metrics(&ReplicationRuntimeStats {
server: current_local_node_identity(),
@@ -2154,6 +2219,12 @@ pub fn init_metrics_runtime(token: CancellationToken) {
for bucket in retire_on_demand_migration_buckets {
let _ = retire_on_demand_migration_metric_series(&bucket);
}
for bucket in retire_on_demand_migration_backfill_buckets {
let _ = retire_on_demand_migration_backfill_metric_series(
&on_demand_migration_backfill_server,
&bucket,
);
}
},
).await;
}
@@ -2980,6 +3051,66 @@ mod tests {
assert_eq!(on_demand_migration_metric_series("photos").len(), emitted.len());
}
#[test]
fn on_demand_migration_backfill_bucket_keys_detect_finished_jobs() {
let stats = crate::metrics::collectors::on_demand_migration::tests::backfill_golden_stats("node1:9000");
let previous = on_demand_migration_backfill_bucket_live_keys(&stats);
let current = on_demand_migration_backfill_bucket_live_keys(&OdmBackfillRuntimeStats {
server: stats.server.clone(),
buckets: stats.buckets[..1].to_vec(),
});
let retired = previous.difference(&current).cloned().collect::<HashSet<_>>();
assert_eq!(retired, bucket_keys(&["docs"]));
assert_eq!(current, bucket_keys(&["photos"]));
assert!(on_demand_migration_backfill_bucket_live_keys(&OdmBackfillRuntimeStats::default()).is_empty());
}
/// Every emitted backfill series must be named by the retirement walk.
/// The walk is wider than one cycle's emission on purpose: only the
/// job's current `state` gauge is emitted, so retirement enumerates all
/// lifecycle values to clear whichever one is live.
#[test]
fn on_demand_migration_backfill_retirement_covers_every_emitted_series() {
let stats = crate::metrics::collectors::on_demand_migration::tests::backfill_golden_stats("node1:9000");
let emitted = collect_on_demand_migration_backfill_metrics(&stats)
.into_iter()
.map(|metric| {
(
metric.name.to_string(),
metric
.labels
.into_iter()
.map(|(key, value)| (key, value.to_string()))
.collect::<Vec<_>>(),
)
})
.collect::<HashSet<_>>();
let retired = stats
.buckets
.iter()
.flat_map(|bucket| on_demand_migration_backfill_metric_series(&stats.server, &bucket.bucket))
.map(|(name, labels)| {
(
name,
labels
.into_iter()
.map(|(key, value)| (key, value.to_string()))
.collect::<Vec<_>>(),
)
})
.collect::<HashSet<_>>();
assert!(emitted.is_subset(&retired), "emitted: {emitted:?}, retired: {retired:?}");
assert_eq!(
on_demand_migration_backfill_metric_series("node1:9000", "photos").len(),
ODM_BACKFILL_STATES.len() + 6
);
// Without a process-global recorder there is nothing to retire; the
// walk itself must still cover every series.
assert_eq!(retire_on_demand_migration_backfill_metric_series("node1:9000", "photos"), 0);
}
#[test]
fn metrics_runtime_status_reports_disabled_state() {
let snapshot = build_metrics_runtime_status_snapshot(false, false, fixed_metrics_runtime_config(), false);
@@ -35,6 +35,10 @@ pub const PATH_L: &str = "path";
pub const REASON_L: &str = "reason";
/// Upper bound (seconds) of a source latency bucket.
pub const LE_L: &str = "le";
/// Node the backfill job runs on.
pub const SERVER_L: &str = "server";
/// Lifecycle state of a backfill job.
pub const STATE_L: &str = "state";
/// Fixed `op` label values.
pub const REQUEST_OPS: [&str; 2] = ["get", "head"];
@@ -71,6 +75,17 @@ pub const PULL_FAILURE_REASONS: [&str; 12] = [
pub const SOURCE_LATENCY_LE: [&str; 15] = [
"0.005", "0.01", "0.02", "0.05", "0.1", "0.2", "0.5", "1", "2", "5", "10", "20", "30", "60", "+Inf",
];
/// Fixed `state` label values of `backfill_jobs`; mirrors the checkpoint's
/// `BackfillState` variants in ecstore.
pub const BACKFILL_STATES: [&str; 7] = [
"pending",
"running",
"paused",
"cancelled",
"completed",
"completed_with_failures",
"failed",
];
/// `breaker_state` gauge value: the breaker admits every request.
pub const BREAKER_STATE_CLOSED: f64 = 0.0;
@@ -183,8 +198,80 @@ pub static ODM_BREAKER_STATE_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
)
});
// backfill_* descriptors (`listed_total`, `pulled_total`, `skipped_total`,
// `failed_total`, `state`) are added here by ODM-12 (rustfs/backlog#2159).
// backfill_* descriptors (ODM-12, rustfs/backlog#2159). Unlike the request
// path above, a backfill job runs on exactly one node at a time, so every
// backfill series carries the owning node in `server` on top of `bucket`.
const BACKFILL_JOBS: &str = "backfill_jobs";
const BACKFILL_LISTED_TOTAL: &str = "backfill_listed_total";
const BACKFILL_ENQUEUED_TOTAL: &str = "backfill_enqueued_total";
const BACKFILL_PULLED_TOTAL: &str = "backfill_pulled_total";
const BACKFILL_SKIPPED_EXISTING_TOTAL: &str = "backfill_skipped_existing_total";
const BACKFILL_FAILED_TOTAL: &str = "backfill_failed_total";
const BACKFILL_BYTES_TOTAL: &str = "backfill_bytes_total";
pub static ODM_BACKFILL_JOBS_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
new_gauge_md(
MetricName::from(BACKFILL_JOBS),
"On-demand migration backfill jobs by server, bucket and state (1 for the bucket's current state)",
&[SERVER_L, BUCKET_L, STATE_L],
subsystems::ON_DEMAND_MIGRATION,
)
});
pub static ODM_BACKFILL_LISTED_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
new_counter_md(
MetricName::from(BACKFILL_LISTED_TOTAL),
"Source keys listed by the on-demand migration backfill job, by server and bucket",
&[SERVER_L, BUCKET_L],
subsystems::ON_DEMAND_MIGRATION,
)
});
pub static ODM_BACKFILL_ENQUEUED_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
new_counter_md(
MetricName::from(BACKFILL_ENQUEUED_TOTAL),
"Keys queued for pulling by the on-demand migration backfill job, by server and bucket",
&[SERVER_L, BUCKET_L],
subsystems::ON_DEMAND_MIGRATION,
)
});
pub static ODM_BACKFILL_PULLED_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
new_counter_md(
MetricName::from(BACKFILL_PULLED_TOTAL),
"Objects stored locally by the on-demand migration backfill job, by server and bucket",
&[SERVER_L, BUCKET_L],
subsystems::ON_DEMAND_MIGRATION,
)
});
pub static ODM_BACKFILL_SKIPPED_EXISTING_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
new_counter_md(
MetricName::from(BACKFILL_SKIPPED_EXISTING_TOTAL),
"Keys the on-demand migration backfill job skipped because a local object already existed, by server and bucket",
&[SERVER_L, BUCKET_L],
subsystems::ON_DEMAND_MIGRATION,
)
});
pub static ODM_BACKFILL_FAILED_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
new_counter_md(
MetricName::from(BACKFILL_FAILED_TOTAL),
"Keys the on-demand migration backfill job could not pull, by server and bucket",
&[SERVER_L, BUCKET_L],
subsystems::ON_DEMAND_MIGRATION,
)
});
pub static ODM_BACKFILL_BYTES_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
new_counter_md(
MetricName::from(BACKFILL_BYTES_TOTAL),
"Bytes stored locally by the on-demand migration backfill job, by server and bucket",
&[SERVER_L, BUCKET_L],
subsystems::ON_DEMAND_MIGRATION,
)
});
#[cfg(test)]
mod tests {
@@ -208,6 +295,13 @@ mod tests {
(&*ODM_SOURCE_LATENCY_SECONDS_SUM_MD, "source_latency_seconds_sum"),
(&*ODM_SOURCE_LATENCY_SECONDS_COUNT_MD, "source_latency_seconds_count"),
(&*ODM_BREAKER_STATE_MD, "breaker_state"),
(&*ODM_BACKFILL_JOBS_MD, "backfill_jobs"),
(&*ODM_BACKFILL_LISTED_MD, "backfill_listed_total"),
(&*ODM_BACKFILL_ENQUEUED_MD, "backfill_enqueued_total"),
(&*ODM_BACKFILL_PULLED_MD, "backfill_pulled_total"),
(&*ODM_BACKFILL_SKIPPED_EXISTING_MD, "backfill_skipped_existing_total"),
(&*ODM_BACKFILL_FAILED_MD, "backfill_failed_total"),
(&*ODM_BACKFILL_BYTES_MD, "backfill_bytes_total"),
] {
assert_eq!(descriptor.get_full_metric_name(), format!("rustfs_on_demand_migration_{suffix}"));
assert_eq!(descriptor.subsystem, subsystems::ON_DEMAND_MIGRATION);
@@ -259,6 +353,23 @@ mod tests {
assert_eq!(SOURCE_LATENCY_LE[14], "+Inf");
}
#[test]
fn backfill_series_are_server_and_bucket_scoped() {
assert_eq!(ODM_BACKFILL_JOBS_MD.metric_type, MetricType::Gauge);
assert_eq!(labels(&ODM_BACKFILL_JOBS_MD), vec!["server", "bucket", "state"]);
for descriptor in [
&*ODM_BACKFILL_LISTED_MD,
&*ODM_BACKFILL_ENQUEUED_MD,
&*ODM_BACKFILL_PULLED_MD,
&*ODM_BACKFILL_SKIPPED_EXISTING_MD,
&*ODM_BACKFILL_FAILED_MD,
&*ODM_BACKFILL_BYTES_MD,
] {
assert_eq!(descriptor.metric_type, MetricType::Counter);
assert_eq!(labels(descriptor), vec!["server", "bucket"]);
}
}
#[test]
fn fixed_label_values_are_unique() {
for values in [
@@ -267,6 +378,7 @@ mod tests {
PULL_PATHS.as_slice(),
PULL_FAILURE_REASONS.as_slice(),
SOURCE_LATENCY_LE.as_slice(),
BACKFILL_STATES.as_slice(),
] {
let unique: std::collections::BTreeSet<_> = values.iter().collect();
assert_eq!(unique.len(), values.len(), "{values:?}");
+9 -3
View File
@@ -26,15 +26,16 @@ use crate::metrics::collectors::{
ClusterHealthStats, ClusterStats, ClusterUsageStats, CompressionClusterStats, CpuStats, DiskStats, DriveCountStats,
DriveDetailedStats, DriveRuntimeDetailedStats, ErasureSetStats, HostNetworkStats, IamStats, IlmActionTaskStats,
IlmBackpressureStats, IlmQueueTaskStats, IlmRuntimeStats, IlmStats, IlmTaskEventStats, MemoryStats, NetworkStats,
OnDemandMigrationBucketStats, ProcessStats, ProcessStatusType, ReplicationMetricsSnapshot, ResourceStats,
ScannerRuntimeStats, ScannerStats,
OdmBackfillRuntimeStats, OnDemandMigrationBucketStats, ProcessStats, ProcessStatusType, ReplicationMetricsSnapshot,
ResourceStats, ScannerRuntimeStats, ScannerStats,
};
use crate::metrics::runtime_sources::{ObsIlmRuntimeSnapshot, bucket_monitor_handle, iam_metrics_snapshot, ilm_runtime_snapshot};
use crate::metrics::{
BucketOperations, BucketOptions, ObsBucketReplicationStatsSnapshot, ObsEcstoreResult, ObsStore, StorageAdminApi,
obs_bucket_replication_stats_snapshot, obs_get_quota_config, obs_get_total_usable_capacity,
obs_get_total_usable_capacity_free, obs_load_compression_total_from_memory, obs_load_data_usage_from_backend,
obs_on_demand_migration_snapshot, obs_replication_site_stats_snapshot, obs_resolve_object_store_handle,
obs_on_demand_migration_backfill_snapshot, obs_on_demand_migration_snapshot, obs_replication_site_stats_snapshot,
obs_resolve_object_store_handle,
};
use crate::node_identity::current_local_node_identity;
use jiff::Timestamp;
@@ -667,6 +668,11 @@ pub fn collect_on_demand_migration_stats() -> Vec<OnDemandMigrationBucketStats>
obs_on_demand_migration_snapshot()
}
/// Collect this node's on-demand migration backfill job progress.
pub fn collect_on_demand_migration_backfill_stats() -> OdmBackfillRuntimeStats {
obs_on_demand_migration_backfill_snapshot(current_local_node_identity())
}
/// Collect site-level replication stats from the global replication runtime.
pub async fn collect_replication_stats() -> ReplicationMetricsSnapshot {
obs_site_replication_stats().await
+41 -3
View File
@@ -17,6 +17,9 @@ use std::time::Duration;
pub(crate) use rustfs_ecstore::api::bucket::bandwidth::monitor::Monitor as ObsBucketBandwidthMonitor;
pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::get_quota_config as obs_get_quota_config;
use rustfs_ecstore::api::bucket::on_demand_migration::backfill::{
BackfillCheckpoint as SourceBackfillCheckpoint, global_backfill_runner as source_global_backfill_runner,
};
use rustfs_ecstore::api::bucket::on_demand_migration::{
BreakerState as SourceOdmBreakerState, OdmBucketSnapshot as SourceOdmBucketSnapshot,
OnDemandMigrationSys as SourceOnDemandMigrationSys,
@@ -41,7 +44,9 @@ pub(crate) use rustfs_ecstore::api::runtime::{
pub(crate) use rustfs_ecstore::api::storage::ECStore as ObsStore;
use rustfs_storage_api as storage_contracts;
use crate::metrics::collectors::{OnDemandMigrationBreakerState, OnDemandMigrationBucketStats};
use crate::metrics::collectors::{
OdmBackfillBucketStats, OdmBackfillRuntimeStats, OnDemandMigrationBreakerState, OnDemandMigrationBucketStats,
};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ObsBucketReplicationTargetStatsSnapshot {
@@ -496,6 +501,38 @@ pub(crate) fn obs_on_demand_migration_snapshot() -> Vec<OnDemandMigrationBucketS
.collect()
}
fn on_demand_migration_backfill_stats_from_checkpoint(
bucket: String,
checkpoint: SourceBackfillCheckpoint,
) -> OdmBackfillBucketStats {
OdmBackfillBucketStats {
bucket,
state: checkpoint.state.as_str().to_string(),
listed: checkpoint.listed,
enqueued: checkpoint.enqueued,
pulled: checkpoint.pulled,
skipped_existing: checkpoint.skipped_existing,
failed: checkpoint.failed,
bytes: checkpoint.bytes,
}
}
/// Backfill jobs running on this node, sorted by bucket. Empty until the
/// runner is installed, and empty again once a job finishes: the series are
/// per-node job progress, not a cluster-wide history.
pub(crate) fn obs_on_demand_migration_backfill_snapshot(server: String) -> OdmBackfillRuntimeStats {
let buckets = source_global_backfill_runner()
.map(|runner| {
runner
.local_job_snapshots()
.into_iter()
.map(|(bucket, checkpoint)| on_demand_migration_backfill_stats_from_checkpoint(bucket, checkpoint))
.collect()
})
.unwrap_or_default();
OdmBackfillRuntimeStats { server, buckets }
}
pub(crate) async fn obs_replication_site_stats_snapshot(current_data_transfer_rate: f64) -> ObsReplicationSiteStatsSnapshot {
let Some(stats) = get_global_replication_stats() else {
return ObsReplicationSiteStatsSnapshot::default();
@@ -808,7 +845,8 @@ pub(crate) mod metrics {
ObsBucketBandwidthMonitor, ObsBucketReplicationStatsSnapshot, ObsEcstoreResult, ObsStore,
obs_bucket_replication_stats_snapshot, obs_expiry_state_handle, obs_get_global_bucket_monitor, obs_get_quota_config,
obs_get_total_usable_capacity, obs_get_total_usable_capacity_free, obs_is_disk_compression_enabled,
obs_load_compression_total_from_memory, obs_load_data_usage_from_backend, obs_on_demand_migration_snapshot,
obs_replication_site_stats_snapshot, obs_resolve_object_store_handle, obs_transition_state_handle,
obs_load_compression_total_from_memory, obs_load_data_usage_from_backend, obs_on_demand_migration_backfill_snapshot,
obs_on_demand_migration_snapshot, obs_replication_site_stats_snapshot, obs_resolve_object_store_handle,
obs_transition_state_handle,
};
}
@@ -33,6 +33,8 @@ Some bucket-scoped routes add gates after the `AdminAction` check. The gates are
| Route | Actions | Extra gates after authorization |
|---|---|---|
| `PUT`/`DELETE /rustfs/admin/v3/on-demand-migration/{bucket}` (`?dry-run=true` validates and probes without saving) | `SetBucketOnDemandMigrationAction` (`admin:SetBucketOnDemandMigration`) | bucket must exist (`NoSuchBucket`); `PUT` also requires the `RUSTFS_ON_DEMAND_MIGRATION_ENABLED` module switch (`OnDemandMigrationDisabled`, 400) and the server license (`license_check()`, same mapping as object zip downloads); the source must answer HEAD + a one-key list (`OnDemandMigrationSourceUnreachable`, 400). Handler: `rustfs/src/admin/handlers/on_demand_migration.rs` |
| `GET /rustfs/admin/v3/on-demand-migration/{bucket}` and `GET .../{bucket}/status` | `GetBucketOnDemandMigrationAction` (`admin:GetBucketOnDemandMigration`) | bucket must exist; reads work while the module switch is off so operators can inspect a disabled deployment; `GET` answers `NoSuchConfiguration` (404) when nothing is configured |
| `GET /rustfs/admin/v3/on-demand-migration/{bucket}` and `GET .../{bucket}/status` | `GetBucketOnDemandMigrationAction` (`admin:GetBucketOnDemandMigration`) | bucket must exist; reads work while the module switch is off so operators can inspect a disabled deployment; `GET` answers `NoSuchConfiguration` (404) when nothing is configured; `status` carries a `backfill` summary of the latest backfill job when one was recorded |
| `POST /rustfs/admin/v3/on-demand-migration/{bucket}/backfill?op=start\|cancel` (body `{prefix?, skip_existing?, dry_run?}` for `start`) | `SetBucketOnDemandMigrationAction` (`admin:SetBucketOnDemandMigration`) | bucket must exist; `op` must be `start` or `cancel` (`InvalidArgument`, 400); `start` also requires the module switch (`OnDemandMigrationDisabled`, 400), the server license, a saved config (`NoSuchConfiguration`, 404) and an enabled bucket state on this node (`OnDemandMigrationDisabled`, 400); a job holding its lease answers `OnDemandMigrationBackfillRunning` (409); `cancel` answers `NoSuchBackfillJob` (404) when the bucket never had a job. Handler: `rustfs/src/admin/handlers/on_demand_migration.rs` |
| `GET /rustfs/admin/v3/on-demand-migration/{bucket}/backfill` | `GetBucketOnDemandMigrationAction` (`admin:GetBucketOnDemandMigration`) | bucket must exist; returns the backfill checkpoint document (`failed_keys` are key hashes, no credentials); `NoSuchBackfillJob` (404) when the bucket never had a job |
Responses on these routes carry the redacted configuration (`secret_key` and `session_token` replaced by `REDACTED`); the wire shape is pinned by the fixtures under `crates/madmin/fixtures/on_demand_migration/`, shared by the server handler tests and the `rustfs-madmin` client tests.
Responses on the config routes carry the redacted configuration (`secret_key` and `session_token` replaced by `REDACTED`); the wire shapes of every route in this group, including the backfill checkpoint and the `status` summary, are pinned by the fixtures under `crates/madmin/fixtures/on_demand_migration/`, shared by the server handler tests and the `rustfs-madmin` client tests.
@@ -18,9 +18,12 @@
//! pulled on first access. This module is the management plane only:
//! `PUT`/`GET`/`DELETE /v3/on-demand-migration/{bucket}` configure, read and
//! clear the source, `?dry-run=true` validates and probes without saving, and
//! `GET .../status` reports the switch state plus this node's runtime
//! snapshot of the bucket (breaker, counters, last source error). The data
//! plane and backfill live in other ODM tasks.
//! `GET .../status` reports the switch state, this node's runtime snapshot
//! of the bucket (breaker, counters, last source error) and a summary of the
//! backfill job. `POST .../backfill?op=start|cancel` and `GET .../backfill`
//! drive the background backfill job (ODM-12, rustfs/backlog#2159) through
//! the process-wide `BackfillRunner`; the checkpoint document is the wire
//! shape.
//!
//! Credentials in the request body are never echoed: every response carries
//! the `redacted()` config, probe failures name only the error class, and no
@@ -35,6 +38,9 @@ use crate::admin::runtime_sources::{
};
use crate::admin::storage_api::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG;
use crate::admin::storage_api::bucket::metadata_sys;
use crate::admin::storage_api::bucket::on_demand_migration::backfill::{
BackfillCheckpoint, BackfillError, BackfillRequest, BackfillState, SkipExisting, global_backfill_runner,
};
use crate::admin::storage_api::bucket::on_demand_migration::source_client::{
SourceClient, SourceClientSpec, SourceError, SourceProbe, SourceProvider, SourceTimeouts,
};
@@ -54,7 +60,7 @@ use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
use rustfs_credentials::Credentials;
use rustfs_policy::policy::action::{Action, AdminAction};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::num::NonZeroU64;
use std::sync::Arc;
@@ -69,7 +75,12 @@ const EVENT_ADMIN_ON_DEMAND_MIGRATION_CONFIG: &str = "admin_bucket_on_demand_mig
const ROUTE_PATH: &str = "/v3/on-demand-migration/{bucket}";
const STATUS_ROUTE_PATH: &str = "/v3/on-demand-migration/{bucket}/status";
const BACKFILL_ROUTE_PATH: &str = "/v3/on-demand-migration/{bucket}/backfill";
const DRY_RUN_QUERY: &str = "dry-run";
/// `POST .../backfill?op=` selector.
const BACKFILL_OP_QUERY: &str = "op";
const BACKFILL_OP_START: &str = "start";
const BACKFILL_OP_CANCEL: &str = "cancel";
/// Error code returned when the module switch is off and a write is attempted.
pub(crate) const ERR_CODE_MODULE_DISABLED: &str = "OnDemandMigrationDisabled";
@@ -77,6 +88,10 @@ pub(crate) const ERR_CODE_MODULE_DISABLED: &str = "OnDemandMigrationDisabled";
pub(crate) const ERR_CODE_SOURCE_UNREACHABLE: &str = "OnDemandMigrationSourceUnreachable";
/// Error code returned by `GET` when the bucket has no configuration.
pub(crate) const ERR_CODE_NO_SUCH_CONFIGURATION: &str = "NoSuchConfiguration";
/// Error code (409) returned by `start` while a backfill job holds the lease.
pub(crate) const ERR_CODE_BACKFILL_RUNNING: &str = "OnDemandMigrationBackfillRunning";
/// Error code (404) returned when the bucket never had a backfill job.
pub(crate) const ERR_CODE_NO_SUCH_BACKFILL_JOB: &str = "NoSuchBackfillJob";
/// The published switch is `RUSTFS_ON_DEMAND_MIGRATION_ENABLED`, owned by
/// ODM-05 in `module_switches.rs`. This is the only read of it in the admin
@@ -148,6 +163,9 @@ pub(crate) struct BucketOnDemandMigrationStatus {
pub served_by_source_ratio: Option<f64>,
/// RFC 3339 save time of the config; `null` when not configured.
pub updated_at: Option<String>,
/// Latest backfill job of the bucket, absent when none was ever started.
#[serde(skip_serializing_if = "Option::is_none")]
pub backfill: Option<BackfillSummary>,
}
#[derive(Debug, Serialize)]
@@ -215,6 +233,7 @@ fn bucket_status(
queue_depth: 0,
served_by_source_ratio: None,
updated_at,
backfill: None,
};
let Some(runtime) = runtime else {
return Ok(status);
@@ -259,10 +278,62 @@ fn bucket_status(
Ok(status)
}
/// Counters of the bucket's backfill job for the status endpoint.
#[derive(Debug, Serialize)]
pub(crate) struct BackfillSummary {
pub job_id: String,
pub state: BackfillState,
pub listed: u64,
pub enqueued: u64,
pub pulled: u64,
pub skipped_existing: u64,
pub failed: u64,
pub bytes: u64,
pub updated_at: String,
}
impl BackfillSummary {
fn from_checkpoint(checkpoint: &BackfillCheckpoint) -> S3Result<Self> {
Ok(Self {
job_id: checkpoint.job_id.to_string(),
state: checkpoint.state,
listed: checkpoint.listed,
enqueued: checkpoint.enqueued,
pulled: checkpoint.pulled,
skipped_existing: checkpoint.skipped_existing,
failed: checkpoint.failed,
bytes: checkpoint.bytes,
updated_at: format_updated_at(checkpoint.updated_at)?,
})
}
}
/// Body of `POST .../backfill?op=start`; every field is optional.
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct BackfillStartRequest {
#[serde(default)]
pub prefix: Option<String>,
/// `always` (default) or `etag_or_size`.
#[serde(default)]
pub skip_existing: Option<String>,
#[serde(default)]
pub dry_run: bool,
}
/// `POST`/`GET .../backfill` response: the checkpoint document as stored.
#[derive(Debug, Serialize)]
pub(crate) struct BackfillJobResponse {
pub bucket: String,
pub job: BackfillCheckpoint,
}
pub struct SetBucketOnDemandMigrationHandler;
pub struct GetBucketOnDemandMigrationHandler;
pub struct DeleteBucketOnDemandMigrationHandler;
pub struct GetBucketOnDemandMigrationStatusHandler;
pub struct ControlBucketOnDemandMigrationBackfillHandler;
pub struct GetBucketOnDemandMigrationBackfillHandler;
pub fn register_on_demand_migration_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
r.insert(
@@ -285,6 +356,16 @@ pub fn register_on_demand_migration_route(r: &mut S3Router<AdminOperation>) -> s
format!("{ADMIN_PREFIX}{STATUS_ROUTE_PATH}").as_str(),
AdminOperation(&GetBucketOnDemandMigrationStatusHandler {}),
)?;
r.insert(
Method::POST,
format!("{ADMIN_PREFIX}{BACKFILL_ROUTE_PATH}").as_str(),
AdminOperation(&ControlBucketOnDemandMigrationBackfillHandler {}),
)?;
r.insert(
Method::GET,
format!("{ADMIN_PREFIX}{BACKFILL_ROUTE_PATH}").as_str(),
AdminOperation(&GetBucketOnDemandMigrationBackfillHandler {}),
)?;
Ok(())
}
@@ -357,6 +438,87 @@ fn is_dry_run(req: &S3Request<Body>) -> bool {
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
}
/// What `POST .../backfill` was asked to do.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BackfillOp {
Start,
Cancel,
}
fn backfill_op(req: &S3Request<Body>) -> S3Result<BackfillOp> {
match extract_query_params(&req.uri).get(BACKFILL_OP_QUERY).map(String::as_str) {
Some(BACKFILL_OP_START) => Ok(BackfillOp::Start),
Some(BACKFILL_OP_CANCEL) => Ok(BackfillOp::Cancel),
_ => Err(admin_s3_error(
S3ErrorCode::InvalidArgument,
format!("query parameter {BACKFILL_OP_QUERY} must be {BACKFILL_OP_START} or {BACKFILL_OP_CANCEL}"),
)),
}
}
/// An empty body means "defaults"; unknown fields and unknown
/// `skip_existing` labels are input errors.
fn parse_backfill_request(body: &[u8]) -> S3Result<BackfillRequest> {
let request: BackfillStartRequest = if body.iter().all(u8::is_ascii_whitespace) {
BackfillStartRequest::default()
} else {
serde_json::from_slice(body)
.map_err(|err| admin_s3_error(S3ErrorCode::InvalidArgument, format!("invalid backfill request: {err}")))?
};
let skip_existing = match request.skip_existing.as_deref() {
None => SkipExisting::default(),
Some(label) => SkipExisting::parse(label).ok_or_else(|| {
admin_s3_error(
S3ErrorCode::InvalidArgument,
format!("skip_existing must be always or etag_or_size, got {label}"),
)
})?,
};
let prefix = request.prefix.filter(|prefix| !prefix.is_empty());
Ok(BackfillRequest {
prefix,
skip_existing,
dry_run: request.dry_run,
})
}
/// Runner errors onto HTTP: a held lease is a 409, a missing job a 404, a
/// missing or disabled config the same codes the config routes use.
fn backfill_error(bucket: &str, err: BackfillError) -> S3Error {
match err {
BackfillError::AlreadyRunning { job_id, owner, .. } => custom_error(
ERR_CODE_BACKFILL_RUNNING,
StatusCode::CONFLICT,
format!("a backfill job is already running for bucket {bucket} (job {job_id}, owner {owner})"),
),
BackfillError::LeaseBusy(_) | BackfillError::Conflict(_) => custom_error(
ERR_CODE_BACKFILL_RUNNING,
StatusCode::CONFLICT,
format!("the backfill job of bucket {bucket} is being started or updated elsewhere; retry"),
),
BackfillError::NotFound(_) => custom_error(
ERR_CODE_NO_SUCH_BACKFILL_JOB,
StatusCode::NOT_FOUND,
format!("no backfill job recorded for bucket {bucket}"),
),
BackfillError::NotConfigured(_) => custom_error(
ERR_CODE_NO_SUCH_CONFIGURATION,
StatusCode::NOT_FOUND,
format!("on-demand migration is not configured for bucket {bucket}"),
),
BackfillError::Unavailable(_) => custom_error(
ERR_CODE_MODULE_DISABLED,
StatusCode::BAD_REQUEST,
format!("bucket {bucket} has no enabled on-demand migration source on this node"),
),
BackfillError::RunnerNotInstalled => admin_s3_error(S3ErrorCode::InternalError, "backfill runner is not installed"),
BackfillError::Malformed(_) | BackfillError::UnsupportedFormatVersion { .. } => {
admin_s3_error(S3ErrorCode::InternalError, format!("backfill checkpoint unreadable: {err}"))
}
BackfillError::Storage(_) => admin_s3_error(S3ErrorCode::InternalError, format!("backfill checkpoint failed: {err}")),
}
}
/// Every endpoint of this deployment, as `scheme://host:port`, so a source
/// naming one of them with the same bucket is rejected as a self-reference.
/// Single-node local-disk layouts carry no host and contribute nothing; the
@@ -671,15 +833,100 @@ impl Operation for GetBucketOnDemandMigrationStatusHandler {
})?;
let runtime = OnDemandMigrationSys::get().bucket_snapshot(&bucket);
let status = bucket_status(
let backfill = match global_backfill_runner() {
Some(runner) => runner
.status(&bucket)
.await
.map_err(|err| backfill_error(&bucket, err))?
.map(|checkpoint| BackfillSummary::from_checkpoint(&checkpoint))
.transpose()?,
None => None,
};
let mut status = bucket_status(
config.as_ref().map(|(config, updated_at)| (config, *updated_at)),
runtime,
module_enabled(),
)?;
status.backfill = backfill;
admin_json_response(req.uri.path(), &cred.secret_key, StatusCode::OK, &status)
}
}
#[async_trait::async_trait]
impl Operation for ControlBucketOnDemandMigrationBackfillHandler {
#[tracing::instrument(skip_all)]
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let bucket = bucket_from_params(&params)?;
let cred = authorize_for_bucket(&req, AdminAction::SetBucketOnDemandMigrationAction, &bucket).await?;
let op = backfill_op(&req)?;
let path = req.uri.path().to_string();
let runner = global_backfill_runner().ok_or_else(|| backfill_error(&bucket, BackfillError::RunnerNotInstalled))?;
let job = match op {
BackfillOp::Start => {
if !module_enabled() {
return Err(custom_error(
ERR_CODE_MODULE_DISABLED,
StatusCode::BAD_REQUEST,
format!("on-demand migration is disabled: set {ENV_ON_DEMAND_MIGRATION_ENABLED}=true"),
));
}
license_gate()?;
let body = read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &path, &cred.secret_key).await?;
let request = parse_backfill_request(&body)?;
let job = runner
.start(&bucket, request)
.await
.map_err(|err| backfill_error(&bucket, err))?;
info!(
event = EVENT_ADMIN_ON_DEMAND_MIGRATION_CONFIG,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_ON_DEMAND_MIGRATION,
bucket = %bucket,
job_id = %job.job_id,
dry_run = job.dry_run,
skip_existing = job.skip_existing.as_str(),
"on-demand migration backfill started"
);
job
}
BackfillOp::Cancel => {
let job = runner.cancel(&bucket).await.map_err(|err| backfill_error(&bucket, err))?;
info!(
event = EVENT_ADMIN_ON_DEMAND_MIGRATION_CONFIG,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_ON_DEMAND_MIGRATION,
bucket = %bucket,
job_id = %job.job_id,
state = job.state.as_str(),
"on-demand migration backfill cancel requested"
);
job
}
};
let response = BackfillJobResponse { bucket, job };
admin_json_response(&path, &cred.secret_key, StatusCode::OK, &response)
}
}
#[async_trait::async_trait]
impl Operation for GetBucketOnDemandMigrationBackfillHandler {
#[tracing::instrument(skip_all)]
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let bucket = bucket_from_params(&params)?;
let cred = authorize_for_bucket(&req, AdminAction::GetBucketOnDemandMigrationAction, &bucket).await?;
let runner = global_backfill_runner().ok_or_else(|| backfill_error(&bucket, BackfillError::RunnerNotInstalled))?;
let job = runner
.status(&bucket)
.await
.map_err(|err| backfill_error(&bucket, err))?
.ok_or_else(|| backfill_error(&bucket, BackfillError::NotFound(bucket.clone())))?;
let response = BackfillJobResponse { bucket, job };
admin_json_response(req.uri.path(), &cred.secret_key, StatusCode::OK, &response)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -699,6 +946,9 @@ mod tests {
const SET_RESPONSE_FIXTURE: &str = include_str!("../../../../crates/madmin/fixtures/on_demand_migration/set_response.json");
const GET_RESPONSE_FIXTURE: &str = include_str!("../../../../crates/madmin/fixtures/on_demand_migration/get_response.json");
const STATUS_FIXTURE: &str = include_str!("../../../../crates/madmin/fixtures/on_demand_migration/status.json");
const STATUS_WITH_BACKFILL_FIXTURE: &str =
include_str!("../../../../crates/madmin/fixtures/on_demand_migration/status_with_backfill.json");
const BACKFILL_JOB_FIXTURE: &str = include_str!("../../../../crates/madmin/fixtures/on_demand_migration/backfill_job.json");
const FIXTURE_UPDATED_AT: &str = "2026-09-02T10:00:00Z";
fn fixture_config() -> OnDemandMigrationConfig {
@@ -800,6 +1050,7 @@ mod tests {
let json = serde_json::to_string(&status).expect("serialize");
assert_eq!(json, STATUS_FIXTURE.trim());
assert!(json.contains(r#""served_by_source_ratio":null"#), "the ratio field is present as null");
assert!(status.backfill.is_none(), "a bucket without a job carries no summary");
}
#[test]
@@ -853,6 +1104,119 @@ mod tests {
assert_eq!(config_endpoint_host(&config).as_deref(), Some("bucket.s3.example"));
}
fn fixture_job() -> BackfillCheckpoint {
let value: serde_json::Value = serde_json::from_str(BACKFILL_JOB_FIXTURE.trim()).expect("fixture parses");
BackfillCheckpoint::from_json(value["job"].to_string().as_bytes()).expect("fixture job decodes")
}
#[test]
fn backfill_job_response_matches_madmin_golden_fixture() {
let response = BackfillJobResponse {
bucket: "photos".to_string(),
job: fixture_job(),
};
assert_eq!(serde_json::to_string(&response).expect("serialize"), BACKFILL_JOB_FIXTURE.trim());
}
#[test]
fn status_with_backfill_summary_matches_madmin_golden_fixture() {
let config = fixture_config();
let updated_at = OffsetDateTime::from_unix_timestamp(1_788_343_200).expect("timestamp");
let mut status = bucket_status(Some((&config, updated_at)), Some(fixture_runtime_snapshot()), true).expect("status");
status.backfill = Some(BackfillSummary::from_checkpoint(&fixture_job()).expect("summary"));
assert_eq!(serde_json::to_string(&status).expect("serialize"), STATUS_WITH_BACKFILL_FIXTURE.trim());
}
#[test]
fn backfill_request_defaults_validates_skip_existing_and_rejects_unknown_fields() {
assert_eq!(parse_backfill_request(b"").expect("empty body"), BackfillRequest::default());
assert_eq!(parse_backfill_request(b" \n").expect("blank body"), BackfillRequest::default());
assert_eq!(parse_backfill_request(b"{}").expect("empty object"), BackfillRequest::default());
let full = parse_backfill_request(br#"{"prefix":"photos/","skip_existing":"etag_or_size","dry_run":true}"#)
.expect("full request");
assert_eq!(
full,
BackfillRequest {
prefix: Some("photos/".to_string()),
skip_existing: SkipExisting::EtagOrSize,
dry_run: true,
}
);
assert_eq!(
parse_backfill_request(br#"{"prefix":""}"#).expect("empty prefix").prefix,
None,
"an empty prefix means no prefix"
);
let err = parse_backfill_request(br#"{"skip_existing":"never"}"#).unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
assert!(err.message().unwrap_or_default().contains("etag_or_size"));
let err = parse_backfill_request(br#"{"bogus":1}"#).unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
assert!(err.message().unwrap_or_default().contains("bogus"));
}
#[test]
fn backfill_op_requires_start_or_cancel() {
let request = |uri: &'static str| S3Request {
input: Body::empty(),
method: Method::POST,
uri: Uri::from_static(uri),
headers: http::HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
assert_eq!(
backfill_op(&request("/rustfs/admin/v3/on-demand-migration/b/backfill?op=start")).expect("start"),
BackfillOp::Start
);
assert_eq!(
backfill_op(&request("/rustfs/admin/v3/on-demand-migration/b/backfill?op=cancel")).expect("cancel"),
BackfillOp::Cancel
);
for uri in [
"/rustfs/admin/v3/on-demand-migration/b/backfill",
"/rustfs/admin/v3/on-demand-migration/b/backfill?op=pause",
"/rustfs/admin/v3/on-demand-migration/b/backfill?op=START",
] {
assert_eq!(backfill_op(&request(uri)).unwrap_err().code(), &S3ErrorCode::InvalidArgument, "{uri}");
}
}
#[test]
fn backfill_errors_map_to_conflict_not_found_and_config_codes() {
let running = backfill_error(
"b",
BackfillError::AlreadyRunning {
bucket: "b".to_string(),
job_id: fixture_job().job_id,
owner: "node-a:9000".to_string(),
},
);
assert_eq!(running.status_code(), Some(StatusCode::CONFLICT));
assert_eq!(running.code(), &S3ErrorCode::Custom(ERR_CODE_BACKFILL_RUNNING.into()));
assert!(running.message().unwrap_or_default().contains("node-a:9000"));
let busy = backfill_error("b", BackfillError::LeaseBusy("b".to_string()));
assert_eq!(busy.status_code(), Some(StatusCode::CONFLICT));
let missing = backfill_error("b", BackfillError::NotFound("b".to_string()));
assert_eq!(missing.status_code(), Some(StatusCode::NOT_FOUND));
assert_eq!(missing.code(), &S3ErrorCode::Custom(ERR_CODE_NO_SUCH_BACKFILL_JOB.into()));
let unconfigured = backfill_error("b", BackfillError::NotConfigured("b".to_string()));
assert_eq!(unconfigured.code(), &S3ErrorCode::Custom(ERR_CODE_NO_SUCH_CONFIGURATION.into()));
let unavailable = backfill_error("b", BackfillError::Unavailable("b".to_string()));
assert_eq!(unavailable.status_code(), Some(StatusCode::BAD_REQUEST));
assert_eq!(unavailable.code(), &S3ErrorCode::Custom(ERR_CODE_MODULE_DISABLED.into()));
let broken = backfill_error("b", BackfillError::UnsupportedFormatVersion { found: 9, supported: 1 });
assert_eq!(broken.code(), &S3ErrorCode::InternalError);
}
#[test]
fn updated_at_uses_rfc3339_utc() {
let ts = OffsetDateTime::from_unix_timestamp(1_788_343_200).expect("timestamp");
+28
View File
@@ -416,6 +416,18 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
GET_BUCKET_ON_DEMAND_MIGRATION,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Post,
"/rustfs/admin/v3/on-demand-migration/{bucket}/backfill",
SET_BUCKET_ON_DEMAND_MIGRATION,
RouteRiskLevel::High,
),
admin(
HttpMethod::Get,
"/rustfs/admin/v3/on-demand-migration/{bucket}/backfill",
GET_BUCKET_ON_DEMAND_MIGRATION,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Get,
"/rustfs/admin/export-bucket-metadata",
@@ -2211,6 +2223,22 @@ mod tests {
"/rustfs/admin/v3/on-demand-migration/{bucket}/status",
GET_BUCKET_ON_DEMAND_MIGRATION,
);
// Backfill control (start/cancel) is a write; reading the checkpoint is not.
assert_action(
HttpMethod::Post,
"/rustfs/admin/v3/on-demand-migration/{bucket}/backfill",
SET_BUCKET_ON_DEMAND_MIGRATION,
);
assert_action(
HttpMethod::Get,
"/rustfs/admin/v3/on-demand-migration/{bucket}/backfill",
GET_BUCKET_ON_DEMAND_MIGRATION,
);
assert_not_action(
HttpMethod::Get,
"/rustfs/admin/v3/on-demand-migration/{bucket}/backfill",
SET_BUCKET_ON_DEMAND_MIGRATION,
);
// Reads never require the write action, and the routes are not bucket-target routes.
assert_not_action(
HttpMethod::Get,
@@ -248,6 +248,16 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
"/v3/on-demand-migration/{bucket}/status",
"/v3/on-demand-migration/test-bucket/status",
),
admin_route_sample(
Method::POST,
"/v3/on-demand-migration/{bucket}/backfill",
"/v3/on-demand-migration/test-bucket/backfill",
),
admin_route_sample(
Method::GET,
"/v3/on-demand-migration/{bucket}/backfill",
"/v3/on-demand-migration/test-bucket/backfill",
),
admin_route(Method::GET, "/export-bucket-metadata"),
admin_route(Method::GET, "/v3/export-bucket-metadata"),
admin_route(Method::PUT, "/import-bucket-metadata"),
@@ -1286,6 +1296,8 @@ fn test_register_routes_cover_representative_admin_paths() {
assert_route(&router, Method::GET, &admin_path("/v3/on-demand-migration/test-bucket"));
assert_route(&router, Method::DELETE, &admin_path("/v3/on-demand-migration/test-bucket"));
assert_route(&router, Method::GET, &admin_path("/v3/on-demand-migration/test-bucket/status"));
assert_route(&router, Method::POST, &admin_path("/v3/on-demand-migration/test-bucket/backfill"));
assert_route(&router, Method::GET, &admin_path("/v3/on-demand-migration/test-bucket/backfill"));
assert_route(&router, Method::GET, &admin_path("/export-bucket-metadata"));
assert_route(&router, Method::GET, &admin_path("/v3/export-bucket-metadata"));
@@ -1419,6 +1431,8 @@ fn test_admin_alias_paths_match_existing_admin_routes() {
(Method::GET, compat_admin_alias_path("/v3/on-demand-migration/b")),
(Method::DELETE, compat_admin_alias_path("/v3/on-demand-migration/b")),
(Method::GET, compat_admin_alias_path("/v3/on-demand-migration/b/status")),
(Method::POST, compat_admin_alias_path("/v3/on-demand-migration/b/backfill")),
(Method::GET, compat_admin_alias_path("/v3/on-demand-migration/b/backfill")),
] {
assert!(
router.contains_compatible_route(method.clone(), &path),
+9
View File
@@ -292,6 +292,15 @@ pub(crate) mod on_demand_migration {
pub(crate) type Provider = super::ecstore_bucket::on_demand_migration::Provider;
pub(crate) type ValidationContext<'a> = super::ecstore_bucket::on_demand_migration::ValidationContext<'a>;
pub(crate) mod backfill {
pub(crate) type BackfillCheckpoint = super::super::ecstore_bucket::on_demand_migration::backfill::BackfillCheckpoint;
pub(crate) type BackfillError = super::super::ecstore_bucket::on_demand_migration::backfill::BackfillError;
pub(crate) type BackfillRequest = super::super::ecstore_bucket::on_demand_migration::backfill::BackfillRequest;
pub(crate) type BackfillState = super::super::ecstore_bucket::on_demand_migration::backfill::BackfillState;
pub(crate) type SkipExisting = super::super::ecstore_bucket::on_demand_migration::backfill::SkipExisting;
pub(crate) use super::super::ecstore_bucket::on_demand_migration::backfill::global_backfill_runner;
}
pub(crate) mod source_client {
pub(crate) type SourceClient = super::super::ecstore_bucket::on_demand_migration::source_client::SourceClient;
pub(crate) type SourceClientSpec = super::super::ecstore_bucket::on_demand_migration::source_client::SourceClientSpec;
+48 -3
View File
@@ -14,9 +14,13 @@
use crate::bitrot_selftest::run_startup_bitrot_self_test;
use crate::module_switches::{
bitrot_selftest_enabled_from_env, bitrot_selftest_strict_from_env, heal_enabled_from_env, scanner_enabled_from_env,
bitrot_selftest_enabled_from_env, bitrot_selftest_strict_from_env, heal_enabled_from_env,
is_on_demand_migration_module_enabled, scanner_enabled_from_env,
};
use crate::storage_api::startup::background::{
BackfillRunner, ECStore, OnDemandMigrationSys, SysBackfillContexts, install_global_backfill_runner,
set_workload_admission_snapshot_provider, spawn_backfill_recovery_loop,
};
use crate::storage_api::startup::background::{ECStore, set_workload_admission_snapshot_provider};
use crate::workload_admission::RustFsWorkloadAdmissionSnapshotProvider;
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
use rustfs_heal::{
@@ -28,6 +32,7 @@ use tracing::{debug, info};
const LOG_COMPONENT_MAIN: &str = "main";
const LOG_SUBSYSTEM_STARTUP: &str = "startup";
const EVENT_BACKGROUND_SERVICES_CONFIGURED: &str = "background_services_configured";
const EVENT_ODM_BACKFILL_RECOVERY_CONFIGURED: &str = "odm_backfill_recovery_configured";
pub(crate) async fn init_background_service_runtime(store: Arc<ECStore>) -> Result<bool> {
// Pin the bitrot algorithms before anything can write or verify a shard:
@@ -56,7 +61,7 @@ pub(crate) async fn init_background_service_runtime(store: Arc<ECStore>) -> Resu
let _ = set_workload_admission_snapshot_provider(workload_provider.clone());
if enable_heal || enable_scanner {
let heal_storage = Arc::new(ECStoreHealStorage::new(store));
let heal_storage = Arc::new(ECStoreHealStorage::new(store.clone()));
init_heal_manager_with_workload_provider(heal_storage, None, Some(workload_provider)).await?;
}
@@ -74,5 +79,45 @@ pub(crate) async fn init_background_service_runtime(store: Arc<ECStore>) -> Resu
);
}
init_on_demand_migration_backfill_runtime(store).await;
Ok(enable_scanner)
}
/// Installs the backfill runner (admin start/cancel/status need it even
/// while the module switch is off, to read checkpoints) and, with the switch
/// on, the recovery loop that takes over expired leases (rustfs/backlog#2159).
async fn init_on_demand_migration_backfill_runtime(store: Arc<ECStore>) {
let contexts = Arc::new(SysBackfillContexts::new(store.clone(), OnDemandMigrationSys::get()));
let runner = BackfillRunner::for_local_node(store.clone(), contexts).await;
if !install_global_backfill_runner(runner.clone()) {
debug!(
target: "rustfs::main::run",
event = EVENT_ODM_BACKFILL_RECOVERY_CONFIGURED,
component = LOG_COMPONENT_MAIN,
subsystem = LOG_SUBSYSTEM_STARTUP,
state = "already_installed",
"On-demand migration backfill runner already installed"
);
return;
}
let module_enabled = is_on_demand_migration_module_enabled();
let node = runner.node().to_string();
let state = if !module_enabled {
"skipped_module_disabled"
} else if spawn_backfill_recovery_loop(runner) {
"started"
} else {
"skipped_no_cancel_token"
};
info!(
target: "rustfs::main::run",
event = EVENT_ODM_BACKFILL_RECOVERY_CONFIGURED,
component = LOG_COMPONENT_MAIN,
subsystem = LOG_SUBSYSTEM_STARTUP,
state = state,
module_enabled = module_enabled,
node = %node,
"On-demand migration backfill recovery configured"
);
}
+4
View File
@@ -278,6 +278,10 @@ pub(crate) mod startup {
}
pub(crate) mod background {
pub(crate) use crate::storage::storage_api::ecstore_bucket::on_demand_migration::OnDemandMigrationSys;
pub(crate) use crate::storage::storage_api::ecstore_bucket::on_demand_migration::backfill::{
BackfillRunner, SysBackfillContexts, install_global_backfill_runner, spawn_backfill_recovery_loop,
};
pub(crate) use crate::storage::storage_api::{
BitrotSelfTestError, ECStore, bitrot_self_test, set_workload_admission_snapshot_provider,
};