mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-31 09:18:28 +00:00
6cf9cf7bb5
* chore(ecstore): drop the bucket dead_code blanket The last blanket of the backlog#1823 burn-down, and the largest: 71 items across lifecycle, replication, metadata, quota, object lock and bucket utils. Four are deleted. Deleted, all trivial: - check_valid_object_name and check_valid_object_name_prefix, a pair that only calls into each other with no external caller. Worth stating plainly so nobody reads this as a validation gap: object names are validated through check_object_name_for_length_and_slash, which is live; this pair is a second, unwired entry point. - DEFAULT_HEALTH_CHECK_RELOAD_DURATION, a lone unused constant. - The LifecycleReplicationConfig alias, which orphaned a re-export in replication/mod.rs that goes with it. Everything else is kept, in four groups, because the blanket here was hiding structure rather than rot: Windows platform gating. WINDOWS_RESERVED_NAMES, the two reason constants and object_name_has_windows_incompatible_segment are called from inside the #[cfg(target_os = "windows")] block in check_object_name_for_length_and_slash (utils.rs:228-255), so they only read as dead on non-Windows hosts. As with the Linux gating in the disk root, this cannot be adjudicated locally: cargo check for both x86_64-pc-windows-msvc and x86_64-unknown-linux-gnu fails in the aws-lc-sys build script for want of a cross C toolchain. CI covers both. Declared boundary surface. The *_boundary.rs and *_bridge.rs files carry the replication split plan's contracts, which scripts/check_architecture_migration_rules.sh pins through the EcstoreReplicationBoundaryImports section of the split-plan doc. Their unused items are declarations, not leftovers. test-util seams. ConfigWriteLockProbe with install/wait_until_attempted follows the same pattern as the barriers in the services and set_disk roots. MinIO-parity tier/lifecycle entry points that this port never wired: apply_lifecycle_action, get_transitioned_object_reader, recover_tier_free_versions, delete_object_from_remote_tier, abort_tier_delete_journal_entry and the replication pool's worker-management surface. These are complete, substantial machinery with no caller — the same shape as data_usage's local_snapshot feature. Removing them is a product decision, so they are made explicit here rather than deleted. Verification, four lanes warning-free: default, --tests, --features rio-v2 --tests, --features test-util --tests. cargo nextest run -p rustfs-ecstore 4096 passed; clippy --lib --tests -D warnings clean; make pre-commit exit 0. Note that clippy is what caught the orphaned re-export above: cargo check and pre-commit both treat unused_imports as a warning. Ref rustfs/backlog#1823 (step 2, final root). * chore(ecstore): correct inaccurate dead_code reasons in the bucket root Six items were labelled 'asserted by this file's tests' or as MinIO-parity entry points while having no caller at all - free get_bucket_acl_config and created_at only reach their own live methods (production goes through created_at_in), BucketVersioningSys::get_in, utils::serialize_content and ServiceType have no reference anywhere, and with_transition_queue_env_async is an unused test fixture, not a tier entry point. Name what each one is so the next reader does not assume coverage that is not there. Ref rustfs/backlog#1823.
3025 lines
120 KiB
Rust
3025 lines
120 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::sync::Arc;
|
|
|
|
use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum};
|
|
use serde::{Deserialize, Serialize};
|
|
use time::OffsetDateTime;
|
|
use uuid::Uuid;
|
|
|
|
use crate::bucket::lifecycle::bucket_lifecycle_ops::{
|
|
ManualTransitionQueueSnapshot, ManualTransitionRunOptions, ManualTransitionRunReport,
|
|
};
|
|
use crate::bucket::lifecycle::config_boundary;
|
|
use crate::disk::RUSTFS_META_BUCKET;
|
|
use crate::error::{Error, Result as EcstoreResult};
|
|
use crate::object_api::ObjectOptions;
|
|
use crate::storage_api_contracts::list::ListOperations as _;
|
|
use crate::storage_api_contracts::object::HTTPPreconditions;
|
|
use crate::store::ECStore;
|
|
|
|
pub const MANUAL_TRANSITION_JOB_SCHEMA: &str = "rustfs-manual-transition-job-v1";
|
|
pub const MANUAL_TRANSITION_TASK_SCHEMA: &str = "rustfs-manual-transition-task-v1";
|
|
pub const MANUAL_TRANSITION_WORKER_RESULT_SCHEMA: &str = "rustfs-manual-transition-worker-result-v1";
|
|
pub const MANUAL_TRANSITION_JOB_RECORD_PREFIX: &str = "ilm/manual-transition/jobs";
|
|
pub const MANUAL_TRANSITION_SCOPE_RECORD_PREFIX: &str = "ilm/manual-transition/scopes";
|
|
pub const MANUAL_TRANSITION_TASK_PREFIX: &str = "ilm/manual-transition/tasks";
|
|
pub const MANUAL_TRANSITION_WORKER_RESULT_PREFIX: &str = "ilm/manual-transition/results";
|
|
pub const MAX_MANUAL_TRANSITION_JOB_RECORD_SIZE: usize = 64 * 1024;
|
|
pub const MAX_MANUAL_TRANSITION_TASK_RECORD_SIZE: usize = 16 * 1024;
|
|
pub const MAX_MANUAL_TRANSITION_WORKER_RESULT_RECORD_SIZE: usize = 8 * 1024;
|
|
const MANUAL_TRANSITION_JOB_LEASE_SECONDS: i128 = 60;
|
|
const MANUAL_TRANSITION_LEGACY_SCOPE_SCAN_LIMIT: i32 = 1000;
|
|
const MANUAL_TRANSITION_TASK_SCAN_LIMIT: i32 = 1000;
|
|
const MANUAL_TRANSITION_WORKER_RESULT_SCAN_LIMIT: i32 = 1000;
|
|
const MANUAL_TRANSITION_JOB_CAS_RETRIES: usize = 4;
|
|
|
|
#[cfg(test)]
|
|
struct ManualTransitionJobCasBarrierState {
|
|
job_id: Uuid,
|
|
paused: std::sync::atomic::AtomicBool,
|
|
arrived: tokio::sync::Notify,
|
|
release: tokio::sync::Semaphore,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) struct ManualTransitionJobCasBarrier {
|
|
state: Arc<ManualTransitionJobCasBarrierState>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
static MANUAL_TRANSITION_JOB_CAS_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc<ManualTransitionJobCasBarrierState>>>> =
|
|
std::sync::OnceLock::new();
|
|
|
|
#[cfg(test)]
|
|
impl ManualTransitionJobCasBarrier {
|
|
pub(crate) fn install(job_id: Uuid) -> Self {
|
|
let state = Arc::new(ManualTransitionJobCasBarrierState {
|
|
job_id,
|
|
paused: std::sync::atomic::AtomicBool::new(false),
|
|
arrived: tokio::sync::Notify::new(),
|
|
release: tokio::sync::Semaphore::new(0),
|
|
});
|
|
let mut slot = MANUAL_TRANSITION_JOB_CAS_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("manual transition progress CAS barrier mutex should not poison");
|
|
assert!(
|
|
slot.is_none(),
|
|
"manual transition job CAS barrier must be installed by one test at a time"
|
|
);
|
|
*slot = Some(Arc::clone(&state));
|
|
drop(slot);
|
|
Self { state }
|
|
}
|
|
|
|
pub(crate) async fn wait_until_paused(&self) {
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
|
loop {
|
|
let arrived = self.state.arrived.notified();
|
|
if self.state.paused.load(std::sync::atomic::Ordering::Acquire) {
|
|
return;
|
|
}
|
|
arrived.await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("manual transition job update should reach the deterministic CAS barrier");
|
|
}
|
|
|
|
pub(crate) fn release(&self) {
|
|
self.state.release.add_permits(1);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Drop for ManualTransitionJobCasBarrier {
|
|
fn drop(&mut self) {
|
|
self.release();
|
|
let mut slot = MANUAL_TRANSITION_JOB_CAS_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("manual transition progress CAS barrier mutex should not poison");
|
|
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
|
*slot = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn pause_manual_transition_job_before_first_cas(job_id: Uuid) {
|
|
let barrier = MANUAL_TRANSITION_JOB_CAS_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("manual transition progress CAS barrier mutex should not poison")
|
|
.as_ref()
|
|
.filter(|barrier| barrier.job_id == job_id)
|
|
.cloned();
|
|
if let Some(barrier) = barrier
|
|
&& barrier
|
|
.paused
|
|
.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire)
|
|
.is_ok()
|
|
{
|
|
barrier.arrived.notify_one();
|
|
barrier
|
|
.release
|
|
.acquire()
|
|
.await
|
|
.expect("manual transition job CAS barrier should remain open")
|
|
.forget();
|
|
}
|
|
}
|
|
|
|
fn is_false(value: &bool) -> bool {
|
|
!*value
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ManualTransitionJobError {
|
|
#[error("manual transition job is corrupt: {0}")]
|
|
Corrupt(&'static str),
|
|
#[error("manual transition job schema is unsupported: {0}")]
|
|
UnsupportedSchema(String),
|
|
#[error("manual transition job checksum mismatch")]
|
|
ChecksumMismatch,
|
|
#[error("manual transition job json error: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ManualTransitionJobState {
|
|
Running,
|
|
Completed,
|
|
Partial,
|
|
Failed,
|
|
Cancelled,
|
|
Unknown,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ManualTransitionJobRecord {
|
|
pub job_id: Uuid,
|
|
pub scope_key: String,
|
|
pub bucket: String,
|
|
pub prefix: String,
|
|
pub tier: Option<String>,
|
|
pub dry_run: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub max_objects: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub max_duration: Option<std::time::Duration>,
|
|
pub owner_id: String,
|
|
pub lease_id: Uuid,
|
|
pub lease_expires_at_unix_nanos: i128,
|
|
pub state: ManualTransitionJobState,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub scan_completed: bool,
|
|
pub cancel_requested: bool,
|
|
pub created_at_unix_nanos: i128,
|
|
pub updated_at_unix_nanos: i128,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub completed_at_unix_nanos: Option<i128>,
|
|
pub report: ManualTransitionRunReport,
|
|
pub queue_snapshot: ManualTransitionQueueSnapshot,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
impl ManualTransitionJobRecord {
|
|
pub fn new(job_id: Uuid, bucket: &str, options: &ManualTransitionRunOptions, owner_id: impl Into<String>) -> Self {
|
|
let scope_key = manual_transition_scope_key(bucket, options);
|
|
let now = OffsetDateTime::now_utc().unix_timestamp_nanos();
|
|
let lease_id = Uuid::new_v4();
|
|
Self {
|
|
job_id,
|
|
scope_key,
|
|
bucket: bucket.to_string(),
|
|
prefix: options.prefix.clone(),
|
|
tier: options.tier.clone(),
|
|
dry_run: options.dry_run,
|
|
max_objects: options.max_objects,
|
|
max_duration: options.max_duration,
|
|
owner_id: owner_id.into(),
|
|
lease_id,
|
|
lease_expires_at_unix_nanos: manual_transition_job_lease_expires_at(now),
|
|
state: ManualTransitionJobState::Running,
|
|
scan_completed: false,
|
|
cancel_requested: false,
|
|
created_at_unix_nanos: now,
|
|
updated_at_unix_nanos: now,
|
|
completed_at_unix_nanos: None,
|
|
report: ManualTransitionRunReport {
|
|
bucket: bucket.to_string(),
|
|
prefix: options.prefix.clone(),
|
|
tier: options.tier.clone(),
|
|
dry_run: options.dry_run,
|
|
..Default::default()
|
|
},
|
|
queue_snapshot: ManualTransitionQueueSnapshot::default(),
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
pub fn complete(&mut self, report: ManualTransitionRunReport, queue_snapshot: ManualTransitionQueueSnapshot) {
|
|
self.scan_completed = true;
|
|
self.report.merge_scan_report_preserving_worker(&report);
|
|
self.queue_snapshot = queue_snapshot;
|
|
self.error = None;
|
|
self.mark_terminal_if_worker_drained();
|
|
}
|
|
|
|
pub fn fail(&mut self, error: impl Into<String>) {
|
|
self.state = ManualTransitionJobState::Failed;
|
|
self.error = Some(error.into());
|
|
self.mark_updated_terminal();
|
|
}
|
|
|
|
pub fn mark_unknown_for_worker_result_journal_error(
|
|
&mut self,
|
|
error: impl Into<String>,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> bool {
|
|
if self.state != ManualTransitionJobState::Running || !self.scan_completed || !self.report.worker_transition_pending() {
|
|
return false;
|
|
}
|
|
self.queue_snapshot = queue_snapshot;
|
|
self.state = ManualTransitionJobState::Unknown;
|
|
self.error = Some(format!("manual transition worker result journal is corrupt: {}", error.into()));
|
|
self.mark_updated_terminal();
|
|
true
|
|
}
|
|
|
|
pub fn mark_unknown_for_task_journal_error(
|
|
&mut self,
|
|
error: impl Into<String>,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> bool {
|
|
if self.state != ManualTransitionJobState::Running || !self.scan_completed {
|
|
return false;
|
|
}
|
|
self.queue_snapshot = queue_snapshot;
|
|
self.state = ManualTransitionJobState::Unknown;
|
|
self.error = Some(format!("manual transition task journal is corrupt: {}", error.into()));
|
|
self.mark_updated_terminal();
|
|
true
|
|
}
|
|
|
|
pub fn cancel_after_recovery(&mut self, queue_snapshot: ManualTransitionQueueSnapshot) {
|
|
if self.state == ManualTransitionJobState::Running && self.cancel_requested {
|
|
self.scan_completed = true;
|
|
self.report.cancelled = true;
|
|
self.queue_snapshot = queue_snapshot;
|
|
self.error = None;
|
|
self.state = ManualTransitionJobState::Cancelled;
|
|
self.mark_updated_terminal();
|
|
}
|
|
}
|
|
|
|
pub fn record_worker_result(&mut self, result: ManualTransitionWorkerResult, queue_snapshot: ManualTransitionQueueSnapshot) {
|
|
self.record_worker_result_with_reason(result, queue_snapshot, None);
|
|
}
|
|
|
|
pub fn record_worker_result_with_reason(
|
|
&mut self,
|
|
result: ManualTransitionWorkerResult,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
failure_reason: Option<ManualTransitionWorkerFailureReason>,
|
|
) {
|
|
if self.is_terminal() {
|
|
return;
|
|
}
|
|
match result {
|
|
ManualTransitionWorkerResult::Completed => {
|
|
self.report.transition_completed = self.report.transition_completed.saturating_add(1);
|
|
}
|
|
ManualTransitionWorkerResult::TierFailure => {
|
|
self.report.transition_failed = self.report.transition_failed.saturating_add(1);
|
|
self.report.tier_failure = self.report.tier_failure.saturating_add(1);
|
|
let reason = failure_reason.unwrap_or(ManualTransitionWorkerFailureReason::Unknown);
|
|
*self.report.tier_failure_by_reason.entry(reason).or_insert(0) += 1;
|
|
}
|
|
}
|
|
self.queue_snapshot = queue_snapshot;
|
|
self.updated_at_unix_nanos = OffsetDateTime::now_utc().unix_timestamp_nanos();
|
|
self.mark_terminal_if_worker_drained();
|
|
}
|
|
|
|
fn apply_worker_result_counts(
|
|
&mut self,
|
|
completed: u64,
|
|
failed: u64,
|
|
failure_reasons: &BTreeMap<ManualTransitionWorkerFailureReason, u64>,
|
|
task_queued: u64,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> bool {
|
|
if self.is_terminal() {
|
|
return false;
|
|
}
|
|
let enqueued = self.report.enqueued.max(task_queued);
|
|
if completed.saturating_add(failed) > enqueued {
|
|
self.queue_snapshot = queue_snapshot;
|
|
self.state = ManualTransitionJobState::Unknown;
|
|
self.error = Some("manual transition worker result journal exceeds enqueued count".to_string());
|
|
self.mark_updated_terminal();
|
|
return true;
|
|
}
|
|
let transition_completed = self.report.transition_completed.max(completed);
|
|
let transition_failed = self.report.transition_failed.max(failed);
|
|
if enqueued == self.report.enqueued
|
|
&& transition_completed == self.report.transition_completed
|
|
&& transition_failed == self.report.transition_failed
|
|
{
|
|
return false;
|
|
}
|
|
let mut scan_tier_failure_by_reason = self.report.tier_failure_by_reason.clone();
|
|
for (reason, count) in failure_reasons {
|
|
let current = scan_tier_failure_by_reason.get(reason).copied().unwrap_or_default();
|
|
scan_tier_failure_by_reason.insert(*reason, current.max(*count));
|
|
}
|
|
let scan_tier_failure = self.report.tier_failure.saturating_sub(self.report.transition_failed);
|
|
self.report.enqueued = enqueued;
|
|
self.report.transition_completed = transition_completed;
|
|
self.report.transition_failed = transition_failed;
|
|
self.report.tier_failure = scan_tier_failure.saturating_add(transition_failed);
|
|
self.report.tier_failure_by_reason = scan_tier_failure_by_reason;
|
|
self.queue_snapshot = queue_snapshot;
|
|
self.updated_at_unix_nanos = OffsetDateTime::now_utc().unix_timestamp_nanos();
|
|
self.mark_terminal_if_worker_drained();
|
|
true
|
|
}
|
|
|
|
pub fn mark_cancel_requested(&mut self) {
|
|
self.cancel_requested = true;
|
|
self.updated_at_unix_nanos = OffsetDateTime::now_utc().unix_timestamp_nanos();
|
|
}
|
|
|
|
pub fn claim_recovery_lease(&mut self, owner_id: impl Into<String>, queue_snapshot: ManualTransitionQueueSnapshot) {
|
|
if self.state == ManualTransitionJobState::Running {
|
|
self.owner_id = owner_id.into();
|
|
self.lease_id = Uuid::new_v4();
|
|
self.renew_lease(queue_snapshot);
|
|
}
|
|
}
|
|
|
|
pub fn abandon_recovery_lease(&mut self, lease_id: Uuid) {
|
|
if self.state == ManualTransitionJobState::Running && self.lease_id == lease_id {
|
|
self.lease_expires_at_unix_nanos = 0;
|
|
self.updated_at_unix_nanos = OffsetDateTime::now_utc().unix_timestamp_nanos();
|
|
}
|
|
}
|
|
|
|
pub fn resume_options(&self) -> ManualTransitionRunOptions {
|
|
ManualTransitionRunOptions {
|
|
prefix: self.prefix.clone(),
|
|
continuation_token: self.report.continuation_token.clone(),
|
|
tier: self.tier.clone(),
|
|
dry_run: self.dry_run,
|
|
max_objects: self.max_objects,
|
|
max_duration: self.max_duration,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn renew_lease(&mut self, queue_snapshot: ManualTransitionQueueSnapshot) {
|
|
let now = OffsetDateTime::now_utc().unix_timestamp_nanos();
|
|
self.updated_at_unix_nanos = now;
|
|
self.lease_expires_at_unix_nanos = manual_transition_job_lease_expires_at(now);
|
|
self.queue_snapshot = queue_snapshot;
|
|
}
|
|
|
|
pub fn mark_unknown_if_worker_results_lost(&mut self, queue_snapshot: ManualTransitionQueueSnapshot) -> bool {
|
|
if self.state != ManualTransitionJobState::Running
|
|
|| !self.scan_completed
|
|
|| !self.report.worker_transition_pending()
|
|
|| queue_snapshot.queued > 0
|
|
|| queue_snapshot.active > 0
|
|
{
|
|
return false;
|
|
}
|
|
self.queue_snapshot = queue_snapshot;
|
|
self.state = ManualTransitionJobState::Unknown;
|
|
self.error = Some("manual transition worker result was not persisted before the transition queue drained".to_string());
|
|
self.mark_updated_terminal();
|
|
true
|
|
}
|
|
|
|
pub fn mark_unknown_if_recovery_would_skip_pending_page(&mut self, queue_snapshot: ManualTransitionQueueSnapshot) -> bool {
|
|
if self.state != ManualTransitionJobState::Running
|
|
|| self.report.continuation_token.is_none()
|
|
|| !self.report.worker_transition_pending()
|
|
|| queue_snapshot.queued > 0
|
|
|| queue_snapshot.active > 0
|
|
{
|
|
return false;
|
|
}
|
|
self.queue_snapshot = queue_snapshot;
|
|
self.state = ManualTransitionJobState::Unknown;
|
|
self.error =
|
|
Some("manual transition page/task journal is missing for pending work before the durable cursor".to_string());
|
|
self.mark_updated_terminal();
|
|
true
|
|
}
|
|
|
|
pub fn update_running_progress(&mut self, report: ManualTransitionRunReport, queue_snapshot: ManualTransitionQueueSnapshot) {
|
|
if self.state == ManualTransitionJobState::Running {
|
|
self.report.merge_scan_report_preserving_worker(&report);
|
|
self.renew_lease(queue_snapshot);
|
|
}
|
|
}
|
|
|
|
pub fn mark_unknown_if_unowned(&mut self) {
|
|
if self.state == ManualTransitionJobState::Running {
|
|
self.state = ManualTransitionJobState::Unknown;
|
|
self.error = Some("manual transition job outcome is unknown after restart or owner loss".to_string());
|
|
self.mark_updated_terminal();
|
|
}
|
|
}
|
|
|
|
pub fn is_terminal(&self) -> bool {
|
|
matches!(
|
|
self.state,
|
|
ManualTransitionJobState::Completed
|
|
| ManualTransitionJobState::Partial
|
|
| ManualTransitionJobState::Failed
|
|
| ManualTransitionJobState::Cancelled
|
|
| ManualTransitionJobState::Unknown
|
|
)
|
|
}
|
|
|
|
fn mark_updated_terminal(&mut self) {
|
|
let now = OffsetDateTime::now_utc().unix_timestamp_nanos();
|
|
self.updated_at_unix_nanos = now;
|
|
self.completed_at_unix_nanos = Some(now);
|
|
}
|
|
|
|
fn mark_terminal_if_worker_drained(&mut self) {
|
|
if !self.scan_completed || self.report.worker_transition_pending() {
|
|
return;
|
|
}
|
|
if self.cancel_requested {
|
|
self.report.cancelled = true;
|
|
}
|
|
self.state = if self.report.cancelled {
|
|
ManualTransitionJobState::Cancelled
|
|
} else if self.report.was_truncated()
|
|
|| self.report.has_partial_enqueue()
|
|
|| self.report.tier_failure > 0
|
|
|| self.report.transition_failed > 0
|
|
{
|
|
ManualTransitionJobState::Partial
|
|
} else {
|
|
ManualTransitionJobState::Completed
|
|
};
|
|
self.mark_updated_terminal();
|
|
}
|
|
|
|
pub fn encode(&self) -> Result<Vec<u8>, ManualTransitionJobError> {
|
|
self.validate()?;
|
|
let job_bytes = serde_json::to_vec(self)?;
|
|
let content_sha256 = hex_sha256(&job_bytes, ToOwned::to_owned);
|
|
let persisted = PersistedManualTransitionJobRecord {
|
|
schema: MANUAL_TRANSITION_JOB_SCHEMA.to_string(),
|
|
content_sha256,
|
|
job: self.clone(),
|
|
};
|
|
let encoded = serde_json::to_vec(&persisted)?;
|
|
if encoded.len() > MAX_MANUAL_TRANSITION_JOB_RECORD_SIZE {
|
|
return Err(ManualTransitionJobError::Corrupt("encoded job exceeds maximum size"));
|
|
}
|
|
Ok(encoded)
|
|
}
|
|
|
|
pub fn decode(expected_job_id: Uuid, data: &[u8]) -> Result<Self, ManualTransitionJobError> {
|
|
if data.len() > MAX_MANUAL_TRANSITION_JOB_RECORD_SIZE {
|
|
return Err(ManualTransitionJobError::Corrupt("encoded job exceeds maximum size"));
|
|
}
|
|
let persisted: PersistedManualTransitionJobRecord = serde_json::from_slice(data)?;
|
|
if persisted.schema != MANUAL_TRANSITION_JOB_SCHEMA {
|
|
return Err(ManualTransitionJobError::UnsupportedSchema(persisted.schema));
|
|
}
|
|
if !is_sha256_checksum(&persisted.content_sha256) {
|
|
return Err(ManualTransitionJobError::Corrupt("content checksum is not a sha256 checksum"));
|
|
}
|
|
let mut job = persisted.job;
|
|
let mut job_bytes = serde_json::to_vec(&job)?;
|
|
let actual_checksum = hex_sha256(&job_bytes, ToOwned::to_owned);
|
|
let checksum_match = if persisted.content_sha256 == actual_checksum {
|
|
true
|
|
} else {
|
|
let mut job_value = serde_json::to_value(&job)?;
|
|
if let Some(job_obj) = job_value.as_object_mut() {
|
|
if let Some(report) = job_obj.get_mut("report").and_then(|value| value.as_object_mut()) {
|
|
report.remove("tier_failure_by_reason");
|
|
}
|
|
} else {
|
|
return Err(ManualTransitionJobError::ChecksumMismatch);
|
|
}
|
|
job_bytes = serde_json::to_vec(&job_value)?;
|
|
let fallback_checksum = hex_sha256(&job_bytes, ToOwned::to_owned);
|
|
persisted.content_sha256 == fallback_checksum
|
|
};
|
|
if !checksum_match {
|
|
return Err(ManualTransitionJobError::ChecksumMismatch);
|
|
}
|
|
if job.job_id != expected_job_id {
|
|
return Err(ManualTransitionJobError::Corrupt("job_id does not match record key"));
|
|
}
|
|
if job.state == ManualTransitionJobState::Cancelled && job.cancel_requested {
|
|
job.report.cancelled = true;
|
|
}
|
|
job.validate()?;
|
|
Ok(job)
|
|
}
|
|
|
|
fn validate(&self) -> Result<(), ManualTransitionJobError> {
|
|
if self.job_id.is_nil() {
|
|
return Err(ManualTransitionJobError::Corrupt("job_id is nil"));
|
|
}
|
|
if self.lease_id.is_nil() {
|
|
return Err(ManualTransitionJobError::Corrupt("lease_id is nil"));
|
|
}
|
|
if self.scope_key.is_empty() {
|
|
return Err(ManualTransitionJobError::Corrupt("scope_key is empty"));
|
|
}
|
|
if self.bucket.is_empty() {
|
|
return Err(ManualTransitionJobError::Corrupt("bucket is empty"));
|
|
}
|
|
if self.owner_id.trim().is_empty() {
|
|
return Err(ManualTransitionJobError::Corrupt("owner_id is empty"));
|
|
}
|
|
if self.completed_at_unix_nanos.is_some() && !self.is_terminal() {
|
|
return Err(ManualTransitionJobError::Corrupt("non-terminal job has completed timestamp"));
|
|
}
|
|
if self.state == ManualTransitionJobState::Cancelled && !self.cancel_requested {
|
|
return Err(ManualTransitionJobError::Corrupt("cancelled job is missing cancel request"));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ManualTransitionWorkerResult {
|
|
Completed,
|
|
TierFailure,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ManualTransitionWorkerFailureReason {
|
|
Unknown,
|
|
NotFound,
|
|
Network,
|
|
PermissionDenied,
|
|
Timeout,
|
|
Quorum,
|
|
SlowDown,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ManualTransitionTaskRecord {
|
|
pub schema: String,
|
|
pub job_id: Uuid,
|
|
pub task_key: String,
|
|
pub bucket: String,
|
|
pub object: String,
|
|
pub version_id: Option<Uuid>,
|
|
pub storage_class: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub etag: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub mod_time_unix_nanos: Option<i128>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub size: Option<i64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub is_latest: Option<bool>,
|
|
pub queued_at_unix_nanos: i128,
|
|
}
|
|
|
|
impl ManualTransitionTaskRecord {
|
|
pub fn new(
|
|
job_id: Uuid,
|
|
task_key: impl Into<String>,
|
|
bucket: impl Into<String>,
|
|
object: impl Into<String>,
|
|
version_id: Option<Uuid>,
|
|
storage_class: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
schema: MANUAL_TRANSITION_TASK_SCHEMA.to_string(),
|
|
job_id,
|
|
task_key: task_key.into(),
|
|
bucket: bucket.into(),
|
|
object: object.into(),
|
|
version_id,
|
|
storage_class: storage_class.into(),
|
|
etag: None,
|
|
mod_time_unix_nanos: None,
|
|
size: None,
|
|
is_latest: None,
|
|
queued_at_unix_nanos: OffsetDateTime::now_utc().unix_timestamp_nanos(),
|
|
}
|
|
}
|
|
|
|
pub fn with_object_metadata(
|
|
mut self,
|
|
etag: Option<String>,
|
|
mod_time: Option<OffsetDateTime>,
|
|
size: i64,
|
|
is_latest: bool,
|
|
) -> Self {
|
|
self.etag = etag;
|
|
self.mod_time_unix_nanos = mod_time.map(|time| time.unix_timestamp_nanos());
|
|
self.size = Some(size);
|
|
self.is_latest = Some(is_latest);
|
|
self
|
|
}
|
|
|
|
pub fn encode(&self) -> Result<Vec<u8>, ManualTransitionJobError> {
|
|
self.validate()?;
|
|
let record_bytes = serde_json::to_vec(self)?;
|
|
let content_sha256 = hex_sha256(&record_bytes, ToOwned::to_owned);
|
|
let persisted = PersistedManualTransitionTaskRecord {
|
|
schema: MANUAL_TRANSITION_TASK_SCHEMA.to_string(),
|
|
content_sha256,
|
|
record: self.clone(),
|
|
};
|
|
let encoded = serde_json::to_vec(&persisted)?;
|
|
if encoded.len() > MAX_MANUAL_TRANSITION_TASK_RECORD_SIZE {
|
|
return Err(ManualTransitionJobError::Corrupt("encoded task record exceeds maximum size"));
|
|
}
|
|
Ok(encoded)
|
|
}
|
|
|
|
pub fn decode(expected_job_id: Uuid, expected_task_key: &str, data: &[u8]) -> Result<Self, ManualTransitionJobError> {
|
|
if data.len() > MAX_MANUAL_TRANSITION_TASK_RECORD_SIZE {
|
|
return Err(ManualTransitionJobError::Corrupt("encoded task record exceeds maximum size"));
|
|
}
|
|
let persisted: PersistedManualTransitionTaskRecord = serde_json::from_slice(data)?;
|
|
if persisted.schema != MANUAL_TRANSITION_TASK_SCHEMA {
|
|
return Err(ManualTransitionJobError::UnsupportedSchema(persisted.schema));
|
|
}
|
|
if !is_sha256_checksum(&persisted.content_sha256) {
|
|
return Err(ManualTransitionJobError::Corrupt("task record content checksum is not a sha256 checksum"));
|
|
}
|
|
let record = persisted.record;
|
|
let record_bytes = serde_json::to_vec(&record)?;
|
|
let actual_checksum = hex_sha256(&record_bytes, ToOwned::to_owned);
|
|
if persisted.content_sha256 != actual_checksum {
|
|
return Err(ManualTransitionJobError::ChecksumMismatch);
|
|
}
|
|
if record.job_id != expected_job_id {
|
|
return Err(ManualTransitionJobError::Corrupt("task record job_id does not match record key"));
|
|
}
|
|
if record.task_key != expected_task_key {
|
|
return Err(ManualTransitionJobError::Corrupt("task record task_key does not match record key"));
|
|
}
|
|
record.validate()?;
|
|
Ok(record)
|
|
}
|
|
|
|
fn validate(&self) -> Result<(), ManualTransitionJobError> {
|
|
if self.job_id.is_nil() {
|
|
return Err(ManualTransitionJobError::Corrupt("task record job_id is nil"));
|
|
}
|
|
if !is_sha256_checksum(&self.task_key) {
|
|
return Err(ManualTransitionJobError::Corrupt("task record task_key is not a sha256 checksum"));
|
|
}
|
|
if self.bucket.is_empty() {
|
|
return Err(ManualTransitionJobError::Corrupt("task record bucket is empty"));
|
|
}
|
|
if self.object.is_empty() {
|
|
return Err(ManualTransitionJobError::Corrupt("task record object is empty"));
|
|
}
|
|
if self.storage_class.trim().is_empty() {
|
|
return Err(ManualTransitionJobError::Corrupt("task record storage_class is empty"));
|
|
}
|
|
if self.size.is_some_and(|size| size < 0) {
|
|
return Err(ManualTransitionJobError::Corrupt("task record size is negative"));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedManualTransitionTaskRecord {
|
|
schema: String,
|
|
content_sha256: String,
|
|
record: ManualTransitionTaskRecord,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ManualTransitionWorkerResultStats {
|
|
pub completed: u64,
|
|
pub failed: u64,
|
|
pub tier_failure_by_reason: BTreeMap<ManualTransitionWorkerFailureReason, u64>,
|
|
}
|
|
|
|
impl ManualTransitionWorkerResultStats {
|
|
fn record(&mut self, result: ManualTransitionWorkerResult, failure_reason: Option<ManualTransitionWorkerFailureReason>) {
|
|
match result {
|
|
ManualTransitionWorkerResult::Completed => self.completed = self.completed.saturating_add(1),
|
|
ManualTransitionWorkerResult::TierFailure => {
|
|
self.failed = self.failed.saturating_add(1);
|
|
let reason = failure_reason.unwrap_or(ManualTransitionWorkerFailureReason::Unknown);
|
|
*self.tier_failure_by_reason.entry(reason).or_insert(0) += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ManualTransitionWorkerResultRecord {
|
|
pub schema: String,
|
|
pub job_id: Uuid,
|
|
pub task_key: String,
|
|
pub result: ManualTransitionWorkerResult,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub failure_reason: Option<ManualTransitionWorkerFailureReason>,
|
|
pub completed_at_unix_nanos: i128,
|
|
}
|
|
|
|
impl ManualTransitionWorkerResultRecord {
|
|
#[allow(
|
|
dead_code,
|
|
reason = "MinIO-parity tier/lifecycle entry point that this port never wired (backlog#1823)"
|
|
)]
|
|
pub fn new(job_id: Uuid, task_key: impl Into<String>, result: ManualTransitionWorkerResult) -> Self {
|
|
Self::new_with_reason(job_id, task_key, result, None)
|
|
}
|
|
|
|
pub fn new_with_reason(
|
|
job_id: Uuid,
|
|
task_key: impl Into<String>,
|
|
result: ManualTransitionWorkerResult,
|
|
failure_reason: Option<ManualTransitionWorkerFailureReason>,
|
|
) -> Self {
|
|
Self {
|
|
schema: MANUAL_TRANSITION_WORKER_RESULT_SCHEMA.to_string(),
|
|
job_id,
|
|
task_key: task_key.into(),
|
|
result,
|
|
failure_reason,
|
|
completed_at_unix_nanos: OffsetDateTime::now_utc().unix_timestamp_nanos(),
|
|
}
|
|
}
|
|
|
|
pub fn encode(&self) -> Result<Vec<u8>, ManualTransitionJobError> {
|
|
self.validate()?;
|
|
let record_bytes = serde_json::to_vec(self)?;
|
|
let content_sha256 = hex_sha256(&record_bytes, ToOwned::to_owned);
|
|
let persisted = PersistedManualTransitionWorkerResultRecord {
|
|
schema: MANUAL_TRANSITION_WORKER_RESULT_SCHEMA.to_string(),
|
|
content_sha256,
|
|
record: self.clone(),
|
|
};
|
|
let encoded = serde_json::to_vec(&persisted)?;
|
|
if encoded.len() > MAX_MANUAL_TRANSITION_WORKER_RESULT_RECORD_SIZE {
|
|
return Err(ManualTransitionJobError::Corrupt("encoded worker result exceeds maximum size"));
|
|
}
|
|
Ok(encoded)
|
|
}
|
|
|
|
pub fn decode(expected_job_id: Uuid, expected_task_key: &str, data: &[u8]) -> Result<Self, ManualTransitionJobError> {
|
|
if data.len() > MAX_MANUAL_TRANSITION_WORKER_RESULT_RECORD_SIZE {
|
|
return Err(ManualTransitionJobError::Corrupt("encoded worker result exceeds maximum size"));
|
|
}
|
|
let persisted: PersistedManualTransitionWorkerResultRecord = serde_json::from_slice(data)?;
|
|
if persisted.schema != MANUAL_TRANSITION_WORKER_RESULT_SCHEMA {
|
|
return Err(ManualTransitionJobError::UnsupportedSchema(persisted.schema));
|
|
}
|
|
if !is_sha256_checksum(&persisted.content_sha256) {
|
|
return Err(ManualTransitionJobError::Corrupt(
|
|
"worker result content checksum is not a sha256 checksum",
|
|
));
|
|
}
|
|
let record = persisted.record;
|
|
let mut record_bytes = serde_json::to_vec(&record)?;
|
|
let actual_checksum = hex_sha256(&record_bytes, ToOwned::to_owned);
|
|
let checksum_match = if persisted.content_sha256 == actual_checksum {
|
|
true
|
|
} else {
|
|
if let Some(record_value) = serde_json::to_value(&record)?.as_object_mut() {
|
|
if record.failure_reason.is_none() {
|
|
record_value.remove("failure_reason");
|
|
}
|
|
record_bytes = serde_json::to_vec(record_value)?;
|
|
} else {
|
|
return Err(ManualTransitionJobError::ChecksumMismatch);
|
|
}
|
|
let fallback_checksum = hex_sha256(&record_bytes, ToOwned::to_owned);
|
|
persisted.content_sha256 == fallback_checksum
|
|
};
|
|
if !checksum_match {
|
|
return Err(ManualTransitionJobError::ChecksumMismatch);
|
|
}
|
|
if record.job_id != expected_job_id {
|
|
return Err(ManualTransitionJobError::Corrupt("worker result job_id does not match record key"));
|
|
}
|
|
if record.task_key != expected_task_key {
|
|
return Err(ManualTransitionJobError::Corrupt("worker result task_key does not match record key"));
|
|
}
|
|
record.validate()?;
|
|
Ok(record)
|
|
}
|
|
|
|
fn validate(&self) -> Result<(), ManualTransitionJobError> {
|
|
if self.schema != MANUAL_TRANSITION_WORKER_RESULT_SCHEMA {
|
|
return Err(ManualTransitionJobError::UnsupportedSchema(self.schema.clone()));
|
|
}
|
|
if self.job_id.is_nil() {
|
|
return Err(ManualTransitionJobError::Corrupt("worker result job_id is nil"));
|
|
}
|
|
if !is_sha256_checksum(&self.task_key) {
|
|
return Err(ManualTransitionJobError::Corrupt("worker result task_key is not a sha256 checksum"));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedManualTransitionWorkerResultRecord {
|
|
schema: String,
|
|
content_sha256: String,
|
|
record: ManualTransitionWorkerResultRecord,
|
|
}
|
|
|
|
fn manual_transition_job_lease_expires_at(now_unix_nanos: i128) -> i128 {
|
|
now_unix_nanos.saturating_add(MANUAL_TRANSITION_JOB_LEASE_SECONDS.saturating_mul(1_000_000_000))
|
|
}
|
|
|
|
pub fn manual_transition_job_lease_expired(record: &ManualTransitionJobRecord) -> bool {
|
|
OffsetDateTime::now_utc().unix_timestamp_nanos() > record.lease_expires_at_unix_nanos
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedManualTransitionJobRecord {
|
|
schema: String,
|
|
content_sha256: String,
|
|
job: ManualTransitionJobRecord,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ManualTransitionScopeAdmission {
|
|
pub schema: String,
|
|
pub scope_key: String,
|
|
pub job_id: Uuid,
|
|
pub lease_id: Uuid,
|
|
pub owner_id: String,
|
|
pub bucket: String,
|
|
pub prefix: String,
|
|
pub tier: Option<String>,
|
|
pub dry_run: bool,
|
|
pub lease_expires_at_unix_nanos: i128,
|
|
pub updated_at_unix_nanos: i128,
|
|
}
|
|
|
|
impl ManualTransitionScopeAdmission {
|
|
pub fn from_job(record: &ManualTransitionJobRecord) -> Self {
|
|
Self {
|
|
schema: MANUAL_TRANSITION_JOB_SCHEMA.to_string(),
|
|
scope_key: record.scope_key.clone(),
|
|
job_id: record.job_id,
|
|
lease_id: record.lease_id,
|
|
owner_id: record.owner_id.clone(),
|
|
bucket: record.bucket.clone(),
|
|
prefix: record.prefix.clone(),
|
|
tier: record.tier.clone(),
|
|
dry_run: record.dry_run,
|
|
lease_expires_at_unix_nanos: record.lease_expires_at_unix_nanos,
|
|
updated_at_unix_nanos: record.updated_at_unix_nanos,
|
|
}
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<(), ManualTransitionJobError> {
|
|
if self.schema != MANUAL_TRANSITION_JOB_SCHEMA {
|
|
return Err(ManualTransitionJobError::UnsupportedSchema(self.schema.clone()));
|
|
}
|
|
if self.job_id.is_nil() {
|
|
return Err(ManualTransitionJobError::Corrupt("job_id is nil"));
|
|
}
|
|
if self.lease_id.is_nil() {
|
|
return Err(ManualTransitionJobError::Corrupt("lease_id is nil"));
|
|
}
|
|
if self.scope_key.is_empty() {
|
|
return Err(ManualTransitionJobError::Corrupt("scope_key is empty"));
|
|
}
|
|
if self.bucket.is_empty() {
|
|
return Err(ManualTransitionJobError::Corrupt("bucket is empty"));
|
|
}
|
|
if self.owner_id.trim().is_empty() {
|
|
return Err(ManualTransitionJobError::Corrupt("owner_id is empty"));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ManualTransitionScopeAdmissionClaim {
|
|
Claimed,
|
|
Conflict(Box<ManualTransitionScopeAdmission>),
|
|
}
|
|
|
|
pub fn manual_transition_scope_key(bucket: &str, options: &ManualTransitionRunOptions) -> String {
|
|
let mut scope = String::new();
|
|
scope.push_str(bucket);
|
|
scope.push('\0');
|
|
// Durable admission v1 is bucket-level: a single CAS alias must cover prefix
|
|
// overlap and wildcard-tier conflicts without a non-atomic scope scan.
|
|
scope.push_str(if options.dry_run { "dry_run" } else { "run" });
|
|
hex_sha256(scope.as_bytes(), ToOwned::to_owned)
|
|
}
|
|
|
|
pub(crate) fn legacy_manual_transition_scope_key(bucket: &str, options: &ManualTransitionRunOptions) -> String {
|
|
let mut scope = String::new();
|
|
scope.push_str(bucket);
|
|
scope.push('\0');
|
|
scope.push_str(&options.prefix);
|
|
scope.push('\0');
|
|
if let Some(tier) = &options.tier {
|
|
scope.push_str(&tier.to_ascii_uppercase());
|
|
}
|
|
scope.push('\0');
|
|
scope.push_str(if options.dry_run { "dry_run" } else { "run" });
|
|
hex_sha256(scope.as_bytes(), ToOwned::to_owned)
|
|
}
|
|
|
|
pub fn manual_transition_job_record_object_name(job_id: Uuid) -> Result<String, ManualTransitionJobError> {
|
|
if job_id.is_nil() {
|
|
return Err(ManualTransitionJobError::Corrupt("job_id is nil"));
|
|
}
|
|
let job_key = job_id.simple().to_string();
|
|
Ok(format!(
|
|
"{}/{}/{}/{}.json",
|
|
MANUAL_TRANSITION_JOB_RECORD_PREFIX,
|
|
&job_key[..2],
|
|
&job_key[2..4],
|
|
job_key
|
|
))
|
|
}
|
|
|
|
fn manual_transition_job_sharded_prefix(prefix: &str, job_id: Uuid) -> Result<String, ManualTransitionJobError> {
|
|
if job_id.is_nil() {
|
|
return Err(ManualTransitionJobError::Corrupt("job_id is nil"));
|
|
}
|
|
let job_key = job_id.simple().to_string();
|
|
Ok(format!("{}/{}/{}/{}", prefix, &job_key[..2], &job_key[2..4], job_key))
|
|
}
|
|
|
|
pub fn manual_transition_worker_result_task_key(bucket: &str, object: &str, version_id: Option<Uuid>) -> String {
|
|
let version = version_id.map(|version| version.to_string()).unwrap_or_default();
|
|
let mut material = Vec::with_capacity(bucket.len() + object.len() + version.len() + 32);
|
|
push_len_prefixed(&mut material, bucket.as_bytes());
|
|
push_len_prefixed(&mut material, object.as_bytes());
|
|
push_len_prefixed(&mut material, version.as_bytes());
|
|
hex_sha256(&material, ToOwned::to_owned)
|
|
}
|
|
|
|
fn push_len_prefixed(out: &mut Vec<u8>, value: &[u8]) {
|
|
out.extend_from_slice(value.len().to_string().as_bytes());
|
|
out.push(b':');
|
|
out.extend_from_slice(value);
|
|
}
|
|
|
|
pub fn manual_transition_worker_result_object_prefix(job_id: Uuid) -> Result<String, ManualTransitionJobError> {
|
|
manual_transition_job_sharded_prefix(MANUAL_TRANSITION_WORKER_RESULT_PREFIX, job_id)
|
|
}
|
|
|
|
pub fn manual_transition_task_object_prefix(job_id: Uuid) -> Result<String, ManualTransitionJobError> {
|
|
manual_transition_job_sharded_prefix(MANUAL_TRANSITION_TASK_PREFIX, job_id)
|
|
}
|
|
|
|
pub fn manual_transition_task_object_name(job_id: Uuid, task_key: &str) -> Result<String, ManualTransitionJobError> {
|
|
if !is_sha256_checksum(task_key) {
|
|
return Err(ManualTransitionJobError::Corrupt("task record task_key is not a sha256 checksum"));
|
|
}
|
|
Ok(format!("{}/{}.json", manual_transition_task_object_prefix(job_id)?, task_key))
|
|
}
|
|
|
|
pub fn manual_transition_worker_result_object_name(job_id: Uuid, task_key: &str) -> Result<String, ManualTransitionJobError> {
|
|
if !is_sha256_checksum(task_key) {
|
|
return Err(ManualTransitionJobError::Corrupt("worker result task_key is not a sha256 checksum"));
|
|
}
|
|
Ok(format!("{}/{}.json", manual_transition_worker_result_object_prefix(job_id)?, task_key))
|
|
}
|
|
|
|
fn manual_transition_worker_result_task_key_from_object_name(
|
|
job_id: Uuid,
|
|
object_name: &str,
|
|
) -> Result<String, ManualTransitionJobError> {
|
|
let prefix = manual_transition_worker_result_object_prefix(job_id)?;
|
|
let rest = object_name
|
|
.strip_prefix(&prefix)
|
|
.ok_or(ManualTransitionJobError::Corrupt("worker result object prefix is invalid"))?
|
|
.strip_prefix('/')
|
|
.ok_or(ManualTransitionJobError::Corrupt("worker result object path is invalid"))?;
|
|
let task_key = rest
|
|
.strip_suffix(".json")
|
|
.ok_or(ManualTransitionJobError::Corrupt("worker result suffix is invalid"))?;
|
|
if task_key.contains('/') || !is_sha256_checksum(task_key) {
|
|
return Err(ManualTransitionJobError::Corrupt("worker result task_key is invalid"));
|
|
}
|
|
Ok(task_key.to_string())
|
|
}
|
|
|
|
fn manual_transition_task_key_from_object_name(job_id: Uuid, object_name: &str) -> Result<String, ManualTransitionJobError> {
|
|
let prefix = manual_transition_task_object_prefix(job_id)?;
|
|
let rest = object_name
|
|
.strip_prefix(&prefix)
|
|
.ok_or(ManualTransitionJobError::Corrupt("task record object prefix is invalid"))?
|
|
.strip_prefix('/')
|
|
.ok_or(ManualTransitionJobError::Corrupt("task record object path is invalid"))?;
|
|
let task_key = rest
|
|
.strip_suffix(".json")
|
|
.ok_or(ManualTransitionJobError::Corrupt("task record suffix is invalid"))?;
|
|
if task_key.contains('/') || !is_sha256_checksum(task_key) {
|
|
return Err(ManualTransitionJobError::Corrupt("task record task_key is invalid"));
|
|
}
|
|
Ok(task_key.to_string())
|
|
}
|
|
|
|
pub fn manual_transition_job_id_from_record_object_name(object_name: &str) -> Result<Uuid, ManualTransitionJobError> {
|
|
let Some(rest) = object_name.strip_prefix(MANUAL_TRANSITION_JOB_RECORD_PREFIX) else {
|
|
return Err(ManualTransitionJobError::Corrupt("job record object prefix is invalid"));
|
|
};
|
|
let rest = rest
|
|
.strip_prefix('/')
|
|
.ok_or(ManualTransitionJobError::Corrupt("job record object path is invalid"))?;
|
|
let mut parts = rest.split('/');
|
|
let first = parts
|
|
.next()
|
|
.ok_or(ManualTransitionJobError::Corrupt("job record first shard is missing"))?;
|
|
let second = parts
|
|
.next()
|
|
.ok_or(ManualTransitionJobError::Corrupt("job record second shard is missing"))?;
|
|
let file = parts
|
|
.next()
|
|
.ok_or(ManualTransitionJobError::Corrupt("job record file is missing"))?;
|
|
if parts.next().is_some() {
|
|
return Err(ManualTransitionJobError::Corrupt("job record object path has extra components"));
|
|
}
|
|
let job_key = file
|
|
.strip_suffix(".json")
|
|
.ok_or(ManualTransitionJobError::Corrupt("job record suffix is invalid"))?;
|
|
if job_key.len() != 32 || first.len() != 2 || second.len() != 2 || first != &job_key[..2] || second != &job_key[2..4] {
|
|
return Err(ManualTransitionJobError::Corrupt("job record shards do not match job id"));
|
|
}
|
|
Uuid::parse_str(job_key).map_err(|_| ManualTransitionJobError::Corrupt("job record job id is invalid"))
|
|
}
|
|
|
|
pub fn manual_transition_scope_record_object_name(scope_key: &str) -> Result<String, ManualTransitionJobError> {
|
|
if scope_key.len() != 64
|
|
|| !scope_key
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
|
{
|
|
return Err(ManualTransitionJobError::Corrupt("scope_key is not a lowercase sha256 hex digest"));
|
|
}
|
|
Ok(format!(
|
|
"{}/{}/{}/{}.json",
|
|
MANUAL_TRANSITION_SCOPE_RECORD_PREFIX,
|
|
&scope_key[..2],
|
|
&scope_key[2..4],
|
|
scope_key
|
|
))
|
|
}
|
|
|
|
pub async fn save_manual_transition_job_record(api: Arc<ECStore>, job: &ManualTransitionJobRecord) -> EcstoreResult<()> {
|
|
let object = manual_transition_job_record_object_name(job.job_id).map_err(manual_transition_job_store_error)?;
|
|
let data = job.encode().map_err(manual_transition_job_store_error)?;
|
|
config_boundary::save_config(api, &object, data).await
|
|
}
|
|
|
|
pub async fn load_manual_transition_job_record(api: Arc<ECStore>, job_id: Uuid) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
let (record, _) = load_manual_transition_job_record_with_etag(api, job_id).await?;
|
|
Ok(record)
|
|
}
|
|
|
|
pub async fn load_manual_transition_job_record_with_etag(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
) -> EcstoreResult<(ManualTransitionJobRecord, String)> {
|
|
let object = manual_transition_job_record_object_name(job_id).map_err(manual_transition_job_store_error)?;
|
|
let (data, object_info) = config_boundary::read_config_with_metadata(api, &object, &ObjectOptions::default()).await?;
|
|
let etag = object_info
|
|
.etag
|
|
.filter(|etag| !etag.trim().is_empty())
|
|
.ok_or_else(|| Error::other("manual transition job record is missing an ETag"))?;
|
|
let record = ManualTransitionJobRecord::decode(job_id, &data).map_err(manual_transition_job_store_error)?;
|
|
Ok((record, etag))
|
|
}
|
|
|
|
pub async fn save_manual_transition_job_record_if_current(
|
|
api: Arc<ECStore>,
|
|
job: &ManualTransitionJobRecord,
|
|
current_etag: &str,
|
|
) -> EcstoreResult<()> {
|
|
if current_etag.trim().is_empty() {
|
|
return Err(Error::other("manual transition job current ETag is empty"));
|
|
}
|
|
let object = manual_transition_job_record_object_name(job.job_id).map_err(manual_transition_job_store_error)?;
|
|
let data = job.encode().map_err(manual_transition_job_store_error)?;
|
|
config_boundary::save_config_with_opts_quiet(
|
|
api,
|
|
&object,
|
|
data,
|
|
&ObjectOptions {
|
|
max_parity: true,
|
|
http_preconditions: Some(HTTPPreconditions {
|
|
if_match: Some(current_etag.to_string()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Applies a job-record mutation with optimistic concurrency control.
|
|
///
|
|
/// The mutation returns whether the record needs to be persisted. When a lease
|
|
/// is supplied, ownership is checked again after every conflicting write.
|
|
pub async fn update_manual_transition_job_record<F>(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
expected_lease_id: Option<Uuid>,
|
|
update: F,
|
|
) -> EcstoreResult<ManualTransitionJobRecord>
|
|
where
|
|
F: FnMut(&mut ManualTransitionJobRecord) -> bool,
|
|
{
|
|
update_manual_transition_job_record_from(api, job_id, expected_lease_id, None, update).await
|
|
}
|
|
|
|
async fn update_manual_transition_job_record_from<F>(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
expected_lease_id: Option<Uuid>,
|
|
mut current: Option<(ManualTransitionJobRecord, String)>,
|
|
mut update: F,
|
|
) -> EcstoreResult<ManualTransitionJobRecord>
|
|
where
|
|
F: FnMut(&mut ManualTransitionJobRecord) -> bool,
|
|
{
|
|
for _ in 0..MANUAL_TRANSITION_JOB_CAS_RETRIES {
|
|
let (mut record, etag) = match current.take() {
|
|
Some(current) => current,
|
|
None => load_manual_transition_job_record_with_etag(api.clone(), job_id).await?,
|
|
};
|
|
if expected_lease_id.is_some_and(|lease_id| record.lease_id != lease_id) {
|
|
return Err(Error::PreconditionFailed);
|
|
}
|
|
if !update(&mut record) {
|
|
return Ok(record);
|
|
}
|
|
#[cfg(test)]
|
|
pause_manual_transition_job_before_first_cas(job_id).await;
|
|
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
|
Ok(()) => return Ok(record),
|
|
Err(Error::PreconditionFailed) => continue,
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
Err(Error::PreconditionFailed)
|
|
}
|
|
|
|
pub(crate) async fn save_manual_transition_worker_result_if_absent(
|
|
api: Arc<ECStore>,
|
|
record: &ManualTransitionWorkerResultRecord,
|
|
) -> EcstoreResult<bool> {
|
|
let object = manual_transition_worker_result_object_name(record.job_id, &record.task_key)
|
|
.map_err(manual_transition_job_store_error)?;
|
|
let data = record.encode().map_err(manual_transition_job_store_error)?;
|
|
match config_boundary::save_config_with_opts(
|
|
api,
|
|
&object,
|
|
data,
|
|
&ObjectOptions {
|
|
max_parity: true,
|
|
http_preconditions: Some(HTTPPreconditions {
|
|
if_none_match: Some("*".to_string()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => Ok(true),
|
|
Err(Error::PreconditionFailed) => Ok(false),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn save_manual_transition_task_if_absent(
|
|
api: Arc<ECStore>,
|
|
record: &ManualTransitionTaskRecord,
|
|
) -> EcstoreResult<bool> {
|
|
let object =
|
|
manual_transition_task_object_name(record.job_id, &record.task_key).map_err(manual_transition_job_store_error)?;
|
|
let data = record.encode().map_err(manual_transition_job_store_error)?;
|
|
match config_boundary::save_config_with_opts(
|
|
api,
|
|
&object,
|
|
data,
|
|
&ObjectOptions {
|
|
max_parity: true,
|
|
http_preconditions: Some(HTTPPreconditions {
|
|
if_none_match: Some("*".to_string()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => Ok(true),
|
|
Err(Error::PreconditionFailed) => Ok(false),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
#[allow(
|
|
dead_code,
|
|
reason = "MinIO-parity tier/lifecycle entry point that this port never wired (backlog#1823)"
|
|
)]
|
|
pub async fn load_manual_transition_task_record(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
task_key: &str,
|
|
) -> EcstoreResult<ManualTransitionTaskRecord> {
|
|
let object = manual_transition_task_object_name(job_id, task_key).map_err(manual_transition_job_store_error)?;
|
|
let data = config_boundary::read_config(api, &object).await?;
|
|
ManualTransitionTaskRecord::decode(job_id, task_key, &data).map_err(manual_transition_job_store_error)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
struct ManualTransitionTaskJournalStats {
|
|
queued: u64,
|
|
}
|
|
|
|
enum ManualTransitionTaskJournal {
|
|
Stats(ManualTransitionTaskJournalStats),
|
|
Corrupt(String),
|
|
}
|
|
|
|
async fn scan_manual_transition_task_journal(api: Arc<ECStore>, job_id: Uuid) -> EcstoreResult<ManualTransitionTaskJournal> {
|
|
let prefix = manual_transition_task_object_prefix(job_id).map_err(manual_transition_job_store_error)?;
|
|
let mut marker = None;
|
|
let mut stats = ManualTransitionTaskJournalStats::default();
|
|
loop {
|
|
let page = api
|
|
.clone()
|
|
.list_objects_v2(
|
|
RUSTFS_META_BUCKET,
|
|
&prefix,
|
|
marker,
|
|
None,
|
|
MANUAL_TRANSITION_TASK_SCAN_LIMIT,
|
|
false,
|
|
None,
|
|
false,
|
|
)
|
|
.await?;
|
|
for object in page.objects {
|
|
let task_key = match manual_transition_task_key_from_object_name(job_id, &object.name) {
|
|
Ok(task_key) => task_key,
|
|
Err(err) => return Ok(ManualTransitionTaskJournal::Corrupt(err.to_string())),
|
|
};
|
|
let object_name = manual_transition_task_object_name(job_id, &task_key).map_err(manual_transition_job_store_error)?;
|
|
let data = match config_boundary::read_config(api.clone(), &object_name).await {
|
|
Ok(data) => data,
|
|
Err(err) => return Err(err),
|
|
};
|
|
if let Err(err) = ManualTransitionTaskRecord::decode(job_id, &task_key, &data) {
|
|
return Ok(ManualTransitionTaskJournal::Corrupt(err.to_string()));
|
|
}
|
|
stats.queued = stats.queued.saturating_add(1);
|
|
}
|
|
if !page.is_truncated {
|
|
return Ok(ManualTransitionTaskJournal::Stats(stats));
|
|
}
|
|
let Some(next_marker) = page.next_continuation_token else {
|
|
return Err(Error::other("manual transition task journal page is truncated without a next marker"));
|
|
};
|
|
marker = Some(next_marker);
|
|
}
|
|
}
|
|
|
|
#[allow(
|
|
dead_code,
|
|
reason = "MinIO-parity tier/lifecycle entry point that this port never wired (backlog#1823)"
|
|
)]
|
|
pub async fn load_manual_transition_worker_result_stats(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
) -> EcstoreResult<ManualTransitionWorkerResultStats> {
|
|
match scan_manual_transition_worker_result_journal(api, job_id).await? {
|
|
ManualTransitionWorkerResultJournal::Stats(stats) => Ok(stats.stats),
|
|
ManualTransitionWorkerResultJournal::Corrupt(error) => Err(Error::other(error)),
|
|
}
|
|
}
|
|
|
|
enum ManualTransitionWorkerResultJournal {
|
|
Stats(ManualTransitionWorkerResultJournalStats),
|
|
Corrupt(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
struct ManualTransitionWorkerResultJournalStats {
|
|
stats: ManualTransitionWorkerResultStats,
|
|
task_keys: BTreeSet<String>,
|
|
}
|
|
|
|
impl ManualTransitionWorkerResultJournalStats {
|
|
fn record(&mut self, result: ManualTransitionWorkerResultRecord) {
|
|
self.task_keys.insert(result.task_key.clone());
|
|
self.stats.record(result.result, result.failure_reason);
|
|
}
|
|
}
|
|
|
|
pub async fn load_manual_transition_pending_task_records(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
limit: usize,
|
|
) -> EcstoreResult<Vec<ManualTransitionTaskRecord>> {
|
|
if limit == 0 {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let result_keys = match scan_manual_transition_worker_result_journal(api.clone(), job_id).await? {
|
|
ManualTransitionWorkerResultJournal::Stats(stats) => stats.task_keys,
|
|
ManualTransitionWorkerResultJournal::Corrupt(error) => return Err(Error::other(error)),
|
|
};
|
|
let prefix = manual_transition_task_object_prefix(job_id).map_err(manual_transition_job_store_error)?;
|
|
let mut marker = None;
|
|
let scan_limit = usize::try_from(MANUAL_TRANSITION_TASK_SCAN_LIMIT)
|
|
.map_err(|_| Error::other("manual transition task scan limit is invalid"))?;
|
|
let mut pending = Vec::with_capacity(limit.min(scan_limit));
|
|
|
|
loop {
|
|
let page = api
|
|
.clone()
|
|
.list_objects_v2(
|
|
RUSTFS_META_BUCKET,
|
|
&prefix,
|
|
marker,
|
|
None,
|
|
MANUAL_TRANSITION_TASK_SCAN_LIMIT,
|
|
false,
|
|
None,
|
|
false,
|
|
)
|
|
.await?;
|
|
|
|
for object in page.objects {
|
|
let task_key =
|
|
manual_transition_task_key_from_object_name(job_id, &object.name).map_err(manual_transition_job_store_error)?;
|
|
if result_keys.contains(&task_key) {
|
|
continue;
|
|
}
|
|
let object_name = manual_transition_task_object_name(job_id, &task_key).map_err(manual_transition_job_store_error)?;
|
|
let data = config_boundary::read_config(api.clone(), &object_name).await?;
|
|
let task = ManualTransitionTaskRecord::decode(job_id, &task_key, &data).map_err(manual_transition_job_store_error)?;
|
|
pending.push(task);
|
|
if pending.len() == limit {
|
|
return Ok(pending);
|
|
}
|
|
}
|
|
|
|
if !page.is_truncated {
|
|
return Ok(pending);
|
|
}
|
|
let Some(next_marker) = page.next_continuation_token else {
|
|
return Err(Error::other("manual transition task journal page is truncated without a next marker"));
|
|
};
|
|
marker = Some(next_marker);
|
|
}
|
|
}
|
|
|
|
async fn scan_manual_transition_worker_result_journal(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
) -> EcstoreResult<ManualTransitionWorkerResultJournal> {
|
|
let prefix = manual_transition_worker_result_object_prefix(job_id).map_err(manual_transition_job_store_error)?;
|
|
let mut marker = None;
|
|
let mut stats = ManualTransitionWorkerResultJournalStats::default();
|
|
loop {
|
|
let page = api
|
|
.clone()
|
|
.list_objects_v2(
|
|
RUSTFS_META_BUCKET,
|
|
&prefix,
|
|
marker,
|
|
None,
|
|
MANUAL_TRANSITION_WORKER_RESULT_SCAN_LIMIT,
|
|
false,
|
|
None,
|
|
false,
|
|
)
|
|
.await?;
|
|
for object in page.objects {
|
|
let task_key = match manual_transition_worker_result_task_key_from_object_name(job_id, &object.name) {
|
|
Ok(task_key) => task_key,
|
|
Err(err) => return Ok(ManualTransitionWorkerResultJournal::Corrupt(err.to_string())),
|
|
};
|
|
let object_name =
|
|
manual_transition_worker_result_object_name(job_id, &task_key).map_err(manual_transition_job_store_error)?;
|
|
let data = match config_boundary::read_config(api.clone(), &object_name).await {
|
|
Ok(data) => data,
|
|
Err(err) => return Err(err),
|
|
};
|
|
let result = match ManualTransitionWorkerResultRecord::decode(job_id, &task_key, &data) {
|
|
Ok(result) => result,
|
|
Err(err) => return Ok(ManualTransitionWorkerResultJournal::Corrupt(err.to_string())),
|
|
};
|
|
stats.record(result);
|
|
}
|
|
if !page.is_truncated {
|
|
return Ok(ManualTransitionWorkerResultJournal::Stats(stats));
|
|
}
|
|
let Some(next_marker) = page.next_continuation_token else {
|
|
return Err(Error::other("manual transition worker result page is truncated without a next marker"));
|
|
};
|
|
marker = Some(next_marker);
|
|
}
|
|
}
|
|
|
|
#[allow(
|
|
dead_code,
|
|
reason = "MinIO-parity tier/lifecycle entry point that this port never wired (backlog#1823)"
|
|
)]
|
|
pub async fn reconcile_manual_transition_worker_results(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
reconcile_manual_transition_worker_results_inner(api, job_id, None, queue_snapshot, false).await
|
|
}
|
|
|
|
pub(crate) async fn reconcile_manual_transition_worker_results_if_owned(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
expected_lease_id: Uuid,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
reconcile_manual_transition_worker_results_inner(api, job_id, Some(expected_lease_id), queue_snapshot, false).await
|
|
}
|
|
|
|
async fn reconcile_manual_transition_worker_results_inner(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
expected_lease_id: Option<Uuid>,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
mark_missing_results_unknown: bool,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
let task_stats = match scan_manual_transition_task_journal(api.clone(), job_id).await? {
|
|
ManualTransitionTaskJournal::Stats(stats) => stats,
|
|
ManualTransitionTaskJournal::Corrupt(error) => {
|
|
return mark_manual_transition_job_unknown_for_task_journal_error(
|
|
api,
|
|
job_id,
|
|
expected_lease_id,
|
|
error,
|
|
queue_snapshot,
|
|
)
|
|
.await;
|
|
}
|
|
};
|
|
let stats = match scan_manual_transition_worker_result_journal(api.clone(), job_id).await? {
|
|
ManualTransitionWorkerResultJournal::Stats(stats) => stats,
|
|
ManualTransitionWorkerResultJournal::Corrupt(error) => {
|
|
return mark_manual_transition_job_unknown_for_worker_result_journal_error(
|
|
api,
|
|
job_id,
|
|
expected_lease_id,
|
|
error,
|
|
queue_snapshot,
|
|
)
|
|
.await;
|
|
}
|
|
};
|
|
let mut changed = false;
|
|
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| {
|
|
let counts_changed = record.apply_worker_result_counts(
|
|
stats.stats.completed,
|
|
stats.stats.failed,
|
|
&stats.stats.tier_failure_by_reason,
|
|
task_stats.queued,
|
|
queue_snapshot,
|
|
);
|
|
let became_unknown = mark_missing_results_unknown && record.mark_unknown_if_worker_results_lost(queue_snapshot);
|
|
changed = counts_changed || became_unknown;
|
|
changed
|
|
})
|
|
.await?;
|
|
if !changed {
|
|
return Ok(record);
|
|
}
|
|
if record.is_terminal() {
|
|
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
|
} else {
|
|
renew_manual_transition_scope_admission_from_job(api, &record).await?;
|
|
}
|
|
Ok(record)
|
|
}
|
|
|
|
async fn mark_manual_transition_job_unknown_for_task_journal_error(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
expected_lease_id: Option<Uuid>,
|
|
error: String,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
let mut changed = false;
|
|
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| {
|
|
changed = record.mark_unknown_for_task_journal_error(error.clone(), queue_snapshot);
|
|
changed
|
|
})
|
|
.await?;
|
|
if changed && record.is_terminal() {
|
|
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
|
}
|
|
Ok(record)
|
|
}
|
|
|
|
async fn mark_manual_transition_job_unknown_for_worker_result_journal_error(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
expected_lease_id: Option<Uuid>,
|
|
error: String,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
let mut changed = false;
|
|
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| {
|
|
changed = record.mark_unknown_for_worker_result_journal_error(error.clone(), queue_snapshot);
|
|
changed
|
|
})
|
|
.await?;
|
|
if changed && record.is_terminal() {
|
|
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
|
}
|
|
Ok(record)
|
|
}
|
|
|
|
pub async fn save_manual_transition_scope_admission_if_absent(
|
|
api: Arc<ECStore>,
|
|
admission: &ManualTransitionScopeAdmission,
|
|
) -> EcstoreResult<()> {
|
|
admission.validate().map_err(manual_transition_job_store_error)?;
|
|
let object = manual_transition_scope_record_object_name(&admission.scope_key).map_err(manual_transition_job_store_error)?;
|
|
let data = serde_json::to_vec(admission).map_err(Error::other)?;
|
|
config_boundary::save_config_with_opts(
|
|
api,
|
|
&object,
|
|
data,
|
|
&ObjectOptions {
|
|
max_parity: true,
|
|
http_preconditions: Some(HTTPPreconditions {
|
|
if_none_match: Some("*".to_string()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn load_manual_transition_scope_admission(
|
|
api: Arc<ECStore>,
|
|
scope_key: &str,
|
|
) -> EcstoreResult<ManualTransitionScopeAdmission> {
|
|
let (admission, _) = load_manual_transition_scope_admission_with_etag(api, scope_key).await?;
|
|
Ok(admission)
|
|
}
|
|
|
|
pub async fn load_manual_transition_scope_admission_with_etag(
|
|
api: Arc<ECStore>,
|
|
scope_key: &str,
|
|
) -> EcstoreResult<(ManualTransitionScopeAdmission, String)> {
|
|
let object = manual_transition_scope_record_object_name(scope_key).map_err(manual_transition_job_store_error)?;
|
|
let (data, object_info) = config_boundary::read_config_with_metadata(api, &object, &ObjectOptions::default()).await?;
|
|
let etag = object_info
|
|
.etag
|
|
.filter(|etag| !etag.trim().is_empty())
|
|
.ok_or_else(|| Error::other("manual transition scope admission is missing an ETag"))?;
|
|
let admission: ManualTransitionScopeAdmission = serde_json::from_slice(&data).map_err(Error::other)?;
|
|
admission.validate().map_err(manual_transition_job_store_error)?;
|
|
Ok((admission, etag))
|
|
}
|
|
|
|
pub async fn save_manual_transition_scope_admission_if_current(
|
|
api: Arc<ECStore>,
|
|
admission: &ManualTransitionScopeAdmission,
|
|
current_etag: &str,
|
|
) -> EcstoreResult<()> {
|
|
if current_etag.trim().is_empty() {
|
|
return Err(Error::other("manual transition scope admission current ETag is empty"));
|
|
}
|
|
admission.validate().map_err(manual_transition_job_store_error)?;
|
|
let object = manual_transition_scope_record_object_name(&admission.scope_key).map_err(manual_transition_job_store_error)?;
|
|
let data = serde_json::to_vec(admission).map_err(Error::other)?;
|
|
match config_boundary::save_config_with_opts(
|
|
api,
|
|
&object,
|
|
data,
|
|
&ObjectOptions {
|
|
max_parity: true,
|
|
http_preconditions: Some(HTTPPreconditions {
|
|
if_match: Some(current_etag.to_string()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Err(Error::ObjectNotFound(bucket, current_object)) if bucket == RUSTFS_META_BUCKET && current_object == object => {
|
|
Err(Error::PreconditionFailed)
|
|
}
|
|
result => result,
|
|
}
|
|
}
|
|
|
|
pub async fn claim_manual_transition_scope_admission(
|
|
api: Arc<ECStore>,
|
|
admission: &ManualTransitionScopeAdmission,
|
|
) -> EcstoreResult<ManualTransitionScopeAdmissionClaim> {
|
|
loop {
|
|
match save_manual_transition_scope_admission_if_absent(api.clone(), admission).await {
|
|
Ok(()) => return finish_manual_transition_scope_admission_claim(api, admission).await,
|
|
Err(Error::PreconditionFailed) => {}
|
|
Err(err) => return Err(err),
|
|
}
|
|
|
|
let (active, etag) = match load_manual_transition_scope_admission_with_etag(api.clone(), &admission.scope_key).await {
|
|
Ok(active) => active,
|
|
Err(Error::ConfigNotFound) => continue,
|
|
Err(err) => return Err(err),
|
|
};
|
|
let scope_lease_expired = manual_transition_scope_admission_lease_expired(&active);
|
|
let active_job_reclaimable = if active.job_id == admission.job_id {
|
|
scope_lease_expired
|
|
} else {
|
|
match load_manual_transition_job_record(api.clone(), active.job_id).await {
|
|
Ok(active_job) => {
|
|
active_job.is_terminal() || (scope_lease_expired && manual_transition_job_lease_expired(&active_job))
|
|
}
|
|
// Missing active job metadata can be transient (for example, immediately after admission creation);
|
|
// require an expired scope lease before treating it as reclaimable.
|
|
Err(Error::ConfigNotFound) => scope_lease_expired,
|
|
Err(err) => return Err(err),
|
|
}
|
|
};
|
|
if active_job_reclaimable {
|
|
match save_manual_transition_scope_admission_if_current(api.clone(), admission, &etag).await {
|
|
Ok(()) => return finish_manual_transition_scope_admission_claim(api, admission).await,
|
|
Err(Error::PreconditionFailed) => continue,
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
|
|
return Ok(ManualTransitionScopeAdmissionClaim::Conflict(Box::new(active)));
|
|
}
|
|
}
|
|
|
|
async fn finish_manual_transition_scope_admission_claim(
|
|
api: Arc<ECStore>,
|
|
admission: &ManualTransitionScopeAdmission,
|
|
) -> EcstoreResult<ManualTransitionScopeAdmissionClaim> {
|
|
if let Some(active) = find_active_legacy_manual_transition_scope_conflict(api.clone(), admission).await? {
|
|
delete_manual_transition_scope_admission_if_current(api, &admission.scope_key, admission.job_id, admission.lease_id)
|
|
.await?;
|
|
return Ok(ManualTransitionScopeAdmissionClaim::Conflict(Box::new(active)));
|
|
}
|
|
Ok(ManualTransitionScopeAdmissionClaim::Claimed)
|
|
}
|
|
|
|
async fn find_active_legacy_manual_transition_scope_conflict(
|
|
api: Arc<ECStore>,
|
|
admission: &ManualTransitionScopeAdmission,
|
|
) -> EcstoreResult<Option<ManualTransitionScopeAdmission>> {
|
|
let mut marker = None;
|
|
loop {
|
|
let page = api
|
|
.clone()
|
|
.list_objects_v2(
|
|
RUSTFS_META_BUCKET,
|
|
MANUAL_TRANSITION_JOB_RECORD_PREFIX,
|
|
marker,
|
|
None,
|
|
MANUAL_TRANSITION_LEGACY_SCOPE_SCAN_LIMIT,
|
|
false,
|
|
None,
|
|
false,
|
|
)
|
|
.await?;
|
|
for object in page.objects {
|
|
let job_id =
|
|
manual_transition_job_id_from_record_object_name(&object.name).map_err(manual_transition_job_store_error)?;
|
|
if job_id == admission.job_id {
|
|
continue;
|
|
}
|
|
let record = match load_manual_transition_job_record(api.clone(), job_id).await {
|
|
Ok(record) => record,
|
|
Err(Error::ConfigNotFound) => continue,
|
|
Err(err) => return Err(err),
|
|
};
|
|
if record.scope_key == admission.scope_key {
|
|
continue;
|
|
}
|
|
let legacy_scope_key = legacy_manual_transition_scope_key(
|
|
&record.bucket,
|
|
&ManualTransitionRunOptions {
|
|
prefix: record.prefix.clone(),
|
|
tier: record.tier.clone(),
|
|
dry_run: record.dry_run,
|
|
..Default::default()
|
|
},
|
|
);
|
|
if record.scope_key != legacy_scope_key {
|
|
continue;
|
|
}
|
|
if record.bucket == admission.bucket
|
|
&& record.dry_run == admission.dry_run
|
|
&& !record.is_terminal()
|
|
&& !manual_transition_job_lease_expired(&record)
|
|
{
|
|
return Ok(Some(ManualTransitionScopeAdmission::from_job(&record)));
|
|
}
|
|
}
|
|
if !page.is_truncated {
|
|
return Ok(None);
|
|
}
|
|
marker = page.next_continuation_token;
|
|
}
|
|
}
|
|
|
|
pub async fn request_manual_transition_job_cancel(api: Arc<ECStore>, job_id: Uuid) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
update_manual_transition_job_record(api, job_id, None, |record| {
|
|
if record.is_terminal() || record.cancel_requested {
|
|
return false;
|
|
}
|
|
record.mark_cancel_requested();
|
|
true
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn persist_manual_transition_job_progress(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
report: &ManualTransitionRunReport,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
let current = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
|
persist_manual_transition_job_progress_inner(api, job_id, current.0.lease_id, Some(current), report, queue_snapshot).await
|
|
}
|
|
|
|
pub async fn persist_manual_transition_job_progress_if_owned(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
expected_lease_id: Uuid,
|
|
report: &ManualTransitionRunReport,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
persist_manual_transition_job_progress_inner(api, job_id, expected_lease_id, None, report, queue_snapshot).await
|
|
}
|
|
|
|
async fn persist_manual_transition_job_progress_inner(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
expected_lease_id: Uuid,
|
|
current: Option<(ManualTransitionJobRecord, String)>,
|
|
report: &ManualTransitionRunReport,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
let record = update_manual_transition_job_record_from(api.clone(), job_id, Some(expected_lease_id), current, |record| {
|
|
if record.state != ManualTransitionJobState::Running {
|
|
return false;
|
|
}
|
|
record.update_running_progress(report.clone(), queue_snapshot);
|
|
true
|
|
})
|
|
.await?;
|
|
if record.state == ManualTransitionJobState::Running {
|
|
renew_manual_transition_scope_admission_from_job(api, &record).await?;
|
|
}
|
|
Ok(record)
|
|
}
|
|
|
|
pub async fn record_manual_transition_worker_result(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
task_key: &str,
|
|
result: ManualTransitionWorkerResult,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
record_manual_transition_worker_result_with_reason(api, job_id, task_key, result, queue_snapshot, None).await
|
|
}
|
|
|
|
pub async fn record_manual_transition_worker_result_with_reason(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
task_key: &str,
|
|
result: ManualTransitionWorkerResult,
|
|
_queue_snapshot: ManualTransitionQueueSnapshot,
|
|
failure_reason: Option<ManualTransitionWorkerFailureReason>,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
let result_record = ManualTransitionWorkerResultRecord::new_with_reason(job_id, task_key, result, failure_reason);
|
|
if !save_manual_transition_worker_result_if_absent(api.clone(), &result_record).await? {
|
|
return load_manual_transition_job_record(api, job_id).await;
|
|
}
|
|
load_manual_transition_job_record(api, job_id).await
|
|
}
|
|
|
|
pub async fn renew_manual_transition_job_lease(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
let current = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
|
renew_manual_transition_job_lease_inner(api, job_id, current.0.lease_id, Some(current), queue_snapshot).await
|
|
}
|
|
|
|
pub async fn renew_manual_transition_job_lease_if_owned(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
expected_lease_id: Uuid,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
renew_manual_transition_job_lease_inner(api, job_id, expected_lease_id, None, queue_snapshot).await
|
|
}
|
|
|
|
async fn renew_manual_transition_job_lease_inner(
|
|
api: Arc<ECStore>,
|
|
job_id: Uuid,
|
|
expected_lease_id: Uuid,
|
|
current: Option<(ManualTransitionJobRecord, String)>,
|
|
queue_snapshot: ManualTransitionQueueSnapshot,
|
|
) -> EcstoreResult<ManualTransitionJobRecord> {
|
|
let (current, current_etag) = match current {
|
|
Some(current) => current,
|
|
None => load_manual_transition_job_record_with_etag(api.clone(), job_id).await?,
|
|
};
|
|
if current.lease_id != expected_lease_id {
|
|
return Err(Error::PreconditionFailed);
|
|
}
|
|
if current.state != ManualTransitionJobState::Running {
|
|
return Ok(current);
|
|
}
|
|
if current.scan_completed && queue_snapshot.queued == 0 && queue_snapshot.active == 0 {
|
|
return reconcile_manual_transition_worker_results_inner(api, job_id, Some(expected_lease_id), queue_snapshot, true)
|
|
.await;
|
|
}
|
|
let record = update_manual_transition_job_record_from(
|
|
api.clone(),
|
|
job_id,
|
|
Some(expected_lease_id),
|
|
Some((current, current_etag)),
|
|
|record| {
|
|
if record.state != ManualTransitionJobState::Running {
|
|
return false;
|
|
}
|
|
record.renew_lease(queue_snapshot);
|
|
true
|
|
},
|
|
)
|
|
.await?;
|
|
if record.is_terminal() {
|
|
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
|
} else if record.state == ManualTransitionJobState::Running {
|
|
renew_manual_transition_scope_admission_from_job(api, &record).await?;
|
|
}
|
|
Ok(record)
|
|
}
|
|
|
|
async fn renew_manual_transition_scope_admission_from_job(
|
|
api: Arc<ECStore>,
|
|
record: &ManualTransitionJobRecord,
|
|
) -> EcstoreResult<()> {
|
|
for _ in 0..MANUAL_TRANSITION_JOB_CAS_RETRIES {
|
|
let (admission, admission_etag) =
|
|
match load_manual_transition_scope_admission_with_etag(api.clone(), &record.scope_key).await {
|
|
Ok(admission) => admission,
|
|
Err(Error::ConfigNotFound) => return Ok(()),
|
|
Err(err) => return Err(err),
|
|
};
|
|
if admission.job_id != record.job_id || admission.lease_id != record.lease_id {
|
|
return Err(Error::PreconditionFailed);
|
|
}
|
|
let mut renewed_admission = ManualTransitionScopeAdmission::from_job(record);
|
|
renewed_admission.lease_expires_at_unix_nanos = renewed_admission
|
|
.lease_expires_at_unix_nanos
|
|
.max(admission.lease_expires_at_unix_nanos);
|
|
renewed_admission.updated_at_unix_nanos = renewed_admission.updated_at_unix_nanos.max(admission.updated_at_unix_nanos);
|
|
if renewed_admission == admission {
|
|
return Ok(());
|
|
}
|
|
match save_manual_transition_scope_admission_if_current(api.clone(), &renewed_admission, &admission_etag).await {
|
|
Ok(()) => return Ok(()),
|
|
Err(Error::PreconditionFailed) => continue,
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
Err(Error::PreconditionFailed)
|
|
}
|
|
|
|
pub async fn delete_manual_transition_scope_admission_if_current(
|
|
api: Arc<ECStore>,
|
|
scope_key: &str,
|
|
job_id: Uuid,
|
|
lease_id: Uuid,
|
|
) -> EcstoreResult<bool> {
|
|
let etag = match load_manual_transition_scope_admission_with_etag(api.clone(), scope_key).await {
|
|
Ok((admission, etag)) if admission.job_id == job_id && admission.lease_id == lease_id => etag,
|
|
Ok(_) => return Ok(false),
|
|
Err(Error::ConfigNotFound) => return Ok(true),
|
|
Err(err) => return Err(err),
|
|
};
|
|
let object = manual_transition_scope_record_object_name(scope_key).map_err(manual_transition_job_store_error)?;
|
|
match config_boundary::delete_config_if_match(api, &object, &etag).await {
|
|
Ok(()) | Err(Error::ConfigNotFound) => Ok(true),
|
|
Err(Error::PreconditionFailed) => Ok(false),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
fn manual_transition_job_store_error(err: ManualTransitionJobError) -> Error {
|
|
Error::other(err)
|
|
}
|
|
|
|
pub fn manual_transition_scope_admission_lease_expired(admission: &ManualTransitionScopeAdmission) -> bool {
|
|
OffsetDateTime::now_utc().unix_timestamp_nanos() > admission.lease_expires_at_unix_nanos
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const TEST_OWNER: &str = "owner-a";
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_rejects_nil_job_id() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
record.job_id = Uuid::nil();
|
|
|
|
let err = record.encode().expect_err("nil job id must fail closed");
|
|
|
|
assert!(err.to_string().contains("job_id is nil"));
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_round_trips_with_checksum() {
|
|
let options = ManualTransitionRunOptions {
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
max_objects: Some(17),
|
|
..Default::default()
|
|
};
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
record.report.scanned = 3;
|
|
record.report.continuation_token = Some("opaque".to_string());
|
|
record.mark_cancel_requested();
|
|
record.complete(
|
|
ManualTransitionRunReport {
|
|
bucket: "bucket".to_string(),
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
cancelled: true,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot::default(),
|
|
);
|
|
|
|
let encoded = record.encode().expect("job record should encode");
|
|
let decoded = ManualTransitionJobRecord::decode(record.job_id, &encoded).expect("job record should decode");
|
|
|
|
assert_eq!(decoded.job_id, record.job_id);
|
|
assert_eq!(decoded.state, ManualTransitionJobState::Cancelled);
|
|
assert!(decoded.cancel_requested);
|
|
assert_eq!(decoded.max_objects, Some(17));
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_waits_for_worker_results() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
|
|
record.complete(
|
|
ManualTransitionRunReport {
|
|
bucket: "bucket".to_string(),
|
|
enqueued: 2,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot::default(),
|
|
);
|
|
|
|
assert!(record.scan_completed);
|
|
assert_eq!(record.state, ManualTransitionJobState::Running);
|
|
assert!(record.completed_at_unix_nanos.is_none());
|
|
|
|
record.record_worker_result(ManualTransitionWorkerResult::Completed, ManualTransitionQueueSnapshot::default());
|
|
assert_eq!(record.state, ManualTransitionJobState::Running);
|
|
assert_eq!(record.report.transition_completed, 1);
|
|
|
|
record.record_worker_result(ManualTransitionWorkerResult::TierFailure, ManualTransitionQueueSnapshot::default());
|
|
assert_eq!(record.state, ManualTransitionJobState::Partial);
|
|
assert_eq!(record.report.transition_completed, 1);
|
|
assert_eq!(record.report.transition_failed, 1);
|
|
assert_eq!(record.report.tier_failure, 1);
|
|
assert!(record.completed_at_unix_nanos.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_marks_report_cancelled_after_worker_drain() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
|
|
record.complete(
|
|
ManualTransitionRunReport {
|
|
bucket: "bucket".to_string(),
|
|
enqueued: 1,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot::default(),
|
|
);
|
|
record.mark_cancel_requested();
|
|
record.record_worker_result(ManualTransitionWorkerResult::TierFailure, ManualTransitionQueueSnapshot::default());
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Cancelled);
|
|
assert!(record.cancel_requested);
|
|
assert!(record.report.cancelled);
|
|
assert_eq!(record.report.transition_failed, 1);
|
|
assert_eq!(record.report.tier_failure, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_records_worker_failure_reasons() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
|
|
record.record_worker_result_with_reason(
|
|
ManualTransitionWorkerResult::TierFailure,
|
|
ManualTransitionQueueSnapshot::default(),
|
|
Some(ManualTransitionWorkerFailureReason::NotFound),
|
|
);
|
|
record.record_worker_result_with_reason(
|
|
ManualTransitionWorkerResult::TierFailure,
|
|
ManualTransitionQueueSnapshot::default(),
|
|
Some(ManualTransitionWorkerFailureReason::Network),
|
|
);
|
|
record.record_worker_result_with_reason(
|
|
ManualTransitionWorkerResult::TierFailure,
|
|
ManualTransitionQueueSnapshot::default(),
|
|
None,
|
|
);
|
|
|
|
assert_eq!(record.report.transition_failed, 3);
|
|
assert_eq!(record.report.tier_failure, 3);
|
|
assert_eq!(
|
|
record
|
|
.report
|
|
.tier_failure_by_reason
|
|
.get(&ManualTransitionWorkerFailureReason::NotFound),
|
|
Some(&1)
|
|
);
|
|
assert_eq!(
|
|
record
|
|
.report
|
|
.tier_failure_by_reason
|
|
.get(&ManualTransitionWorkerFailureReason::Network),
|
|
Some(&1)
|
|
);
|
|
assert_eq!(
|
|
record
|
|
.report
|
|
.tier_failure_by_reason
|
|
.get(&ManualTransitionWorkerFailureReason::Unknown),
|
|
Some(&1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_cancel_recovery_finishes_after_worker_drain() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
record.report.enqueued = 2;
|
|
record.report.transition_completed = 2;
|
|
record.mark_cancel_requested();
|
|
|
|
record.cancel_after_recovery(ManualTransitionQueueSnapshot::default());
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Cancelled);
|
|
assert!(record.report.cancelled);
|
|
assert_eq!(record.report.enqueued, 2);
|
|
assert_eq!(record.report.transition_completed, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_decode_normalizes_legacy_cancelled_report() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
record.mark_cancel_requested();
|
|
record.state = ManualTransitionJobState::Cancelled;
|
|
record.completed_at_unix_nanos = Some(OffsetDateTime::now_utc().unix_timestamp_nanos());
|
|
record.report.cancelled = false;
|
|
let encoded = record.encode().expect("legacy-shaped cancelled job should encode");
|
|
|
|
let decoded =
|
|
ManualTransitionJobRecord::decode(record.job_id, &encoded).expect("legacy-shaped cancelled job should decode");
|
|
|
|
assert_eq!(decoded.state, ManualTransitionJobState::Cancelled);
|
|
assert!(decoded.cancel_requested);
|
|
assert!(decoded.report.cancelled);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_scan_progress_preserves_worker_counters() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
record.record_worker_result(ManualTransitionWorkerResult::TierFailure, ManualTransitionQueueSnapshot::default());
|
|
|
|
record.update_running_progress(
|
|
ManualTransitionRunReport {
|
|
bucket: "bucket".to_string(),
|
|
scanned: 3,
|
|
enqueued: 1,
|
|
tier_failure: 2,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot::default(),
|
|
);
|
|
|
|
assert_eq!(record.report.scanned, 3);
|
|
assert_eq!(record.report.enqueued, 1);
|
|
assert_eq!(record.report.transition_failed, 1);
|
|
assert_eq!(record.report.tier_failure, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_scan_progress_preserves_worker_failure_reasons() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
record.record_worker_result_with_reason(
|
|
ManualTransitionWorkerResult::TierFailure,
|
|
ManualTransitionQueueSnapshot::default(),
|
|
Some(ManualTransitionWorkerFailureReason::NotFound),
|
|
);
|
|
|
|
record.update_running_progress(
|
|
ManualTransitionRunReport {
|
|
bucket: "bucket".to_string(),
|
|
scanned: 3,
|
|
enqueued: 1,
|
|
tier_failure: 2,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot::default(),
|
|
);
|
|
|
|
assert_eq!(
|
|
record
|
|
.report
|
|
.tier_failure_by_reason
|
|
.get(&ManualTransitionWorkerFailureReason::NotFound),
|
|
Some(&1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_apply_worker_result_counts_preserves_existing_failure_reasons() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
record.record_worker_result_with_reason(
|
|
ManualTransitionWorkerResult::TierFailure,
|
|
ManualTransitionQueueSnapshot::default(),
|
|
Some(ManualTransitionWorkerFailureReason::NotFound),
|
|
);
|
|
|
|
let mut failure_reasons = BTreeMap::new();
|
|
failure_reasons.insert(ManualTransitionWorkerFailureReason::PermissionDenied, 2);
|
|
|
|
record.apply_worker_result_counts(0, 1, &failure_reasons, 1, ManualTransitionQueueSnapshot::default());
|
|
|
|
assert_eq!(
|
|
record
|
|
.report
|
|
.tier_failure_by_reason
|
|
.get(&ManualTransitionWorkerFailureReason::NotFound),
|
|
Some(&1)
|
|
);
|
|
assert_eq!(
|
|
record
|
|
.report
|
|
.tier_failure_by_reason
|
|
.get(&ManualTransitionWorkerFailureReason::PermissionDenied),
|
|
Some(&2)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_unknown_checkpoint_persists_counters_and_cursor() {
|
|
let options = ManualTransitionRunOptions {
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
max_objects: Some(5),
|
|
..Default::default()
|
|
};
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
record.update_running_progress(
|
|
ManualTransitionRunReport {
|
|
bucket: "bucket".to_string(),
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
scanned: 5,
|
|
eligible: 4,
|
|
enqueued: 3,
|
|
skipped_queue_full: 2,
|
|
skipped_queue_timeout: 1,
|
|
tier_failure: 1,
|
|
truncated_by_limit: true,
|
|
continuation_token: Some("opaque-page-token".to_string()),
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot {
|
|
queue_capacity: 8,
|
|
queued: 3,
|
|
active: 2,
|
|
workers: 4,
|
|
queue_full: 2,
|
|
queue_send_timeout: 1,
|
|
..Default::default()
|
|
},
|
|
);
|
|
record.record_worker_result(
|
|
ManualTransitionWorkerResult::TierFailure,
|
|
ManualTransitionQueueSnapshot {
|
|
queue_capacity: 8,
|
|
queued: 2,
|
|
active: 1,
|
|
workers: 4,
|
|
queue_full: 2,
|
|
queue_send_timeout: 1,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert!(record.mark_unknown_if_recovery_would_skip_pending_page(ManualTransitionQueueSnapshot {
|
|
queue_capacity: 8,
|
|
workers: 4,
|
|
queue_full: 2,
|
|
queue_send_timeout: 1,
|
|
..Default::default()
|
|
}));
|
|
let encoded = record.encode().expect("unknown checkpoint should encode");
|
|
let decoded = ManualTransitionJobRecord::decode(record.job_id, &encoded).expect("unknown checkpoint should decode");
|
|
|
|
assert_eq!(decoded.state, ManualTransitionJobState::Unknown);
|
|
assert!(decoded.is_terminal());
|
|
assert!(decoded.completed_at_unix_nanos.is_some());
|
|
assert_eq!(decoded.report.continuation_token.as_deref(), Some("opaque-page-token"));
|
|
assert!(decoded.report.was_truncated());
|
|
assert!(decoded.report.has_partial_enqueue());
|
|
assert_eq!(decoded.report.scanned, 5);
|
|
assert_eq!(decoded.report.eligible, 4);
|
|
assert_eq!(decoded.report.enqueued, 3);
|
|
assert_eq!(decoded.report.skipped_queue_full, 2);
|
|
assert_eq!(decoded.report.skipped_queue_timeout, 1);
|
|
assert_eq!(decoded.report.transition_failed, 1);
|
|
assert_eq!(decoded.report.tier_failure, 2);
|
|
assert_eq!(decoded.queue_snapshot.queue_capacity, 8);
|
|
assert_eq!(decoded.queue_snapshot.workers, 4);
|
|
assert_eq!(decoded.queue_snapshot.queue_full, 2);
|
|
assert_eq!(decoded.queue_snapshot.queue_send_timeout, 1);
|
|
assert!(
|
|
decoded
|
|
.error
|
|
.as_deref()
|
|
.is_some_and(|error| error.contains("page/task journal"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_marks_unknown_when_worker_results_are_lost() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
|
|
record.complete(
|
|
ManualTransitionRunReport {
|
|
bucket: "bucket".to_string(),
|
|
enqueued: 1,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot {
|
|
queued: 1,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Running);
|
|
assert!(!record.mark_unknown_if_worker_results_lost(ManualTransitionQueueSnapshot {
|
|
queued: 1,
|
|
..Default::default()
|
|
}));
|
|
|
|
assert!(record.mark_unknown_if_worker_results_lost(ManualTransitionQueueSnapshot::default()));
|
|
assert_eq!(record.state, ManualTransitionJobState::Unknown);
|
|
assert!(record.error.as_deref().is_some_and(|err| err.contains("worker result")));
|
|
assert!(record.completed_at_unix_nanos.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_marks_unknown_when_recovery_would_skip_pending_page() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
record.report.enqueued = 2;
|
|
record.report.transition_completed = 1;
|
|
|
|
assert!(!record.mark_unknown_if_recovery_would_skip_pending_page(ManualTransitionQueueSnapshot {
|
|
queued: 1,
|
|
..Default::default()
|
|
}));
|
|
|
|
record.report.continuation_token = Some("opaque".to_string());
|
|
assert!(!record.mark_unknown_if_recovery_would_skip_pending_page(ManualTransitionQueueSnapshot {
|
|
active: 1,
|
|
..Default::default()
|
|
}));
|
|
|
|
assert!(record.mark_unknown_if_recovery_would_skip_pending_page(ManualTransitionQueueSnapshot::default()));
|
|
assert_eq!(record.state, ManualTransitionJobState::Unknown);
|
|
assert!(record.error.as_deref().is_some_and(|err| err.contains("page/task journal")));
|
|
assert!(record.completed_at_unix_nanos.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_marks_unknown_for_corrupt_worker_result_journal() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
|
|
assert!(!record.mark_unknown_for_worker_result_journal_error("bad marker", ManualTransitionQueueSnapshot::default()));
|
|
|
|
record.complete(
|
|
ManualTransitionRunReport {
|
|
bucket: "bucket".to_string(),
|
|
enqueued: 1,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot::default(),
|
|
);
|
|
assert!(record.mark_unknown_for_worker_result_journal_error("bad marker", ManualTransitionQueueSnapshot::default()));
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Unknown);
|
|
assert!(
|
|
record
|
|
.error
|
|
.as_deref()
|
|
.is_some_and(|error| error.contains("worker result journal is corrupt"))
|
|
);
|
|
assert!(record.completed_at_unix_nanos.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_builds_resume_options() {
|
|
let options = ManualTransitionRunOptions {
|
|
prefix: "logs/".to_string(),
|
|
continuation_token: Some("start-token".to_string()),
|
|
tier: Some("warm".to_string()),
|
|
dry_run: true,
|
|
max_objects: Some(3),
|
|
max_duration: Some(std::time::Duration::from_secs(5)),
|
|
..Default::default()
|
|
};
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
record.report.continuation_token = Some("resume-token".to_string());
|
|
|
|
let resume = record.resume_options();
|
|
|
|
assert_eq!(resume.prefix, "logs/");
|
|
assert_eq!(resume.continuation_token.as_deref(), Some("resume-token"));
|
|
assert_eq!(resume.tier.as_deref(), Some("warm"));
|
|
assert!(resume.dry_run);
|
|
assert_eq!(resume.max_objects, Some(3));
|
|
assert_eq!(resume.max_duration, Some(std::time::Duration::from_secs(5)));
|
|
assert!(resume.cancel_token.is_none());
|
|
assert!(resume.cancel_check.is_none());
|
|
assert!(resume.progress_sink.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_object_name_round_trips_job_id() {
|
|
let job_id = Uuid::new_v4();
|
|
let object_name = manual_transition_job_record_object_name(job_id).expect("job record path should encode");
|
|
|
|
let decoded = manual_transition_job_id_from_record_object_name(&object_name).expect("job record path should decode");
|
|
|
|
assert_eq!(decoded, job_id);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_object_name_rejects_shard_mismatch() {
|
|
let job_id = Uuid::new_v4();
|
|
let object_name = manual_transition_job_record_object_name(job_id).expect("job record path should encode");
|
|
let job_key = job_id.simple().to_string();
|
|
let bad_first_shard = if &job_key[..2] == "ff" { "00" } else { "ff" };
|
|
let object_name = object_name.replacen(
|
|
&format!("{MANUAL_TRANSITION_JOB_RECORD_PREFIX}/{}/", &job_key[..2]),
|
|
&format!("{MANUAL_TRANSITION_JOB_RECORD_PREFIX}/{bad_first_shard}/"),
|
|
1,
|
|
);
|
|
|
|
let err = manual_transition_job_id_from_record_object_name(&object_name).expect_err("bad shard must fail closed");
|
|
|
|
assert!(err.to_string().contains("shards"));
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_rejects_checksum_drift() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
let encoded = record.encode().expect("job record should encode");
|
|
let mut value: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded job should be json");
|
|
value["job"]["bucket"] = serde_json::Value::String("other-bucket".to_string());
|
|
let mutated = serde_json::to_vec(&value).expect("mutated job should encode");
|
|
|
|
let err = ManualTransitionJobRecord::decode(record.job_id, &mutated).expect_err("checksum drift must fail closed");
|
|
|
|
assert!(matches!(err, ManualTransitionJobError::ChecksumMismatch));
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_worker_result_record_rejects_checksum_drift() {
|
|
let job_id = Uuid::new_v4();
|
|
let task_key = manual_transition_worker_result_task_key("bucket", "logs/a", None);
|
|
let record = ManualTransitionWorkerResultRecord::new(job_id, &task_key, ManualTransitionWorkerResult::Completed);
|
|
let encoded = record.encode().expect("worker result record should encode");
|
|
let mut value: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded worker result should be json");
|
|
value["record"]["result"] = serde_json::Value::String("tier_failure".to_string());
|
|
let mutated = serde_json::to_vec(&value).expect("mutated worker result should encode");
|
|
|
|
let err = ManualTransitionWorkerResultRecord::decode(job_id, &task_key, &mutated)
|
|
.expect_err("worker result checksum drift must fail closed");
|
|
|
|
assert!(matches!(err, ManualTransitionJobError::ChecksumMismatch));
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_worker_result_record_round_trips_without_failure_reason() {
|
|
let job_id = Uuid::new_v4();
|
|
let task_key = manual_transition_worker_result_task_key("bucket", "logs/a", None);
|
|
let record = ManualTransitionWorkerResultRecord::new_with_reason(
|
|
job_id,
|
|
&task_key,
|
|
ManualTransitionWorkerResult::TierFailure,
|
|
Some(ManualTransitionWorkerFailureReason::Network),
|
|
);
|
|
let encoded = record.encode().expect("worker result record should encode");
|
|
let mut value: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded worker result should be json");
|
|
let record_obj = value["record"].as_object_mut().expect("record object should be present");
|
|
record_obj.remove("failure_reason");
|
|
let record_bytes = serde_json::to_vec(&value["record"]).expect("record without reason should encode");
|
|
value["content_sha256"] = serde_json::Value::String(hex_sha256(&record_bytes, ToOwned::to_owned));
|
|
let stripped = serde_json::to_vec(&value).expect("stripped worker result should encode");
|
|
|
|
let decoded = ManualTransitionWorkerResultRecord::decode(job_id, &task_key, &stripped)
|
|
.expect("worker result without reason should decode");
|
|
|
|
assert_eq!(decoded.failure_reason, None);
|
|
assert_eq!(decoded.result, ManualTransitionWorkerResult::TierFailure);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_worker_result_stats_aggregates_reasons() {
|
|
let mut stats = ManualTransitionWorkerResultStats::default();
|
|
|
|
stats.record(ManualTransitionWorkerResult::Completed, None);
|
|
stats.record(
|
|
ManualTransitionWorkerResult::TierFailure,
|
|
Some(ManualTransitionWorkerFailureReason::PermissionDenied),
|
|
);
|
|
stats.record(
|
|
ManualTransitionWorkerResult::TierFailure,
|
|
Some(ManualTransitionWorkerFailureReason::PermissionDenied),
|
|
);
|
|
stats.record(ManualTransitionWorkerResult::TierFailure, None);
|
|
|
|
assert_eq!(stats.completed, 1);
|
|
assert_eq!(stats.failed, 3);
|
|
assert_eq!(
|
|
stats
|
|
.tier_failure_by_reason
|
|
.get(&ManualTransitionWorkerFailureReason::PermissionDenied),
|
|
Some(&2)
|
|
);
|
|
assert_eq!(
|
|
stats
|
|
.tier_failure_by_reason
|
|
.get(&ManualTransitionWorkerFailureReason::Unknown),
|
|
Some(&1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_task_record_rejects_checksum_drift() {
|
|
let job_id = Uuid::new_v4();
|
|
let task_key = manual_transition_worker_result_task_key("bucket", "logs/a", Some(Uuid::new_v4()));
|
|
let record = ManualTransitionTaskRecord::new(job_id, &task_key, "bucket", "logs/a", Some(Uuid::new_v4()), "WARM");
|
|
let encoded = record.encode().expect("task record should encode");
|
|
let mut value: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded task record should be json");
|
|
value["record"]["storage_class"] = serde_json::Value::String("COLD".to_string());
|
|
let mutated = serde_json::to_vec(&value).expect("mutated task record should encode");
|
|
|
|
let err =
|
|
ManualTransitionTaskRecord::decode(job_id, &task_key, &mutated).expect_err("task checksum drift must fail closed");
|
|
|
|
assert!(matches!(err, ManualTransitionJobError::ChecksumMismatch));
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_round_trips_legacy_report_without_failure_reason_map() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
let encoded = record.encode().expect("job record should encode");
|
|
let mut value: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded job should be json");
|
|
let report = value["job"]["report"].as_object_mut().expect("report should be object");
|
|
report.remove("tier_failure_by_reason");
|
|
let record_bytes = serde_json::to_vec(&value["job"]).expect("job without report failure map should encode");
|
|
value["content_sha256"] = serde_json::Value::String(hex_sha256(&record_bytes, ToOwned::to_owned));
|
|
let legacy = serde_json::to_vec(&value).expect("job without report failure map should encode");
|
|
|
|
let decoded = ManualTransitionJobRecord::decode(record.job_id, &legacy).expect("legacy job report should decode");
|
|
|
|
assert_eq!(decoded.state, ManualTransitionJobState::Running);
|
|
assert!(decoded.report.tier_failure_by_reason.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_rejects_unknown_report_fields() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
let encoded = record.encode().expect("job record should encode");
|
|
let mut value: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded job should be json");
|
|
let checksum = value.get_mut("content_sha256").expect("encoded job has checksum");
|
|
*checksum = serde_json::Value::String("0".repeat(64));
|
|
value["job"]["report"]["unexpected"] = serde_json::Value::Bool(true);
|
|
let mutated_job_bytes = serde_json::to_vec(&value["job"]).expect("mutated job should encode");
|
|
value["content_sha256"] = serde_json::Value::String(hex_sha256(&mutated_job_bytes, ToOwned::to_owned));
|
|
let mutated = serde_json::to_vec(&value).expect("mutated envelope should encode");
|
|
|
|
let err = ManualTransitionJobRecord::decode(record.job_id, &mutated).expect_err("unknown report field must fail");
|
|
|
|
assert!(err.to_string().contains("unknown field"));
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_marks_restart_unknown_terminal() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
|
|
record.mark_unknown_if_unowned();
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Unknown);
|
|
assert!(record.is_terminal());
|
|
assert!(record.completed_at_unix_nanos.is_some());
|
|
assert!(
|
|
record
|
|
.error
|
|
.as_deref()
|
|
.is_some_and(|error| error.contains("unknown after restart"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_persists_restart_unknown_state() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
record.mark_unknown_if_unowned();
|
|
|
|
let encoded = record.encode().expect("unknown restart job should encode");
|
|
let decoded = ManualTransitionJobRecord::decode(record.job_id, &encoded).expect("unknown restart job should decode");
|
|
|
|
assert_eq!(decoded.state, ManualTransitionJobState::Unknown);
|
|
assert!(decoded.is_terminal());
|
|
assert!(decoded.completed_at_unix_nanos.is_some());
|
|
assert!(
|
|
decoded
|
|
.error
|
|
.as_deref()
|
|
.is_some_and(|error| error.contains("unknown after restart"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_control_plane_failure_does_not_count_tier_failure() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
|
|
record.fail("missing tier");
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Failed);
|
|
assert_eq!(record.report.tier_failure, 0);
|
|
assert_eq!(record.error.as_deref(), Some("missing tier"));
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_tier_failure_report_is_partial() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
|
|
record.complete(
|
|
ManualTransitionRunReport {
|
|
tier_failure: 1,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot::default(),
|
|
);
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Partial);
|
|
assert_eq!(record.report.tier_failure, 1);
|
|
assert!(record.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_queue_pressure_report_is_partial() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
let queue_snapshot = ManualTransitionQueueSnapshot {
|
|
queue_capacity: 1,
|
|
queue_full: 1,
|
|
..Default::default()
|
|
};
|
|
|
|
record.complete(
|
|
ManualTransitionRunReport {
|
|
skipped_queue_full: 1,
|
|
..Default::default()
|
|
},
|
|
queue_snapshot,
|
|
);
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Partial);
|
|
assert_eq!(record.report.skipped_queue_full, 1);
|
|
assert_eq!(record.queue_snapshot.queue_capacity, 1);
|
|
assert_eq!(record.queue_snapshot.queue_full, 1);
|
|
assert!(record.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_in_flight_skip_report_is_partial() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
|
|
record.complete(
|
|
ManualTransitionRunReport {
|
|
eligible: 1,
|
|
skipped_already_in_flight: 1,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot::default(),
|
|
);
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Partial);
|
|
assert_eq!(record.report.skipped_already_in_flight, 1);
|
|
assert!(record.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_queue_pressure_reports_persist_readback() {
|
|
let options = ManualTransitionRunOptions {
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
max_objects: Some(10),
|
|
..Default::default()
|
|
};
|
|
|
|
for (bucket, skipped_queue_full, skipped_queue_closed, skipped_queue_timeout, active_snapshot, terminal_snapshot) in [
|
|
(
|
|
"manual-queue-full-readback-bucket",
|
|
2,
|
|
0,
|
|
0,
|
|
ManualTransitionQueueSnapshot {
|
|
queue_capacity: 4,
|
|
queued: 4,
|
|
workers: 2,
|
|
queue_full: 2,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot {
|
|
queue_capacity: 4,
|
|
workers: 2,
|
|
queue_full: 2,
|
|
..Default::default()
|
|
},
|
|
),
|
|
(
|
|
"manual-queue-closed-readback-bucket",
|
|
0,
|
|
1,
|
|
0,
|
|
ManualTransitionQueueSnapshot {
|
|
queue_capacity: 4,
|
|
workers: 2,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot {
|
|
queue_capacity: 4,
|
|
workers: 2,
|
|
..Default::default()
|
|
},
|
|
),
|
|
(
|
|
"manual-queue-timeout-readback-bucket",
|
|
0,
|
|
0,
|
|
1,
|
|
ManualTransitionQueueSnapshot {
|
|
queue_capacity: 4,
|
|
active: 1,
|
|
workers: 2,
|
|
queue_send_timeout: 1,
|
|
..Default::default()
|
|
},
|
|
ManualTransitionQueueSnapshot {
|
|
queue_capacity: 4,
|
|
workers: 2,
|
|
queue_send_timeout: 1,
|
|
..Default::default()
|
|
},
|
|
),
|
|
] {
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), bucket, &options, TEST_OWNER);
|
|
record.complete(
|
|
ManualTransitionRunReport {
|
|
enqueued: 1,
|
|
skipped_queue_full,
|
|
skipped_queue_closed,
|
|
skipped_queue_timeout,
|
|
continuation_token: Some("opaque-queue-pressure-token".to_string()),
|
|
..Default::default()
|
|
},
|
|
active_snapshot,
|
|
);
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Running);
|
|
assert!(record.report.has_partial_enqueue());
|
|
assert!(record.report.worker_transition_pending());
|
|
assert_eq!(record.queue_snapshot, active_snapshot);
|
|
|
|
let encoded = record.encode().expect("queue-pressure partial job should encode");
|
|
let decoded =
|
|
ManualTransitionJobRecord::decode(record.job_id, &encoded).expect("running queue-pressure job should decode");
|
|
assert_eq!(decoded.state, ManualTransitionJobState::Running);
|
|
assert!(decoded.report.worker_transition_pending());
|
|
assert_eq!(decoded.report.skipped_queue_full, skipped_queue_full);
|
|
assert_eq!(decoded.report.skipped_queue_closed, skipped_queue_closed);
|
|
assert_eq!(decoded.report.skipped_queue_timeout, skipped_queue_timeout);
|
|
assert_eq!(decoded.report.continuation_token.as_deref(), Some("opaque-queue-pressure-token"));
|
|
assert_eq!(decoded.queue_snapshot, active_snapshot);
|
|
|
|
record.record_worker_result(ManualTransitionWorkerResult::Completed, terminal_snapshot);
|
|
|
|
let encoded = record.encode().expect("terminal queue-pressure partial job should encode");
|
|
let decoded =
|
|
ManualTransitionJobRecord::decode(record.job_id, &encoded).expect("terminal queue-pressure job should decode");
|
|
assert_eq!(decoded.state, ManualTransitionJobState::Partial);
|
|
assert!(decoded.is_terminal());
|
|
assert_eq!(decoded.report.enqueued, 1);
|
|
assert_eq!(decoded.report.transition_completed, 1);
|
|
assert_eq!(decoded.report.skipped_queue_full, skipped_queue_full);
|
|
assert_eq!(decoded.report.skipped_queue_closed, skipped_queue_closed);
|
|
assert_eq!(decoded.report.skipped_queue_timeout, skipped_queue_timeout);
|
|
assert!(decoded.report.has_partial_enqueue());
|
|
assert_eq!(decoded.report.continuation_token.as_deref(), Some("opaque-queue-pressure-token"));
|
|
assert_eq!(decoded.queue_snapshot, terminal_snapshot);
|
|
assert!(decoded.completed_at_unix_nanos.is_some());
|
|
assert!(decoded.error.is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_record_budget_reports_are_partial_and_resumable() {
|
|
let options = ManualTransitionRunOptions {
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
max_objects: Some(1),
|
|
max_duration: Some(std::time::Duration::from_secs(10)),
|
|
..Default::default()
|
|
};
|
|
|
|
for (bucket, truncated_by_limit, truncated_by_duration) in [
|
|
("manual-budget-limit-bucket", true, false),
|
|
("manual-budget-duration-bucket", false, true),
|
|
] {
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), bucket, &options, TEST_OWNER);
|
|
let queue_snapshot = ManualTransitionQueueSnapshot {
|
|
queue_capacity: 8,
|
|
queued: 1,
|
|
active: 1,
|
|
workers: 2,
|
|
..Default::default()
|
|
};
|
|
record.complete(
|
|
ManualTransitionRunReport {
|
|
bucket: bucket.to_string(),
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
scanned: 1,
|
|
eligible: 1,
|
|
dry_run_eligible: 1,
|
|
truncated_by_limit,
|
|
truncated_by_duration,
|
|
continuation_token: Some("opaque-budget-token".to_string()),
|
|
..Default::default()
|
|
},
|
|
queue_snapshot,
|
|
);
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Partial);
|
|
assert_eq!(record.report.continuation_token.as_deref(), Some("opaque-budget-token"));
|
|
assert!(record.report.was_truncated());
|
|
assert_eq!(record.queue_snapshot, queue_snapshot);
|
|
assert!(record.completed_at_unix_nanos.is_some());
|
|
assert!(record.error.is_none());
|
|
|
|
let encoded = record.encode().expect("budget partial job should encode");
|
|
let decoded = ManualTransitionJobRecord::decode(record.job_id, &encoded).expect("budget partial job should decode");
|
|
assert_eq!(decoded.state, ManualTransitionJobState::Partial);
|
|
assert_eq!(decoded.max_duration, options.max_duration);
|
|
assert_eq!(decoded.report.truncated_by_limit, truncated_by_limit);
|
|
assert_eq!(decoded.report.truncated_by_duration, truncated_by_duration);
|
|
assert_eq!(decoded.report.continuation_token.as_deref(), Some("opaque-budget-token"));
|
|
assert_eq!(decoded.queue_snapshot, queue_snapshot);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_job_budget_report_waits_for_pending_workers() {
|
|
let options = ManualTransitionRunOptions {
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
max_objects: Some(1),
|
|
..Default::default()
|
|
};
|
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "manual-budget-pending-bucket", &options, TEST_OWNER);
|
|
let active_snapshot = ManualTransitionQueueSnapshot {
|
|
queue_capacity: 4,
|
|
queued: 1,
|
|
active: 1,
|
|
workers: 2,
|
|
..Default::default()
|
|
};
|
|
|
|
record.complete(
|
|
ManualTransitionRunReport {
|
|
bucket: "manual-budget-pending-bucket".to_string(),
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
scanned: 1,
|
|
eligible: 1,
|
|
enqueued: 1,
|
|
truncated_by_limit: true,
|
|
continuation_token: Some("opaque-budget-token".to_string()),
|
|
..Default::default()
|
|
},
|
|
active_snapshot,
|
|
);
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Running);
|
|
assert!(record.scan_completed);
|
|
assert!(record.completed_at_unix_nanos.is_none());
|
|
assert!(record.report.was_truncated());
|
|
assert!(record.report.worker_transition_pending());
|
|
assert_eq!(record.report.continuation_token.as_deref(), Some("opaque-budget-token"));
|
|
assert_eq!(record.queue_snapshot, active_snapshot);
|
|
|
|
let drained_snapshot = ManualTransitionQueueSnapshot {
|
|
queue_capacity: 4,
|
|
workers: 2,
|
|
..Default::default()
|
|
};
|
|
record.record_worker_result(ManualTransitionWorkerResult::Completed, drained_snapshot);
|
|
|
|
assert_eq!(record.state, ManualTransitionJobState::Partial);
|
|
assert_eq!(record.report.transition_completed, 1);
|
|
assert_eq!(record.queue_snapshot, drained_snapshot);
|
|
assert!(record.completed_at_unix_nanos.is_some());
|
|
assert!(record.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_scope_key_is_stable_and_sanitized() {
|
|
let first = manual_transition_scope_key(
|
|
"bucket",
|
|
&ManualTransitionRunOptions {
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
let second = manual_transition_scope_key(
|
|
"bucket",
|
|
&ManualTransitionRunOptions {
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("WARM".to_string()),
|
|
marker: Some("ignored".to_string()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(first, second);
|
|
assert!(
|
|
manual_transition_scope_record_object_name(&first)
|
|
.expect("scope path should encode")
|
|
.starts_with(MANUAL_TRANSITION_SCOPE_RECORD_PREFIX)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_scope_key_uses_bucket_level_admission_for_durable_v1() {
|
|
let broad = manual_transition_scope_key(
|
|
"bucket",
|
|
&ManualTransitionRunOptions {
|
|
prefix: "logs/".to_string(),
|
|
tier: None,
|
|
..Default::default()
|
|
},
|
|
);
|
|
let nested = manual_transition_scope_key(
|
|
"bucket",
|
|
&ManualTransitionRunOptions {
|
|
prefix: "logs/2026/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
let disjoint = manual_transition_scope_key(
|
|
"bucket",
|
|
&ManualTransitionRunOptions {
|
|
prefix: "archive/".to_string(),
|
|
tier: Some("cold".to_string()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
let dry_run = manual_transition_scope_key(
|
|
"bucket",
|
|
&ManualTransitionRunOptions {
|
|
prefix: "logs/".to_string(),
|
|
dry_run: true,
|
|
..Default::default()
|
|
},
|
|
);
|
|
let other_bucket = manual_transition_scope_key(
|
|
"bucket-b",
|
|
&ManualTransitionRunOptions {
|
|
prefix: "logs/".to_string(),
|
|
tier: Some("warm".to_string()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(broad, nested);
|
|
assert_eq!(broad, disjoint);
|
|
assert_ne!(broad, dry_run);
|
|
assert_ne!(broad, other_bucket);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_transition_scope_admission_carries_job_lease_fence() {
|
|
let options = ManualTransitionRunOptions::default();
|
|
let record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
|
|
|
let admission = ManualTransitionScopeAdmission::from_job(&record);
|
|
|
|
assert_eq!(admission.job_id, record.job_id);
|
|
assert_eq!(admission.lease_id, record.lease_id);
|
|
assert_eq!(admission.owner_id, TEST_OWNER);
|
|
assert_eq!(admission.scope_key, record.scope_key);
|
|
assert!(admission.validate().is_ok());
|
|
}
|
|
}
|