mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-21 11:56:38 +00:00
feat(ilm): add durable manual transition jobs (#5223)
Refs #1479 Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
@@ -43,12 +43,12 @@ pub mod bucket {
|
|||||||
|
|
||||||
pub mod bucket_lifecycle_ops {
|
pub mod bucket_lifecycle_ops {
|
||||||
pub use crate::bucket::lifecycle::bucket_lifecycle_ops::{
|
pub use crate::bucket::lifecycle::bucket_lifecycle_ops::{
|
||||||
ExpiryState, LifecycleOps, ManualTransitionRunOptions, ManualTransitionRunReport, RestoreRequestOps,
|
ExpiryState, LifecycleOps, ManualTransitionRunExecution, ManualTransitionRunOptions, ManualTransitionRunReport,
|
||||||
TransitionState, TransitionedObject, apply_expiry_rule, apply_transition_rule,
|
RestoreRequestOps, TransitionState, TransitionedObject, apply_expiry_rule, apply_transition_rule,
|
||||||
enqueue_expiry_for_existing_objects, enqueue_transition_for_existing_objects,
|
enqueue_expiry_for_existing_objects, enqueue_transition_for_existing_objects,
|
||||||
enqueue_transition_for_existing_objects_scoped, enqueue_transition_immediate, expire_transitioned_object,
|
enqueue_transition_for_existing_objects_scoped, enqueue_transition_for_existing_objects_scoped_with_cancel,
|
||||||
get_global_expiry_state, get_global_transition_state, init_background_expiry, post_restore_opts,
|
enqueue_transition_immediate, expire_transitioned_object, get_global_expiry_state, get_global_transition_state,
|
||||||
run_stale_multipart_upload_cleanup_once, validate_transition_tier,
|
init_background_expiry, post_restore_opts, run_stale_multipart_upload_cleanup_once, validate_transition_tier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2445,7 +2445,7 @@ pub struct ManualTransitionRunOptions {
|
|||||||
pub max_duration: Option<std::time::Duration>,
|
pub max_duration: Option<std::time::Duration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct ManualTransitionRunReport {
|
pub struct ManualTransitionRunReport {
|
||||||
pub bucket: String,
|
pub bucket: String,
|
||||||
pub prefix: String,
|
pub prefix: String,
|
||||||
@@ -2474,6 +2474,12 @@ pub struct ManualTransitionRunReport {
|
|||||||
pub next_version_idmarker: Option<String>,
|
pub next_version_idmarker: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ManualTransitionRunExecution {
|
||||||
|
pub report: ManualTransitionRunReport,
|
||||||
|
pub cancelled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl ManualTransitionRunReport {
|
impl ManualTransitionRunReport {
|
||||||
fn new(bucket: &str, options: &ManualTransitionRunOptions) -> Self {
|
fn new(bucket: &str, options: &ManualTransitionRunOptions) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -2522,11 +2528,25 @@ pub async fn enqueue_transition_for_existing_objects_scoped(
|
|||||||
bucket: &str,
|
bucket: &str,
|
||||||
options: ManualTransitionRunOptions,
|
options: ManualTransitionRunOptions,
|
||||||
) -> Result<ManualTransitionRunReport, Error> {
|
) -> Result<ManualTransitionRunReport, Error> {
|
||||||
|
Ok(enqueue_transition_for_existing_objects_scoped_with_cancel(api, bucket, options, None)
|
||||||
|
.await?
|
||||||
|
.report)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn enqueue_transition_for_existing_objects_scoped_with_cancel(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
bucket: &str,
|
||||||
|
options: ManualTransitionRunOptions,
|
||||||
|
cancel_token: Option<CancellationToken>,
|
||||||
|
) -> Result<ManualTransitionRunExecution, Error> {
|
||||||
const LIST_PAGE_SIZE: i32 = 1000;
|
const LIST_PAGE_SIZE: i32 = 1000;
|
||||||
|
|
||||||
let mut report = ManualTransitionRunReport::new(bucket, &options);
|
let mut report = ManualTransitionRunReport::new(bucket, &options);
|
||||||
let Some(lc) = runtime_sources::bucket_lifecycle_config(bucket).await else {
|
let Some(lc) = runtime_sources::bucket_lifecycle_config(bucket).await else {
|
||||||
return Ok(report);
|
return Ok(ManualTransitionRunExecution {
|
||||||
|
report,
|
||||||
|
cancelled: false,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
report.lifecycle_config_found = true;
|
report.lifecycle_config_found = true;
|
||||||
let mut marker = options.marker.clone();
|
let mut marker = options.marker.clone();
|
||||||
@@ -2537,24 +2557,39 @@ pub async fn enqueue_transition_for_existing_objects_scoped(
|
|||||||
let deadline = options.max_duration.map(|duration| tokio::time::Instant::now() + duration);
|
let deadline = options.max_duration.map(|duration| tokio::time::Instant::now() + duration);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
if manual_transition_cancelled(cancel_token.as_ref()) {
|
||||||
|
return Ok(ManualTransitionRunExecution { report, cancelled: true });
|
||||||
|
}
|
||||||
|
|
||||||
let page = api
|
let page = api
|
||||||
.clone()
|
.clone()
|
||||||
.list_object_versions(bucket, &options.prefix, marker.clone(), version_marker.clone(), None, LIST_PAGE_SIZE)
|
.list_object_versions(bucket, &options.prefix, marker.clone(), version_marker.clone(), None, LIST_PAGE_SIZE)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for (index, object) in page.objects.iter().enumerate() {
|
for (index, object) in page.objects.iter().enumerate() {
|
||||||
|
if manual_transition_cancelled(cancel_token.as_ref()) {
|
||||||
|
report.next_marker.clone_from(&previous_marker);
|
||||||
|
report.next_version_idmarker.clone_from(&previous_version_marker);
|
||||||
|
return Ok(ManualTransitionRunExecution { report, cancelled: true });
|
||||||
|
}
|
||||||
if manual_transition_duration_elapsed(deadline) {
|
if manual_transition_duration_elapsed(deadline) {
|
||||||
report.truncated_by_duration = true;
|
report.truncated_by_duration = true;
|
||||||
report.next_marker.clone_from(&previous_marker);
|
report.next_marker.clone_from(&previous_marker);
|
||||||
report.next_version_idmarker.clone_from(&previous_version_marker);
|
report.next_version_idmarker.clone_from(&previous_version_marker);
|
||||||
return Ok(report);
|
return Ok(ManualTransitionRunExecution {
|
||||||
|
report,
|
||||||
|
cancelled: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
report.scanned = report.scanned.saturating_add(1);
|
report.scanned = report.scanned.saturating_add(1);
|
||||||
enqueue_transition_with_lifecycle_report(object, &lc, &src, &options, &mut report).await;
|
enqueue_transition_with_lifecycle_report(object, &lc, &src, &options, &mut report).await;
|
||||||
if report.has_partial_enqueue() {
|
if report.has_partial_enqueue() {
|
||||||
report.next_marker.clone_from(&previous_marker);
|
report.next_marker.clone_from(&previous_marker);
|
||||||
report.next_version_idmarker.clone_from(&previous_version_marker);
|
report.next_version_idmarker.clone_from(&previous_version_marker);
|
||||||
return Ok(report);
|
return Ok(ManualTransitionRunExecution {
|
||||||
|
report,
|
||||||
|
cancelled: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if options.max_objects.is_some_and(|max_objects| report.scanned >= max_objects) {
|
if options.max_objects.is_some_and(|max_objects| report.scanned >= max_objects) {
|
||||||
if manual_transition_has_more_after_limit(index, page.objects.len(), page.is_truncated) {
|
if manual_transition_has_more_after_limit(index, page.objects.len(), page.is_truncated) {
|
||||||
@@ -2562,20 +2597,29 @@ pub async fn enqueue_transition_for_existing_objects_scoped(
|
|||||||
report.next_marker = Some(object.name.clone());
|
report.next_marker = Some(object.name.clone());
|
||||||
report.next_version_idmarker = Some(manual_transition_version_marker(object));
|
report.next_version_idmarker = Some(manual_transition_version_marker(object));
|
||||||
}
|
}
|
||||||
return Ok(report);
|
return Ok(ManualTransitionRunExecution {
|
||||||
|
report,
|
||||||
|
cancelled: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
previous_marker = Some(object.name.clone());
|
previous_marker = Some(object.name.clone());
|
||||||
previous_version_marker = Some(manual_transition_version_marker(object));
|
previous_version_marker = Some(manual_transition_version_marker(object));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !page.is_truncated {
|
if !page.is_truncated {
|
||||||
return Ok(report);
|
return Ok(ManualTransitionRunExecution {
|
||||||
|
report,
|
||||||
|
cancelled: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if manual_transition_duration_elapsed(deadline) {
|
if manual_transition_duration_elapsed(deadline) {
|
||||||
report.truncated_by_duration = true;
|
report.truncated_by_duration = true;
|
||||||
report.next_marker.clone_from(&previous_marker);
|
report.next_marker.clone_from(&previous_marker);
|
||||||
report.next_version_idmarker.clone_from(&previous_version_marker);
|
report.next_version_idmarker.clone_from(&previous_version_marker);
|
||||||
return Ok(report);
|
return Ok(ManualTransitionRunExecution {
|
||||||
|
report,
|
||||||
|
cancelled: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
marker = page.next_marker;
|
marker = page.next_marker;
|
||||||
@@ -2779,6 +2823,10 @@ fn manual_transition_duration_elapsed(deadline: Option<tokio::time::Instant>) ->
|
|||||||
deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
|
deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn manual_transition_cancelled(cancel_token: Option<&CancellationToken>) -> bool {
|
||||||
|
cancel_token.is_some_and(CancellationToken::is_cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
fn manual_transition_version_marker(oi: &ObjectInfo) -> String {
|
fn manual_transition_version_marker(oi: &ObjectInfo) -> String {
|
||||||
oi.version_id
|
oi.version_id
|
||||||
.map(|version| version.to_string())
|
.map(|version| version.to_string())
|
||||||
@@ -3694,7 +3742,7 @@ mod tests {
|
|||||||
enqueue_recovered_free_version_with_state, enqueue_transition_with_lifecycle, enqueue_transition_with_lifecycle_report,
|
enqueue_recovered_free_version_with_state, enqueue_transition_with_lifecycle, enqueue_transition_with_lifecycle_report,
|
||||||
eval_action_from_lifecycle, jitter_tier_free_version_recovery_delay, lifecycle_action_blocked_by_replication,
|
eval_action_from_lifecycle, jitter_tier_free_version_recovery_delay, lifecycle_action_blocked_by_replication,
|
||||||
lifecycle_delete_all_versions_replication_scan, lifecycle_deleted_object, lifecycle_replication_blocks_action,
|
lifecycle_delete_all_versions_replication_scan, lifecycle_deleted_object, lifecycle_replication_blocks_action,
|
||||||
lifecycle_rule_has_date_expiration, lifecycle_version_purge_state_from_completed_targets,
|
lifecycle_rule_has_date_expiration, lifecycle_version_purge_state_from_completed_targets, manual_transition_cancelled,
|
||||||
manual_transition_duration_elapsed, manual_transition_has_more_after_limit, manual_transition_version_marker,
|
manual_transition_duration_elapsed, manual_transition_has_more_after_limit, manual_transition_version_marker,
|
||||||
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate, replication_state_for_delete,
|
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate, replication_state_for_delete,
|
||||||
resolve_transition_queue_capacity, resolve_transition_queue_send_timeout, resolve_transition_worker_count,
|
resolve_transition_queue_capacity, resolve_transition_queue_send_timeout, resolve_transition_worker_count,
|
||||||
@@ -6446,6 +6494,18 @@ mod tests {
|
|||||||
assert!(report.was_truncated());
|
assert!(report.was_truncated());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_transition_cancel_token_detects_cancelled_state() {
|
||||||
|
let cancel_token = CancellationToken::new();
|
||||||
|
|
||||||
|
assert!(!manual_transition_cancelled(None));
|
||||||
|
assert!(!manual_transition_cancelled(Some(&cancel_token)));
|
||||||
|
|
||||||
|
cancel_token.cancel();
|
||||||
|
|
||||||
|
assert!(manual_transition_cancelled(Some(&cancel_token)));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn existing_object_lifecycle_allows_expired_marker_after_replication_completed() {
|
async fn existing_object_lifecycle_allows_expired_marker_after_replication_completed() {
|
||||||
let lc = expired_delete_marker_lifecycle();
|
let lc = expired_delete_marker_lifecycle();
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ use crate::admin::auth::validate_admin_request;
|
|||||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||||
use crate::admin::runtime_sources::object_store_from_extensions;
|
use crate::admin::runtime_sources::object_store_from_extensions;
|
||||||
use crate::admin::storage_api::bucket::is_reserved_or_invalid_bucket;
|
use crate::admin::storage_api::bucket::is_reserved_or_invalid_bucket;
|
||||||
|
use crate::admin::storage_api::config::{read_admin_config, save_admin_config};
|
||||||
|
use crate::admin::storage_api::error::StorageError;
|
||||||
use crate::admin::storage_api::lifecycle::{
|
use crate::admin::storage_api::lifecycle::{
|
||||||
ManualTransitionRunOptions, ManualTransitionRunReport, enqueue_transition_for_existing_objects_scoped,
|
ManualTransitionRunOptions, ManualTransitionRunReport, enqueue_transition_for_existing_objects_scoped,
|
||||||
|
enqueue_transition_for_existing_objects_scoped_with_cancel,
|
||||||
};
|
};
|
||||||
|
use crate::admin::storage_api::runtime::ECStore;
|
||||||
use crate::auth::{check_key_valid, get_session_token};
|
use crate::auth::{check_key_valid, get_session_token};
|
||||||
use crate::server::{ADMIN_PREFIX, RemoteAddr};
|
use crate::server::{ADMIN_PREFIX, RemoteAddr};
|
||||||
use http::{HeaderMap, HeaderValue};
|
use http::{HeaderMap, HeaderValue};
|
||||||
@@ -32,8 +36,13 @@ use rustfs_utils::{
|
|||||||
use s3s::header::CONTENT_TYPE;
|
use s3s::header::CONTENT_TYPE;
|
||||||
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
|
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
const JSON_CONTENT_TYPE: &str = "application/json";
|
const JSON_CONTENT_TYPE: &str = "application/json";
|
||||||
const DEFAULT_MANUAL_TRANSITION_MAX_OBJECTS: u64 = 10_000;
|
const DEFAULT_MANUAL_TRANSITION_MAX_OBJECTS: u64 = 10_000;
|
||||||
@@ -42,8 +51,11 @@ const MAX_MANUAL_TRANSITION_DURATION_SECONDS: u64 = 3600;
|
|||||||
const LOG_COMPONENT_ADMIN: &str = "admin";
|
const LOG_COMPONENT_ADMIN: &str = "admin";
|
||||||
const LOG_SUBSYSTEM_ILM_TRANSITION: &str = "ilm_transition";
|
const LOG_SUBSYSTEM_ILM_TRANSITION: &str = "ilm_transition";
|
||||||
const EVENT_ADMIN_ILM_TRANSITION_STATE: &str = "admin_ilm_transition_state";
|
const EVENT_ADMIN_ILM_TRANSITION_STATE: &str = "admin_ilm_transition_state";
|
||||||
|
const MANUAL_TRANSITION_JOB_SCHEMA_VERSION: u8 = 1;
|
||||||
|
const MANUAL_TRANSITION_JOB_CONFIG_PREFIX: &str = "ilm/transition/jobs";
|
||||||
|
|
||||||
static ACTIVE_MANUAL_TRANSITION_SCOPES: OnceLock<Mutex<Vec<ManualTransitionRunScope>>> = OnceLock::new();
|
static ACTIVE_MANUAL_TRANSITION_SCOPES: OnceLock<Mutex<Vec<ManualTransitionRunScope>>> = OnceLock::new();
|
||||||
|
static MANUAL_TRANSITION_JOBS: OnceLock<Mutex<HashMap<String, ManualTransitionJobRuntime>>> = OnceLock::new();
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct ManualTransitionRunScope {
|
struct ManualTransitionRunScope {
|
||||||
@@ -139,6 +151,21 @@ struct ManualTransitionRunQuery {
|
|||||||
max_duration_seconds: Option<u64>,
|
max_duration_seconds: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ManualTransitionRunMode {
|
||||||
|
EnqueueOnly,
|
||||||
|
Async,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManualTransitionRunMode {
|
||||||
|
fn response_mode(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::EnqueueOnly => "enqueue_only",
|
||||||
|
Self::Async => "durable_job",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct ManualTransitionRunResponse {
|
struct ManualTransitionRunResponse {
|
||||||
state: &'static str,
|
state: &'static str,
|
||||||
@@ -148,16 +175,71 @@ struct ManualTransitionRunResponse {
|
|||||||
report: ManualTransitionRunReport,
|
report: ManualTransitionRunReport,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ManualTransitionJobRuntime {
|
||||||
|
record: ManualTransitionJobRecord,
|
||||||
|
cancel_token: CancellationToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum ManualTransitionJobStatus {
|
||||||
|
Queued,
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
Partial,
|
||||||
|
Cancelled,
|
||||||
|
Failed,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManualTransitionJobStatus {
|
||||||
|
fn is_terminal(self) -> bool {
|
||||||
|
matches!(self, Self::Completed | Self::Partial | Self::Cancelled | Self::Failed | Self::Unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct ManualTransitionJobRecord {
|
||||||
|
schema_version: u8,
|
||||||
|
job_id: String,
|
||||||
|
status_endpoint: String,
|
||||||
|
status: ManualTransitionJobStatus,
|
||||||
|
bucket: String,
|
||||||
|
prefix: String,
|
||||||
|
tier: Option<String>,
|
||||||
|
dry_run: bool,
|
||||||
|
max_objects: Option<u64>,
|
||||||
|
max_duration_seconds: Option<u64>,
|
||||||
|
created_at: String,
|
||||||
|
started_at: Option<String>,
|
||||||
|
finished_at: Option<String>,
|
||||||
|
cancel_requested: bool,
|
||||||
|
failure_reason: Option<String>,
|
||||||
|
report: ManualTransitionRunReport,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn register_ilm_transition_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
pub fn register_ilm_transition_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
||||||
r.insert(
|
r.insert(
|
||||||
Method::POST,
|
Method::POST,
|
||||||
format!("{ADMIN_PREFIX}/v3/ilm/transition/run").as_str(),
|
format!("{ADMIN_PREFIX}/v3/ilm/transition/run").as_str(),
|
||||||
AdminOperation(&ManualTransitionRunHandler {}),
|
AdminOperation(&ManualTransitionRunHandler {}),
|
||||||
)?;
|
)?;
|
||||||
|
r.insert(
|
||||||
|
Method::GET,
|
||||||
|
format!("{ADMIN_PREFIX}/v3/ilm/transition/jobs/{{job_id}}").as_str(),
|
||||||
|
AdminOperation(&ManualTransitionJobStatusHandler {}),
|
||||||
|
)?;
|
||||||
|
r.insert(
|
||||||
|
Method::DELETE,
|
||||||
|
format!("{ADMIN_PREFIX}/v3/ilm/transition/jobs/{{job_id}}").as_str(),
|
||||||
|
AdminOperation(&ManualTransitionJobCancelHandler {}),
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_manual_transition_query(query: Option<&str>) -> S3Result<(String, ManualTransitionRunOptions)> {
|
fn parse_manual_transition_query(query: Option<&str>) -> S3Result<(String, ManualTransitionRunOptions, ManualTransitionRunMode)> {
|
||||||
let query: ManualTransitionRunQuery = match query {
|
let query: ManualTransitionRunQuery = match query {
|
||||||
Some(query) => serde_urlencoded::from_bytes(query.as_bytes())
|
Some(query) => serde_urlencoded::from_bytes(query.as_bytes())
|
||||||
.map_err(|_| s3_error!(InvalidArgument, "invalid manual transition query"))?,
|
.map_err(|_| s3_error!(InvalidArgument, "invalid manual transition query"))?,
|
||||||
@@ -184,12 +266,11 @@ fn parse_manual_transition_query(query: Option<&str>) -> S3Result<(String, Manua
|
|||||||
if mode.is_some_and(|mode| mode != "enqueue_only" && mode != "async") {
|
if mode.is_some_and(|mode| mode != "enqueue_only" && mode != "async") {
|
||||||
return Err(s3_error!(InvalidArgument, "unsupported manual transition mode"));
|
return Err(s3_error!(InvalidArgument, "unsupported manual transition mode"));
|
||||||
}
|
}
|
||||||
if query.async_mode == Some(true) || mode == Some("async") {
|
let run_mode = if query.async_mode == Some(true) || mode == Some("async") {
|
||||||
return Err(S3Error::with_message(
|
ManualTransitionRunMode::Async
|
||||||
S3ErrorCode::NotImplemented,
|
} else {
|
||||||
"durable manual transition jobs are not implemented; omit async/mode for enqueue_only",
|
ManualTransitionRunMode::EnqueueOnly
|
||||||
));
|
};
|
||||||
}
|
|
||||||
|
|
||||||
let max_objects = query.max_objects.unwrap_or(DEFAULT_MANUAL_TRANSITION_MAX_OBJECTS);
|
let max_objects = query.max_objects.unwrap_or(DEFAULT_MANUAL_TRANSITION_MAX_OBJECTS);
|
||||||
if max_objects == 0 || max_objects > MAX_MANUAL_TRANSITION_OBJECTS {
|
if max_objects == 0 || max_objects > MAX_MANUAL_TRANSITION_OBJECTS {
|
||||||
@@ -213,6 +294,7 @@ fn parse_manual_transition_query(query: Option<&str>) -> S3Result<(String, Manua
|
|||||||
max_objects: Some(max_objects),
|
max_objects: Some(max_objects),
|
||||||
max_duration: query.max_duration_seconds.map(std::time::Duration::from_secs),
|
max_duration: query.max_duration_seconds.map(std::time::Duration::from_secs),
|
||||||
},
|
},
|
||||||
|
run_mode,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +423,7 @@ fn response_state(report: &ManualTransitionRunReport) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn json_response(response: &ManualTransitionRunResponse) -> S3Result<S3Response<(StatusCode, Body)>> {
|
fn json_response<T: Serialize>(status: StatusCode, response: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
let body = serde_json::to_vec(response).map_err(|err| {
|
let body = serde_json::to_vec(response).map_err(|err| {
|
||||||
S3Error::with_message(S3ErrorCode::InternalError, format!("failed to encode manual transition response: {err}"))
|
S3Error::with_message(S3ErrorCode::InternalError, format!("failed to encode manual transition response: {err}"))
|
||||||
})?;
|
})?;
|
||||||
@@ -349,7 +431,223 @@ fn json_response(response: &ManualTransitionRunResponse) -> S3Result<S3Response<
|
|||||||
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid content type: {err}")))?;
|
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid content type: {err}")))?;
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(CONTENT_TYPE, content_type);
|
headers.insert(CONTENT_TYPE, content_type);
|
||||||
Ok(S3Response::with_headers((StatusCode::OK, Body::from(body)), headers))
|
Ok(S3Response::with_headers((status, Body::from(body)), headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manual_transition_job_registry() -> &'static Mutex<HashMap<String, ManualTransitionJobRuntime>> {
|
||||||
|
MANUAL_TRANSITION_JOBS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_manual_transition_jobs() -> MutexGuard<'static, HashMap<String, ManualTransitionJobRuntime>> {
|
||||||
|
match manual_transition_job_registry().lock() {
|
||||||
|
Ok(jobs) => jobs,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manual_transition_job_status_endpoint(job_id: &str) -> String {
|
||||||
|
format!("{ADMIN_PREFIX}/v3/ilm/transition/jobs/{job_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manual_transition_job_config_path(job_id: &str) -> String {
|
||||||
|
format!("{MANUAL_TRANSITION_JOB_CONFIG_PREFIX}/{job_id}.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_manual_transition_job_id(job_id: &str) -> S3Result<String> {
|
||||||
|
Uuid::parse_str(job_id).map_err(|_| s3_error!(InvalidArgument, "invalid manual transition job id"))?;
|
||||||
|
Ok(job_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manual_transition_timestamp(now: OffsetDateTime) -> String {
|
||||||
|
now.format(&Rfc3339).unwrap_or_else(|_| now.unix_timestamp().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_manual_transition_report(bucket: &str, options: &ManualTransitionRunOptions) -> ManualTransitionRunReport {
|
||||||
|
ManualTransitionRunReport {
|
||||||
|
bucket: bucket.to_string(),
|
||||||
|
prefix: options.prefix.clone(),
|
||||||
|
tier: options.tier.clone(),
|
||||||
|
dry_run: options.dry_run,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manual_transition_status_from_report(report: &ManualTransitionRunReport) -> ManualTransitionJobStatus {
|
||||||
|
if report.was_truncated() || report.has_partial_enqueue() {
|
||||||
|
ManualTransitionJobStatus::Partial
|
||||||
|
} else {
|
||||||
|
ManualTransitionJobStatus::Completed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_manual_transition_job_record(
|
||||||
|
job_id: String,
|
||||||
|
bucket: &str,
|
||||||
|
options: &ManualTransitionRunOptions,
|
||||||
|
created_at: OffsetDateTime,
|
||||||
|
) -> ManualTransitionJobRecord {
|
||||||
|
ManualTransitionJobRecord {
|
||||||
|
schema_version: MANUAL_TRANSITION_JOB_SCHEMA_VERSION,
|
||||||
|
status_endpoint: manual_transition_job_status_endpoint(&job_id),
|
||||||
|
job_id,
|
||||||
|
status: ManualTransitionJobStatus::Queued,
|
||||||
|
bucket: bucket.to_string(),
|
||||||
|
prefix: options.prefix.clone(),
|
||||||
|
tier: options.tier.clone(),
|
||||||
|
dry_run: options.dry_run,
|
||||||
|
max_objects: options.max_objects,
|
||||||
|
max_duration_seconds: options.max_duration.map(|duration| duration.as_secs()),
|
||||||
|
created_at: manual_transition_timestamp(created_at),
|
||||||
|
started_at: None,
|
||||||
|
finished_at: None,
|
||||||
|
cancel_requested: false,
|
||||||
|
failure_reason: None,
|
||||||
|
report: initial_manual_transition_report(bucket, options),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_manual_transition_job(record: ManualTransitionJobRecord, cancel_token: CancellationToken) {
|
||||||
|
let mut jobs = lock_manual_transition_jobs();
|
||||||
|
jobs.insert(record.job_id.clone(), ManualTransitionJobRuntime { record, cancel_token });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_manual_transition_job_record(
|
||||||
|
job_id: &str,
|
||||||
|
update: impl FnOnce(&mut ManualTransitionJobRecord),
|
||||||
|
) -> Option<ManualTransitionJobRecord> {
|
||||||
|
let mut jobs = lock_manual_transition_jobs();
|
||||||
|
let runtime = jobs.get_mut(job_id)?;
|
||||||
|
update(&mut runtime.record);
|
||||||
|
Some(runtime.record.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_memory_manual_transition_job_record(job_id: &str) -> Option<ManualTransitionJobRecord> {
|
||||||
|
lock_manual_transition_jobs()
|
||||||
|
.get(job_id)
|
||||||
|
.map(|runtime| runtime.record.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_manual_transition_job_cancel(job_id: &str) -> Option<ManualTransitionJobRecord> {
|
||||||
|
let mut jobs = lock_manual_transition_jobs();
|
||||||
|
let runtime = jobs.get_mut(job_id)?;
|
||||||
|
if !runtime.record.status.is_terminal() {
|
||||||
|
runtime.record.cancel_requested = true;
|
||||||
|
runtime.cancel_token.cancel();
|
||||||
|
}
|
||||||
|
Some(runtime.record.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_manual_transition_job(job_id: &str) {
|
||||||
|
let mut jobs = lock_manual_transition_jobs();
|
||||||
|
jobs.remove(job_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_manual_transition_job_record(store: Arc<ECStore>, record: &ManualTransitionJobRecord) -> S3Result<()> {
|
||||||
|
let data = serde_json::to_vec(record).map_err(|err| {
|
||||||
|
S3Error::with_message(S3ErrorCode::InternalError, format!("failed to encode manual transition job: {err}"))
|
||||||
|
})?;
|
||||||
|
save_admin_config(store, &manual_transition_job_config_path(&record.job_id), data)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
S3Error::with_message(S3ErrorCode::InternalError, format!("failed to persist manual transition job: {err}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_manual_transition_job_record(store: Arc<ECStore>, job_id: &str) -> S3Result<Option<ManualTransitionJobRecord>> {
|
||||||
|
match read_admin_config(store, &manual_transition_job_config_path(job_id)).await {
|
||||||
|
Ok(data) => serde_json::from_slice(&data).map(Some).map_err(|err| {
|
||||||
|
S3Error::with_message(S3ErrorCode::InternalError, format!("failed to decode manual transition job: {err}"))
|
||||||
|
}),
|
||||||
|
Err(StorageError::ConfigNotFound) => Ok(None),
|
||||||
|
Err(err) => Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::InternalError,
|
||||||
|
format!("failed to read manual transition job: {err}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_manual_transition_job_record(store: Arc<ECStore>, job_id: &str) -> S3Result<ManualTransitionJobRecord> {
|
||||||
|
if let Some(record) = in_memory_manual_transition_job_record(job_id) {
|
||||||
|
return Ok(record);
|
||||||
|
}
|
||||||
|
let Some(mut record) = read_manual_transition_job_record(store, job_id).await? else {
|
||||||
|
return Err(s3_error!(NoSuchKey, "manual transition job not found"));
|
||||||
|
};
|
||||||
|
if !record.status.is_terminal() {
|
||||||
|
record.status = ManualTransitionJobStatus::Unknown;
|
||||||
|
record.finished_at = Some(manual_transition_timestamp(OffsetDateTime::now_utc()));
|
||||||
|
record.failure_reason = Some("manual transition job owner is not active on this node".to_string());
|
||||||
|
}
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_manual_transition_job_update(store: Arc<ECStore>, record: &ManualTransitionJobRecord) -> bool {
|
||||||
|
if let Err(err) = save_manual_transition_job_record(store, record).await {
|
||||||
|
error!(
|
||||||
|
event = EVENT_ADMIN_ILM_TRANSITION_STATE,
|
||||||
|
component = LOG_COMPONENT_ADMIN,
|
||||||
|
subsystem = LOG_SUBSYSTEM_ILM_TRANSITION,
|
||||||
|
operation = "manual_transition_job",
|
||||||
|
job_id = %record.job_id,
|
||||||
|
error = %err,
|
||||||
|
"failed to persist manual transition job update"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_manual_transition_job(
|
||||||
|
store: Arc<ECStore>,
|
||||||
|
bucket: String,
|
||||||
|
options: ManualTransitionRunOptions,
|
||||||
|
job_id: String,
|
||||||
|
cancel_token: CancellationToken,
|
||||||
|
admission_guard: ManualTransitionRunAdmission,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let started_record = update_manual_transition_job_record(&job_id, |record| {
|
||||||
|
record.status = ManualTransitionJobStatus::Running;
|
||||||
|
record.started_at = Some(manual_transition_timestamp(OffsetDateTime::now_utc()));
|
||||||
|
});
|
||||||
|
if let Some(record) = started_record.as_ref() {
|
||||||
|
persist_manual_transition_job_update(store.clone(), record).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = enqueue_transition_for_existing_objects_scoped_with_cancel(
|
||||||
|
store.clone(),
|
||||||
|
&bucket,
|
||||||
|
options,
|
||||||
|
Some(cancel_token.clone()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let finished_at = manual_transition_timestamp(OffsetDateTime::now_utc());
|
||||||
|
let finished_record = update_manual_transition_job_record(&job_id, |record| {
|
||||||
|
record.finished_at = Some(finished_at);
|
||||||
|
match result {
|
||||||
|
Ok(execution) => {
|
||||||
|
record.report = execution.report;
|
||||||
|
record.status = if execution.cancelled || record.cancel_requested || cancel_token.is_cancelled() {
|
||||||
|
ManualTransitionJobStatus::Cancelled
|
||||||
|
} else {
|
||||||
|
manual_transition_status_from_report(&record.report)
|
||||||
|
};
|
||||||
|
record.failure_reason = None;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
record.status = ManualTransitionJobStatus::Failed;
|
||||||
|
record.failure_reason = Some(err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(record) = finished_record.as_ref()
|
||||||
|
&& persist_manual_transition_job_update(store, record).await
|
||||||
|
&& record.status.is_terminal()
|
||||||
|
{
|
||||||
|
remove_manual_transition_job(&job_id);
|
||||||
|
}
|
||||||
|
drop(admission_guard);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ManualTransitionRunHandler {}
|
pub struct ManualTransitionRunHandler {}
|
||||||
@@ -360,7 +658,7 @@ impl Operation for ManualTransitionRunHandler {
|
|||||||
let request_id = admin_request_id(&req.headers).unwrap_or_default().to_string();
|
let request_id = admin_request_id(&req.headers).unwrap_or_default().to_string();
|
||||||
let remote_addr = admin_remote_addr(&req).unwrap_or_default();
|
let remote_addr = admin_remote_addr(&req).unwrap_or_default();
|
||||||
let actor = authorize_manual_transition_request(&req).await?;
|
let actor = authorize_manual_transition_request(&req).await?;
|
||||||
let (bucket, options) = match parse_manual_transition_query(req.uri.query()) {
|
let (bucket, options, run_mode) = match parse_manual_transition_query(req.uri.query()) {
|
||||||
Ok(parsed) => parsed,
|
Ok(parsed) => parsed,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log_manual_transition_rejected("invalid_query_parameters", &request_id, &actor, &remote_addr);
|
log_manual_transition_rejected("invalid_query_parameters", &request_id, &actor, &remote_addr);
|
||||||
@@ -374,7 +672,7 @@ impl Operation for ManualTransitionRunHandler {
|
|||||||
let max_objects = options.max_objects;
|
let max_objects = options.max_objects;
|
||||||
let max_duration_seconds = options.max_duration.map(|duration| duration.as_secs());
|
let max_duration_seconds = options.max_duration.map(|duration| duration.as_secs());
|
||||||
let scope = ManualTransitionRunScope::new(&bucket, &options);
|
let scope = ManualTransitionRunScope::new(&bucket, &options);
|
||||||
let _admission = match acquire_manual_transition_admission(scope) {
|
let admission = match acquire_manual_transition_admission(scope) {
|
||||||
Ok(admission) => admission,
|
Ok(admission) => admission,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log_manual_transition_rejected("already_running", &request_id, &actor, &remote_addr);
|
log_manual_transition_rejected("already_running", &request_id, &actor, &remote_addr);
|
||||||
@@ -382,6 +680,24 @@ impl Operation for ManualTransitionRunHandler {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if run_mode == ManualTransitionRunMode::Async {
|
||||||
|
let job_id = Uuid::new_v4().to_string();
|
||||||
|
let record = new_manual_transition_job_record(job_id.clone(), &bucket, &options, OffsetDateTime::now_utc());
|
||||||
|
save_manual_transition_job_record(store.clone(), &record).await?;
|
||||||
|
let cancel_token = CancellationToken::new();
|
||||||
|
insert_manual_transition_job(record.clone(), cancel_token.clone());
|
||||||
|
spawn_manual_transition_job(store, bucket, options, job_id.clone(), cancel_token, admission);
|
||||||
|
let response = ManualTransitionRunResponse {
|
||||||
|
state: "accepted",
|
||||||
|
mode: run_mode.response_mode(),
|
||||||
|
job_id: Some(job_id),
|
||||||
|
status_endpoint: Some(record.status_endpoint),
|
||||||
|
report: record.report,
|
||||||
|
};
|
||||||
|
|
||||||
|
return json_response(StatusCode::ACCEPTED, &response);
|
||||||
|
}
|
||||||
|
|
||||||
let report = match enqueue_transition_for_existing_objects_scoped(store, &bucket, options).await {
|
let report = match enqueue_transition_for_existing_objects_scoped(store, &bucket, options).await {
|
||||||
Ok(report) => report,
|
Ok(report) => report,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -402,7 +718,59 @@ impl Operation for ManualTransitionRunHandler {
|
|||||||
report,
|
report,
|
||||||
};
|
};
|
||||||
|
|
||||||
json_response(&response)
|
json_response(StatusCode::OK, &response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ManualTransitionJobStatusHandler {}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Operation for ManualTransitionJobStatusHandler {
|
||||||
|
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
|
let _actor = authorize_manual_transition_request(&req).await?;
|
||||||
|
let job_id = parse_manual_transition_job_id(
|
||||||
|
params
|
||||||
|
.get("job_id")
|
||||||
|
.ok_or_else(|| s3_error!(InvalidRequest, "manual transition job id is required"))?,
|
||||||
|
)?;
|
||||||
|
let Some(store) = object_store_from_extensions(&req.extensions) else {
|
||||||
|
return Err(s3_error!(InternalError, "object store is not initialized"));
|
||||||
|
};
|
||||||
|
let record = load_manual_transition_job_record(store, &job_id).await?;
|
||||||
|
json_response(StatusCode::OK, &record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ManualTransitionJobCancelHandler {}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Operation for ManualTransitionJobCancelHandler {
|
||||||
|
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
|
let _actor = authorize_manual_transition_request(&req).await?;
|
||||||
|
let job_id = parse_manual_transition_job_id(
|
||||||
|
params
|
||||||
|
.get("job_id")
|
||||||
|
.ok_or_else(|| s3_error!(InvalidRequest, "manual transition job id is required"))?,
|
||||||
|
)?;
|
||||||
|
let Some(store) = object_store_from_extensions(&req.extensions) else {
|
||||||
|
return Err(s3_error!(InternalError, "object store is not initialized"));
|
||||||
|
};
|
||||||
|
let record = if let Some(record) = request_manual_transition_job_cancel(&job_id) {
|
||||||
|
record
|
||||||
|
} else {
|
||||||
|
let Some(mut record) = read_manual_transition_job_record(store.clone(), &job_id).await? else {
|
||||||
|
return Err(s3_error!(NoSuchKey, "manual transition job not found"));
|
||||||
|
};
|
||||||
|
if !record.status.is_terminal() {
|
||||||
|
record.status = ManualTransitionJobStatus::Unknown;
|
||||||
|
record.cancel_requested = true;
|
||||||
|
record.finished_at = Some(manual_transition_timestamp(OffsetDateTime::now_utc()));
|
||||||
|
record.failure_reason = Some("manual transition job owner is not active on this node".to_string());
|
||||||
|
save_manual_transition_job_record(store, &record).await?;
|
||||||
|
}
|
||||||
|
record
|
||||||
|
};
|
||||||
|
json_response(StatusCode::OK, &record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,11 +780,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_transition_query_defaults_to_bounded_run() {
|
fn manual_transition_query_defaults_to_bounded_run() {
|
||||||
let (bucket, options) =
|
let (bucket, options, run_mode) =
|
||||||
parse_manual_transition_query(Some("bucket=data&prefix=logs/&marker=logs/a&versionMarker=v1&tier=warm"))
|
parse_manual_transition_query(Some("bucket=data&prefix=logs/&marker=logs/a&versionMarker=v1&tier=warm"))
|
||||||
.expect("valid query should parse");
|
.expect("valid query should parse");
|
||||||
|
|
||||||
assert_eq!(bucket, "data");
|
assert_eq!(bucket, "data");
|
||||||
|
assert_eq!(run_mode, ManualTransitionRunMode::EnqueueOnly);
|
||||||
assert_eq!(options.prefix, "logs/");
|
assert_eq!(options.prefix, "logs/");
|
||||||
assert_eq!(options.marker.as_deref(), Some("logs/a"));
|
assert_eq!(options.marker.as_deref(), Some("logs/a"));
|
||||||
assert_eq!(options.version_marker.as_deref(), Some("v1"));
|
assert_eq!(options.version_marker.as_deref(), Some("v1"));
|
||||||
@@ -428,32 +797,34 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_transition_query_accepts_duration_budget() {
|
fn manual_transition_query_accepts_duration_budget() {
|
||||||
let (_bucket, options) =
|
let (_bucket, options, run_mode) =
|
||||||
parse_manual_transition_query(Some("bucket=data&maxDurationSeconds=30")).expect("valid query should parse");
|
parse_manual_transition_query(Some("bucket=data&maxDurationSeconds=30")).expect("valid query should parse");
|
||||||
|
|
||||||
|
assert_eq!(run_mode, ManualTransitionRunMode::EnqueueOnly);
|
||||||
assert_eq!(options.max_duration, Some(std::time::Duration::from_secs(30)));
|
assert_eq!(options.max_duration, Some(std::time::Duration::from_secs(30)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_transition_query_accepts_explicit_enqueue_only_mode() {
|
fn manual_transition_query_accepts_explicit_enqueue_only_mode() {
|
||||||
let (_bucket, options) = parse_manual_transition_query(Some("bucket=data&mode=enqueue_only&async=false"))
|
let (_bucket, options, run_mode) = parse_manual_transition_query(Some("bucket=data&mode=enqueue_only&async=false"))
|
||||||
.expect("explicit enqueue_only mode should remain compatible");
|
.expect("explicit enqueue_only mode should remain compatible");
|
||||||
|
|
||||||
|
assert_eq!(run_mode, ManualTransitionRunMode::EnqueueOnly);
|
||||||
assert!(!options.dry_run);
|
assert!(!options.dry_run);
|
||||||
assert_eq!(options.max_objects, Some(DEFAULT_MANUAL_TRANSITION_MAX_OBJECTS));
|
assert_eq!(options.max_objects, Some(DEFAULT_MANUAL_TRANSITION_MAX_OBJECTS));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_transition_query_rejects_unimplemented_durable_mode() {
|
fn manual_transition_query_accepts_explicit_async_mode() {
|
||||||
let err = parse_manual_transition_query(Some("bucket=data&async=true"))
|
let (_bucket, _options, run_mode) =
|
||||||
.expect_err("async durable jobs must not be silently accepted");
|
parse_manual_transition_query(Some("bucket=data&async=true")).expect("async mode should parse");
|
||||||
|
|
||||||
assert_eq!(err.code(), &S3ErrorCode::NotImplemented);
|
assert_eq!(run_mode, ManualTransitionRunMode::Async);
|
||||||
|
|
||||||
let err = parse_manual_transition_query(Some("bucket=data&mode=async"))
|
let (_bucket, _options, run_mode) =
|
||||||
.expect_err("mode=async must not fall back to enqueue_only");
|
parse_manual_transition_query(Some("bucket=data&mode=async")).expect("mode=async should parse");
|
||||||
|
|
||||||
assert_eq!(err.code(), &S3ErrorCode::NotImplemented);
|
assert_eq!(run_mode, ManualTransitionRunMode::Async);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -479,11 +850,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_transition_scope_ignores_resume_and_budget_parameters() {
|
fn manual_transition_scope_ignores_resume_and_budget_parameters() {
|
||||||
let (bucket, first) = parse_manual_transition_query(Some(
|
let (bucket, first, _run_mode) = parse_manual_transition_query(Some(
|
||||||
"bucket=data&prefix=logs/&tier=warm&marker=logs/a&versionMarker=v1&maxObjects=10",
|
"bucket=data&prefix=logs/&tier=warm&marker=logs/a&versionMarker=v1&maxObjects=10",
|
||||||
))
|
))
|
||||||
.expect("first query should parse");
|
.expect("first query should parse");
|
||||||
let (_, second) = parse_manual_transition_query(Some(
|
let (_, second, _run_mode) = parse_manual_transition_query(Some(
|
||||||
"bucket=data&prefix=logs/&tier=WARM&marker=logs/z&versionMarker=v9&maxObjects=20",
|
"bucket=data&prefix=logs/&tier=WARM&marker=logs/z&versionMarker=v9&maxObjects=20",
|
||||||
))
|
))
|
||||||
.expect("second query should parse");
|
.expect("second query should parse");
|
||||||
@@ -496,9 +867,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_transition_scope_distinguishes_dry_run_mode() {
|
fn manual_transition_scope_distinguishes_dry_run_mode() {
|
||||||
let (bucket, real) =
|
let (bucket, real, _run_mode) =
|
||||||
parse_manual_transition_query(Some("bucket=data&prefix=logs/&tier=warm")).expect("real query should parse");
|
parse_manual_transition_query(Some("bucket=data&prefix=logs/&tier=warm")).expect("real query should parse");
|
||||||
let (_, dry_run) = parse_manual_transition_query(Some("bucket=data&prefix=logs/&tier=warm&dryRun=true"))
|
let (_, dry_run, _run_mode) = parse_manual_transition_query(Some("bucket=data&prefix=logs/&tier=warm&dryRun=true"))
|
||||||
.expect("dry-run query should parse");
|
.expect("dry-run query should parse");
|
||||||
|
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
@@ -509,7 +880,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_transition_admission_rejects_same_scope_until_guard_drops() {
|
fn manual_transition_admission_rejects_same_scope_until_guard_drops() {
|
||||||
let (bucket, options) =
|
let (bucket, options, _run_mode) =
|
||||||
parse_manual_transition_query(Some("bucket=admission-test&prefix=logs/&tier=warm")).expect("query should parse");
|
parse_manual_transition_query(Some("bucket=admission-test&prefix=logs/&tier=warm")).expect("query should parse");
|
||||||
let scope = ManualTransitionRunScope::new(&bucket, &options);
|
let scope = ManualTransitionRunScope::new(&bucket, &options);
|
||||||
let first = acquire_manual_transition_admission(scope.clone()).expect("first admission should succeed");
|
let first = acquire_manual_transition_admission(scope.clone()).expect("first admission should succeed");
|
||||||
@@ -536,7 +907,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_transition_admission_rejects_overlapping_prefix_or_tier() {
|
fn manual_transition_admission_rejects_overlapping_prefix_or_tier() {
|
||||||
let (bucket, options) =
|
let (bucket, options, _run_mode) =
|
||||||
parse_manual_transition_query(Some("bucket=admission-overlap-test&prefix=logs/")).expect("query should parse");
|
parse_manual_transition_query(Some("bucket=admission-overlap-test&prefix=logs/")).expect("query should parse");
|
||||||
let scope = ManualTransitionRunScope::new(&bucket, &options);
|
let scope = ManualTransitionRunScope::new(&bucket, &options);
|
||||||
let active = acquire_manual_transition_admission(scope).expect("first admission should succeed");
|
let active = acquire_manual_transition_admission(scope).expect("first admission should succeed");
|
||||||
@@ -647,6 +1018,53 @@ mod tests {
|
|||||||
assert!(value.pointer("/report/next_version_idmarker").is_none());
|
assert!(value.pointer("/report/next_version_idmarker").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_transition_job_record_omits_raw_resume_markers() {
|
||||||
|
let (bucket, options, run_mode) =
|
||||||
|
parse_manual_transition_query(Some("bucket=data&prefix=logs/&async=true")).expect("async query should parse");
|
||||||
|
let mut record = new_manual_transition_job_record(
|
||||||
|
"11111111-1111-4111-8111-111111111111".to_string(),
|
||||||
|
&bucket,
|
||||||
|
&options,
|
||||||
|
OffsetDateTime::UNIX_EPOCH,
|
||||||
|
);
|
||||||
|
record.status = ManualTransitionJobStatus::Partial;
|
||||||
|
record.report.next_marker = Some("private/object".to_string());
|
||||||
|
record.report.next_version_idmarker = Some("null".to_string());
|
||||||
|
|
||||||
|
assert_eq!(run_mode, ManualTransitionRunMode::Async);
|
||||||
|
assert_eq!(
|
||||||
|
record.status_endpoint,
|
||||||
|
"/rustfs/admin/v3/ilm/transition/jobs/11111111-1111-4111-8111-111111111111"
|
||||||
|
);
|
||||||
|
|
||||||
|
let value = serde_json::to_value(record).expect("job record should serialize");
|
||||||
|
assert!(value.pointer("/report/next_marker").is_none());
|
||||||
|
assert!(value.pointer("/report/next_version_idmarker").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_transition_job_id_rejects_non_uuid_path_segment() {
|
||||||
|
let err = parse_manual_transition_job_id("../config").expect_err("path-like job ids must be rejected");
|
||||||
|
|
||||||
|
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_transition_cancel_marks_active_job_and_token() {
|
||||||
|
let (bucket, options, _run_mode) =
|
||||||
|
parse_manual_transition_query(Some("bucket=data&prefix=logs/&async=true")).expect("async query should parse");
|
||||||
|
let job_id = Uuid::new_v4().to_string();
|
||||||
|
let cancel_token = CancellationToken::new();
|
||||||
|
let record = new_manual_transition_job_record(job_id.clone(), &bucket, &options, OffsetDateTime::UNIX_EPOCH);
|
||||||
|
insert_manual_transition_job(record, cancel_token.clone());
|
||||||
|
|
||||||
|
let cancelled = request_manual_transition_job_cancel(&job_id).expect("active job should be found");
|
||||||
|
|
||||||
|
assert!(cancelled.cancel_requested);
|
||||||
|
assert!(cancel_token.is_cancelled());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_transition_handler_requires_set_tier_action() {
|
fn manual_transition_handler_requires_set_tier_action() {
|
||||||
let src = include_str!("ilm_transition.rs");
|
let src = include_str!("ilm_transition.rs");
|
||||||
@@ -679,6 +1097,16 @@ mod tests {
|
|||||||
assert!(!log_block.contains("next_version_idmarker"));
|
assert!(!log_block.contains("next_version_idmarker"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_transition_background_job_uses_cancelable_scanner_entrypoint() {
|
||||||
|
let src = include_str!("ilm_transition.rs");
|
||||||
|
let job_block =
|
||||||
|
extract_block_between_markers(src, "fn spawn_manual_transition_job", "pub struct ManualTransitionRunHandler");
|
||||||
|
|
||||||
|
assert!(job_block.contains("enqueue_transition_for_existing_objects_scoped_with_cancel"));
|
||||||
|
assert!(job_block.contains("CancellationToken"));
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_block_between_markers<'a>(src: &'a str, start_marker: &str, end_marker: &str) -> &'a str {
|
fn extract_block_between_markers<'a>(src: &'a str, start_marker: &str, end_marker: &str) -> &'a str {
|
||||||
let start = src
|
let start = src
|
||||||
.find(start_marker)
|
.find(start_marker)
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ mod tests {
|
|||||||
let _remove_remote_target_handler = replication::RemoveRemoteTargetHandler {};
|
let _remove_remote_target_handler = replication::RemoveRemoteTargetHandler {};
|
||||||
let _scanner_status_handler = scanner::ScannerStatusHandler {};
|
let _scanner_status_handler = scanner::ScannerStatusHandler {};
|
||||||
let _manual_transition_handler = ilm_transition::ManualTransitionRunHandler {};
|
let _manual_transition_handler = ilm_transition::ManualTransitionRunHandler {};
|
||||||
|
let _manual_transition_status_handler = ilm_transition::ManualTransitionJobStatusHandler {};
|
||||||
|
let _manual_transition_cancel_handler = ilm_transition::ManualTransitionJobCancelHandler {};
|
||||||
let _site_replication_add_handler = site_replication::SiteReplicationAddHandler {};
|
let _site_replication_add_handler = site_replication::SiteReplicationAddHandler {};
|
||||||
let _site_replication_info_handler = site_replication::SiteReplicationInfoHandler {};
|
let _site_replication_info_handler = site_replication::SiteReplicationInfoHandler {};
|
||||||
let _site_replication_status_handler = site_replication::SiteReplicationStatusHandler {};
|
let _site_replication_status_handler = site_replication::SiteReplicationStatusHandler {};
|
||||||
|
|||||||
@@ -414,6 +414,18 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
|
|||||||
admin(HttpMethod::Put, "/rustfs/admin/v3/config", CONFIG_UPDATE, RouteRiskLevel::High),
|
admin(HttpMethod::Put, "/rustfs/admin/v3/config", CONFIG_UPDATE, RouteRiskLevel::High),
|
||||||
admin(HttpMethod::Get, "/rustfs/admin/v3/scanner/status", SERVER_INFO, RouteRiskLevel::Sensitive),
|
admin(HttpMethod::Get, "/rustfs/admin/v3/scanner/status", SERVER_INFO, RouteRiskLevel::Sensitive),
|
||||||
admin(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/run", SET_TIER, RouteRiskLevel::High),
|
admin(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/run", SET_TIER, RouteRiskLevel::High),
|
||||||
|
admin(
|
||||||
|
HttpMethod::Get,
|
||||||
|
"/rustfs/admin/v3/ilm/transition/jobs/{job_id}",
|
||||||
|
SET_TIER,
|
||||||
|
RouteRiskLevel::High,
|
||||||
|
),
|
||||||
|
admin(
|
||||||
|
HttpMethod::Delete,
|
||||||
|
"/rustfs/admin/v3/ilm/transition/jobs/{job_id}",
|
||||||
|
SET_TIER,
|
||||||
|
RouteRiskLevel::High,
|
||||||
|
),
|
||||||
admin(
|
admin(
|
||||||
HttpMethod::Get,
|
HttpMethod::Get,
|
||||||
"/rustfs/admin/v3/audit/target/list",
|
"/rustfs/admin/v3/audit/target/list",
|
||||||
@@ -1803,7 +1815,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn route_policy_requires_set_tier_for_manual_transition_run() {
|
fn route_policy_requires_set_tier_for_manual_transition_run() {
|
||||||
assert_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/run", SET_TIER);
|
assert_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/run", SET_TIER);
|
||||||
|
assert_action(HttpMethod::Get, "/rustfs/admin/v3/ilm/transition/jobs/{job_id}", SET_TIER);
|
||||||
|
assert_action(HttpMethod::Delete, "/rustfs/admin/v3/ilm/transition/jobs/{job_id}", SET_TIER);
|
||||||
assert_not_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/run", SERVER_INFO);
|
assert_not_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/run", SERVER_INFO);
|
||||||
|
assert_not_action(HttpMethod::Get, "/rustfs/admin/v3/ilm/transition/jobs/{job_id}", SERVER_INFO);
|
||||||
|
assert_not_action(HttpMethod::Delete, "/rustfs/admin/v3/ilm/transition/jobs/{job_id}", SERVER_INFO);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -196,6 +196,16 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
|
|||||||
admin_route_sample(Method::POST, "/v3/tier/{tiername}", "/v3/tier/HOT"),
|
admin_route_sample(Method::POST, "/v3/tier/{tiername}", "/v3/tier/HOT"),
|
||||||
admin_route(Method::POST, "/v3/tier/clear"),
|
admin_route(Method::POST, "/v3/tier/clear"),
|
||||||
admin_route(Method::POST, "/v3/ilm/transition/run"),
|
admin_route(Method::POST, "/v3/ilm/transition/run"),
|
||||||
|
admin_route_sample(
|
||||||
|
Method::GET,
|
||||||
|
"/v3/ilm/transition/jobs/{job_id}",
|
||||||
|
"/v3/ilm/transition/jobs/11111111-1111-4111-8111-111111111111",
|
||||||
|
),
|
||||||
|
admin_route_sample(
|
||||||
|
Method::DELETE,
|
||||||
|
"/v3/ilm/transition/jobs/{job_id}",
|
||||||
|
"/v3/ilm/transition/jobs/11111111-1111-4111-8111-111111111111",
|
||||||
|
),
|
||||||
admin_route(Method::PUT, "/v3/set-bucket-quota"),
|
admin_route(Method::PUT, "/v3/set-bucket-quota"),
|
||||||
admin_route(Method::GET, "/v3/get-bucket-quota"),
|
admin_route(Method::GET, "/v3/get-bucket-quota"),
|
||||||
admin_route_sample(Method::PUT, "/v3/quota/{bucket}", "/v3/quota/test-bucket"),
|
admin_route_sample(Method::PUT, "/v3/quota/{bucket}", "/v3/quota/test-bucket"),
|
||||||
@@ -834,6 +844,16 @@ fn test_register_routes_cover_representative_admin_paths() {
|
|||||||
assert_route(&router, Method::PUT, &admin_path("/v3/config"));
|
assert_route(&router, Method::PUT, &admin_path("/v3/config"));
|
||||||
assert_route(&router, Method::GET, &admin_path("/v3/scanner/status"));
|
assert_route(&router, Method::GET, &admin_path("/v3/scanner/status"));
|
||||||
assert_route(&router, Method::POST, &admin_path("/v3/ilm/transition/run"));
|
assert_route(&router, Method::POST, &admin_path("/v3/ilm/transition/run"));
|
||||||
|
assert_route(
|
||||||
|
&router,
|
||||||
|
Method::GET,
|
||||||
|
&admin_path("/v3/ilm/transition/jobs/11111111-1111-4111-8111-111111111111"),
|
||||||
|
);
|
||||||
|
assert_route(
|
||||||
|
&router,
|
||||||
|
Method::DELETE,
|
||||||
|
&admin_path("/v3/ilm/transition/jobs/11111111-1111-4111-8111-111111111111"),
|
||||||
|
);
|
||||||
|
|
||||||
assert_route(&router, Method::GET, &table_catalog_path("/config"));
|
assert_route(&router, Method::GET, &table_catalog_path("/config"));
|
||||||
assert_route(&router, Method::PUT, &table_catalog_path("/buckets/analytics"));
|
assert_route(&router, Method::PUT, &table_catalog_path("/buckets/analytics"));
|
||||||
|
|||||||
@@ -195,6 +195,8 @@ pub(crate) mod bucket_target_sys {
|
|||||||
pub(crate) mod lifecycle {
|
pub(crate) mod lifecycle {
|
||||||
pub(crate) type ManualTransitionRunOptions =
|
pub(crate) type ManualTransitionRunOptions =
|
||||||
super::ecstore_bucket::lifecycle::bucket_lifecycle_ops::ManualTransitionRunOptions;
|
super::ecstore_bucket::lifecycle::bucket_lifecycle_ops::ManualTransitionRunOptions;
|
||||||
|
pub(crate) type ManualTransitionRunExecution =
|
||||||
|
super::ecstore_bucket::lifecycle::bucket_lifecycle_ops::ManualTransitionRunExecution;
|
||||||
pub(crate) type ManualTransitionRunReport = super::ecstore_bucket::lifecycle::bucket_lifecycle_ops::ManualTransitionRunReport;
|
pub(crate) type ManualTransitionRunReport = super::ecstore_bucket::lifecycle::bucket_lifecycle_ops::ManualTransitionRunReport;
|
||||||
|
|
||||||
pub(crate) async fn enqueue_transition_for_existing_objects_scoped(
|
pub(crate) async fn enqueue_transition_for_existing_objects_scoped(
|
||||||
@@ -208,6 +210,21 @@ pub(crate) mod lifecycle {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn enqueue_transition_for_existing_objects_scoped_with_cancel(
|
||||||
|
api: std::sync::Arc<super::ECStore>,
|
||||||
|
bucket: &str,
|
||||||
|
options: ManualTransitionRunOptions,
|
||||||
|
cancel_token: Option<tokio_util::sync::CancellationToken>,
|
||||||
|
) -> super::Result<ManualTransitionRunExecution> {
|
||||||
|
super::ecstore_bucket::lifecycle::bucket_lifecycle_ops::enqueue_transition_for_existing_objects_scoped_with_cancel(
|
||||||
|
api,
|
||||||
|
bucket,
|
||||||
|
options,
|
||||||
|
cancel_token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) mod tier_last_day_stats {
|
pub(crate) mod tier_last_day_stats {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) type LastDayTierStats = super::super::ecstore_bucket::lifecycle::tier_last_day_stats::LastDayTierStats;
|
pub(crate) type LastDayTierStats = super::super::ecstore_bucket::lifecycle::tier_last_day_stats::LastDayTierStats;
|
||||||
|
|||||||
Reference in New Issue
Block a user