feat(ilm): add durable manual transition job store (#5229)

* feat(ilm): add manual transition job route contract

Refs #1479

Co-Authored-By: heihutu <heihutu@gmail.com>

* feat(ilm): add durable manual transition job store

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(ilm): harden durable transition job cancellation

Co-Authored-By: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-07-25 22:54:14 +08:00
committed by GitHub
parent 7ab0955f8b
commit 6974963e20
9 changed files with 2271 additions and 486 deletions
+59
View File
@@ -48,6 +48,7 @@ mod tests {
const ADMIN_INFO_PATH: &str = "/rustfs/admin/v3/info";
const ADMIN_MANUAL_TRANSITION_PATH: &str =
"/rustfs/admin/v3/ilm/transition/run?bucket=auth-deny-manual-transition&maxObjects=1";
const ADMIN_MANUAL_TRANSITION_JOB_PATH: &str = "/rustfs/admin/v3/ilm/transition/jobs/11111111-1111-4111-8111-111111111111";
/// Send a SigV4-signed request to `path` (optionally with a JSON `body`) and
/// return `(status, body)`. Uses the `UNSIGNED_PAYLOAD` content hash so a
@@ -189,6 +190,42 @@ mod tests {
root_body.contains("\"mode\":\"enqueue_only\""),
"root response should be the manual transition JSON contract, body: {root_body}"
);
let (root_status, root_body) = signed_request(
&env.url,
http::Method::GET,
ADMIN_MANUAL_TRANSITION_JOB_PATH,
None,
&env.access_key,
&env.secret_key,
)
.await?;
assert_eq!(
root_status,
reqwest::StatusCode::NOT_FOUND,
"root credential must reach the manual transition status handler, body: {root_body}"
);
assert!(
root_body.contains("NoSuchKey"),
"missing durable job should return NoSuchKey once authorized, body: {root_body}"
);
let (root_status, root_body) = signed_request(
&env.url,
http::Method::DELETE,
ADMIN_MANUAL_TRANSITION_JOB_PATH,
None,
&env.access_key,
&env.secret_key,
)
.await?;
assert_eq!(
root_status,
reqwest::StatusCode::NOT_FOUND,
"root credential must reach the manual transition cancel handler, body: {root_body}"
);
assert!(
root_body.contains("NoSuchKey"),
"missing durable job cancel should return NoSuchKey once authorized, body: {root_body}"
);
let (status, body) =
signed_request(&env.url, http::Method::POST, ADMIN_MANUAL_TRANSITION_PATH, None, user_ak, user_sk).await?;
@@ -201,6 +238,28 @@ mod tests {
body.contains("AccessDenied"),
"manual transition rejection must carry the AccessDenied S3 error code, body: {body}"
);
let (status, body) =
signed_request(&env.url, http::Method::GET, ADMIN_MANUAL_TRANSITION_JOB_PATH, None, user_ak, user_sk).await?;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"non-admin credential must get 403 on manual transition status, body: {body}"
);
assert!(
body.contains("AccessDenied"),
"manual transition status rejection must carry the AccessDenied S3 error code, body: {body}"
);
let (status, body) =
signed_request(&env.url, http::Method::DELETE, ADMIN_MANUAL_TRANSITION_JOB_PATH, None, user_ak, user_sk).await?;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"non-admin credential must get 403 on manual transition cancel, body: {body}"
);
assert!(
body.contains("AccessDenied"),
"manual transition cancel rejection must carry the AccessDenied S3 error code, body: {body}"
);
env.stop_server();
Ok(())
+90 -1
View File
@@ -71,8 +71,10 @@ const MANUAL_DRY_RUN_BUCKET: &str = "ilm7-manual-dry-run";
const MANUAL_NOT_DUE_BUCKET: &str = "ilm7-manual-not-due";
const MANUAL_QUEUE_PRESSURE_BUCKET: &str = "ilm7-manual-queue-pressure";
const MANUAL_ASYNC_STATUS_BUCKET: &str = "ilm7-manual-async-status";
const MANUAL_CONTINUATION_BUCKET: &str = "ilm7-manual-continuation";
const MANUAL_ASYNC_LIMIT_BUCKET: &str = "ilm7-manual-async-limit";
const MANUAL_QUEUE_PRESSURE_PREFIX: &str = "manual-queue-pressure/";
const MANUAL_CONTINUATION_PREFIX: &str = "manual-continuation/";
const MANUAL_ASYNC_LIMIT_PREFIX: &str = "manual-async-limit/";
const OBJECT_KEY: &str = "tier/鲁A12345/report.bin";
const MANUAL_DUE_KEY: &str = "manual-due/report.bin";
@@ -352,6 +354,7 @@ struct ManualTransitionRunReport {
skipped_queue_timeout: u64,
truncated_by_limit: bool,
truncated_by_duration: bool,
continuation_token: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -379,13 +382,28 @@ async fn manual_transition_run_with_max(
prefix: &str,
dry_run: bool,
max_objects: u64,
) -> Result<ManualTransitionRunResponse, Box<dyn std::error::Error + Send + Sync>> {
manual_transition_run_with_max_and_continuation(hot, bucket, prefix, dry_run, max_objects, None).await
}
async fn manual_transition_run_with_max_and_continuation(
hot: &RustFSTestEnvironment,
bucket: &str,
prefix: &str,
dry_run: bool,
max_objects: u64,
continuation_token: Option<&str>,
) -> Result<ManualTransitionRunResponse, Box<dyn std::error::Error + Send + Sync>> {
let bucket = urlencoding::encode(bucket);
let prefix = urlencoding::encode(prefix);
let tier = urlencoding::encode(TIER_NAME);
let path = format!(
let mut path = format!(
"/rustfs/admin/v3/ilm/transition/run?bucket={bucket}&prefix={prefix}&tier={tier}&dryRun={dry_run}&maxObjects={max_objects}"
);
if let Some(token) = continuation_token {
path.push_str("&continuationToken=");
path.push_str(&urlencoding::encode(token));
}
let (status, body) = signed_admin_request(&hot.url, Method::POST, &path, None, &hot.access_key, &hot.secret_key).await?;
if !status.is_success() {
return Err(format!("manual transition run failed: status={status}, body={body}").into());
@@ -928,6 +946,77 @@ async fn test_manual_transition_run_contract_no_status_cancel_fields() -> TestRe
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_manual_transition_run_continuation_token_resumes_without_raw_markers() -> TestResult {
let mut cold = RustFSTestEnvironment::new().await?;
cold.access_key = "manualcontinuationcoldtieradmin".to_string();
cold.secret_key = "manualcontinuationcoldtiersecret".to_string();
cold.start_rustfs_server_without_cleanup(vec![]).await?;
let cold_client = cold.create_s3_client();
cold_client.create_bucket().bucket(TIER_BUCKET).send().await?;
let mut hot = RustFSTestEnvironment::new().await?;
hot.start_rustfs_server_with_env(vec![], &[("RUSTFS_SCANNER_ENABLED", "false"), ("RUSTFS_SCANNER_CYCLE", "3600")])
.await?;
let hot_client = hot.create_s3_client();
add_rustfs_tier(&hot, &cold).await?;
hot_client.create_bucket().bucket(MANUAL_CONTINUATION_BUCKET).send().await?;
for idx in 0..2 {
let key = format!("{MANUAL_CONTINUATION_PREFIX}obj-{idx:02}");
put_single_part_object(&hot_client, MANUAL_CONTINUATION_BUCKET, &key, b"manual continuation payload").await?;
}
put_lifecycle_transition_rule(
&hot_client,
MANUAL_CONTINUATION_BUCKET,
"manual-continuation",
MANUAL_CONTINUATION_PREFIX,
0,
)
.await?;
let first = manual_transition_run_with_max(&hot, MANUAL_CONTINUATION_BUCKET, MANUAL_CONTINUATION_PREFIX, true, 1).await?;
assert_eq!(first.state, "partial", "first continuation page: {first:#?}");
assert_eq!(first.mode, "enqueue_only");
assert_eq!(first.report.bucket, MANUAL_CONTINUATION_BUCKET);
assert_eq!(first.report.prefix, MANUAL_CONTINUATION_PREFIX);
assert!(first.report.dry_run);
assert_eq!(first.report.scanned, 1, "first continuation page: {first:#?}");
assert_eq!(first.report.eligible, 1, "first continuation page: {first:#?}");
assert_eq!(first.report.dry_run_eligible, 1, "first continuation page: {first:#?}");
assert!(first.report.truncated_by_limit);
let continuation = first
.report
.continuation_token
.as_deref()
.ok_or("partial manual transition run must return an opaque continuation token")?;
assert!(
!continuation.contains(MANUAL_CONTINUATION_PREFIX),
"continuation token must not expose the raw object prefix: {continuation}"
);
hot.restart_server_preserving_data(vec![], &[("RUSTFS_SCANNER_ENABLED", "false"), ("RUSTFS_SCANNER_CYCLE", "3600")])
.await?;
let second = manual_transition_run_with_max_and_continuation(
&hot,
MANUAL_CONTINUATION_BUCKET,
MANUAL_CONTINUATION_PREFIX,
true,
10,
Some(continuation),
)
.await?;
assert_eq!(second.state, "completed", "second continuation page: {second:#?}");
assert_eq!(second.report.scanned, 1, "second continuation page: {second:#?}");
assert_eq!(second.report.eligible, 1, "second continuation page: {second:#?}");
assert_eq!(second.report.dry_run_eligible, 1, "second continuation page: {second:#?}");
assert!(!second.report.truncated_by_limit);
assert!(second.report.continuation_token.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_manual_transition_run_queue_pressure_partial() -> TestResult {
let mut cold = RustFSTestEnvironment::new().await?;