mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-22 20:36:38 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a206895fad | |||
| 3cee88f313 |
@@ -1130,6 +1130,23 @@ fn should_cleanup_decommission_source_entry(decommissioned: usize, total_version
|
|||||||
decommissioned.saturating_add(expired) == total_versions
|
decommissioned.saturating_add(expired) == total_versions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Disposition reason logged for tier free-version records that decommission
|
||||||
|
/// skips instead of migrating.
|
||||||
|
const DECOMMISSION_FREE_VERSION_SKIP_REASON: &str = "tier_free_version_not_migrated";
|
||||||
|
|
||||||
|
/// Counts the tier free-version records present in a decommission entry
|
||||||
|
/// inventory. The exact loader (`load_file_info_versions_exact`) keeps these
|
||||||
|
/// records inline in `versions` instead of separating them into
|
||||||
|
/// `free_versions`, and the migration loop then routes them through the
|
||||||
|
/// generic delete-marker path: the free-version flag and its remote-tier
|
||||||
|
/// identity are never carried to the target pool, and a lone record is skipped
|
||||||
|
/// by the empty-delete-marker rule. Accounting for them here keeps the final
|
||||||
|
/// sweep from silently omitting records whose free-version disposition was
|
||||||
|
/// dropped (see docs/architecture/decommission-compatibility.md).
|
||||||
|
fn decommission_free_versions_skipped(fivs: &FileInfoVersions) -> usize {
|
||||||
|
fivs.versions.iter().filter(|version| version.tier_free_version()).count()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[allow(
|
#[allow(
|
||||||
dead_code,
|
dead_code,
|
||||||
@@ -3097,6 +3114,22 @@ impl ECStore {
|
|||||||
fivs.versions
|
fivs.versions
|
||||||
.sort_by_key(|v| (v.mod_time.is_none(), std::cmp::Reverse(v.mod_time)));
|
.sort_by_key(|v| (v.mod_time.is_none(), std::cmp::Reverse(v.mod_time)));
|
||||||
|
|
||||||
|
let skipped_free_versions = decommission_free_versions_skipped(&fivs);
|
||||||
|
if skipped_free_versions > 0 {
|
||||||
|
debug!(
|
||||||
|
event = EVENT_DECOMMISSION_ENTRY,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
||||||
|
pool_index = idx,
|
||||||
|
bucket = %bucket,
|
||||||
|
object = %entry.name,
|
||||||
|
skipped_free_versions,
|
||||||
|
reason = DECOMMISSION_FREE_VERSION_SKIP_REASON,
|
||||||
|
state = "free_versions_skipped",
|
||||||
|
"Decommission skipped free-version migration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut decommissioned: usize = 0;
|
let mut decommissioned: usize = 0;
|
||||||
let mut expired: usize = 0;
|
let mut expired: usize = 0;
|
||||||
let mut cleanup_preflight_allowed_missing = Vec::new();
|
let mut cleanup_preflight_allowed_missing = Vec::new();
|
||||||
@@ -5458,11 +5491,12 @@ pub(crate) fn fallback_free_capacity_dedup(disks: &[rustfs_madmin::Disk]) -> usi
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod pools_tests {
|
mod pools_tests {
|
||||||
use super::{
|
use super::{
|
||||||
DECOMMISSION_PROGRESS_SAVE_INTERVAL, DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD, DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF,
|
DECOMMISSION_FREE_VERSION_SKIP_REASON, DECOMMISSION_PROGRESS_SAVE_INTERVAL, DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
|
||||||
DecomBucketInfo, DecommissionStartPoolState, DecommissionTerminalState, ListCallback, PoolDecommissionInfo, PoolMeta,
|
DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF, DecomBucketInfo, DecommissionStartPoolState, DecommissionTerminalState,
|
||||||
PoolSpaceInfo, PoolStatus, apply_decommission_status_space_info, bind_decommission_cancelers,
|
ListCallback, PoolDecommissionInfo, PoolMeta, PoolSpaceInfo, PoolStatus, apply_decommission_status_space_info,
|
||||||
bind_missing_decommission_cancelers, cancel_decommission_canceler, classify_decommission_terminal_state,
|
bind_decommission_cancelers, bind_missing_decommission_cancelers, cancel_decommission_canceler,
|
||||||
count_decommission_item, decommission_cancel_signal_result, decommission_item_size, decommission_meta_bucket_options,
|
classify_decommission_terminal_state, count_decommission_item, decommission_cancel_signal_result,
|
||||||
|
decommission_free_versions_skipped, decommission_item_size, decommission_meta_bucket_options,
|
||||||
decommission_start_pool_state, dedup_indices, default_decommission_bucket_concurrency,
|
decommission_start_pool_state, dedup_indices, default_decommission_bucket_concurrency,
|
||||||
ensure_decommission_cancel_allowed, ensure_decommission_clear_allowed, ensure_decommission_listing_disks_available,
|
ensure_decommission_cancel_allowed, ensure_decommission_clear_allowed, ensure_decommission_listing_disks_available,
|
||||||
ensure_decommission_not_rebalancing, ensure_decommission_start_allowed, ensure_decommission_start_keeps_active_pool,
|
ensure_decommission_not_rebalancing, ensure_decommission_start_allowed, ensure_decommission_start_keeps_active_pool,
|
||||||
@@ -6762,6 +6796,27 @@ mod pools_tests {
|
|||||||
assert!(!should_cleanup_decommission_source_entry(2, 2, 1));
|
assert!(!should_cleanup_decommission_source_entry(2, 2, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decommission_free_version_accounting_reports_skipped_records() {
|
||||||
|
let mut fivs = FileInfoVersions::default();
|
||||||
|
assert_eq!(decommission_free_versions_skipped(&fivs), 0);
|
||||||
|
|
||||||
|
fivs.versions.push(FileInfo {
|
||||||
|
name: "object.txt".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut free_one = FileInfo::default();
|
||||||
|
free_one.set_tier_free_version();
|
||||||
|
fivs.versions.push(free_one);
|
||||||
|
let mut free_two = FileInfo::default();
|
||||||
|
free_two.set_tier_free_version();
|
||||||
|
free_two.transition_tier = "WARM".to_string();
|
||||||
|
fivs.versions.push(free_two);
|
||||||
|
|
||||||
|
assert_eq!(decommission_free_versions_skipped(&fivs), 2);
|
||||||
|
assert_eq!(DECOMMISSION_FREE_VERSION_SKIP_REASON, "tier_free_version_not_migrated");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pool_meta_update_after_rejects_out_of_range_index() {
|
fn test_pool_meta_update_after_rejects_out_of_range_index() {
|
||||||
let mut meta = PoolMeta::default();
|
let mut meta = PoolMeta::default();
|
||||||
|
|||||||
@@ -90,6 +90,22 @@ fn legacy_data_key_for_version(version_id: Option<Uuid>) -> Option<String> {
|
|||||||
pub const TRANSITION_COMPLETE: &str = "complete";
|
pub const TRANSITION_COMPLETE: &str = "complete";
|
||||||
pub const TRANSITION_PENDING: &str = "pending";
|
pub const TRANSITION_PENDING: &str = "pending";
|
||||||
|
|
||||||
|
/// xl.meta key marking a tier free-version record.
|
||||||
|
///
|
||||||
|
/// A free version is a delete-marker-shaped cleanup hint appended by
|
||||||
|
/// [`MetaObject::delete_version`] when a version whose remote transition
|
||||||
|
/// completed is removed from xl.meta; it carries the remote tier identity for
|
||||||
|
/// an idempotent remote delete and is never a user-visible version
|
||||||
|
/// (`num_versions` excludes it). While the record exists it is consumed by the
|
||||||
|
/// lifecycle free-version recovery scan and the usage scanner, which re-enqueue
|
||||||
|
/// the pending remote delete, and by heal metadata walks. On S3 and lifecycle
|
||||||
|
/// delete paths the same obligation is also carried by a committed tier-journal
|
||||||
|
/// entry; deletes without such an entry (for example a removed version whose
|
||||||
|
/// transition state decodes as unknown) rely on this record alone until the
|
||||||
|
/// worker removes it after a successful remote delete. Decommission does not
|
||||||
|
/// preserve these semantics: its exact inventory keeps the records inline in
|
||||||
|
/// `versions` and the migration loop treats them as ordinary delete markers —
|
||||||
|
/// see docs/architecture/decommission-compatibility.md.
|
||||||
pub const FREE_VERSION: &str = "free-version";
|
pub const FREE_VERSION: &str = "free-version";
|
||||||
|
|
||||||
pub const TRANSITION_STATUS: &str = "transition-status";
|
pub const TRANSITION_STATUS: &str = "transition-status";
|
||||||
|
|||||||
@@ -2725,6 +2725,15 @@ impl MetaObject {
|
|||||||
self.meta_sys.retain(|k, _| !k.starts_with("X-Amz-Restore"));
|
self.meta_sys.retain(|k, _| !k.starts_with("X-Amz-Restore"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the free-version cleanup record appended when a transitioned
|
||||||
|
/// version is removed from xl.meta. The record keeps the remote tier
|
||||||
|
/// identity so the lifecycle worker can issue the idempotent remote delete
|
||||||
|
/// and only then remove the record; until then the recovery scan and the
|
||||||
|
/// usage scanner keep re-enqueueing it. S3 and lifecycle deletes also
|
||||||
|
/// persist a committed tier-journal entry for the same remote delete, so a
|
||||||
|
/// record destroyed without its remote delete (as decommission does when it
|
||||||
|
/// treats these records as ordinary delete markers) strands only the
|
||||||
|
/// journal-less cases — see docs/architecture/decommission-compatibility.md.
|
||||||
pub fn init_free_version(&self, fi: &FileInfo) -> Result<(FileMetaVersion, bool)> {
|
pub fn init_free_version(&self, fi: &FileInfo) -> Result<(FileMetaVersion, bool)> {
|
||||||
if fi.skip_tier_free_version() {
|
if fi.skip_tier_free_version() {
|
||||||
return Ok((FileMetaVersion::default(), false));
|
return Ok((FileMetaVersion::default(), false));
|
||||||
|
|||||||
@@ -153,6 +153,91 @@ No migration step is required for these decisions because this note documents th
|
|||||||
current RustFS behavior. Changing either decision later requires an operator
|
current RustFS behavior. Changing either decision later requires an operator
|
||||||
compatibility note and updated characterization tests.
|
compatibility note and updated characterization tests.
|
||||||
|
|
||||||
|
## Tier Free Versions During Decommission
|
||||||
|
|
||||||
|
A tier free version is an internal xl.meta record (`rustfs_filemeta::FREE_VERSION`,
|
||||||
|
flagged `XL_FLAG_FREE_VERSION`) shaped like a delete marker. It is created by
|
||||||
|
`MetaObject::init_free_version` when a version whose remote transition completed is
|
||||||
|
deleted locally: the visible version is removed and the record keeps the remote-tier
|
||||||
|
identity (tier, object name, version id, state, destination id) needed for an
|
||||||
|
idempotent remote delete. Free versions are not user-visible versions; `num_versions`
|
||||||
|
and all listing/GET paths exclude them.
|
||||||
|
|
||||||
|
### Lifecycle And Consumers
|
||||||
|
|
||||||
|
Creation: any local delete that removes a version whose transition status is
|
||||||
|
`complete` appends the record via `MetaObject::delete_version` →
|
||||||
|
`init_free_version` (skipped only when `skip_tier_free_version` is set, as on
|
||||||
|
data-movement copies). The same deletes also persist a durable tier-journal
|
||||||
|
entry on every user-facing path: S3 single deletes (`execute_delete_object` →
|
||||||
|
`delete_object_with_tier_delete_journal`), S3 batch deletes, lifecycle expiry,
|
||||||
|
and lifecycle delete-all all prepare and commit a journal entry around the
|
||||||
|
delete. A journal entry is omitted when the removed version's transition state
|
||||||
|
decodes as `TransitionVersionState::Unknown`, or on internal journal-less
|
||||||
|
delete paths that never touch transitioned user objects.
|
||||||
|
|
||||||
|
Consumption while the record exists: the background recovery loop started by
|
||||||
|
`init_background_expiry` (spawned by `spawn_tier_free_version_recovery_once`,
|
||||||
|
enabled by default) scans disks for pending records and re-enqueues them; the
|
||||||
|
usage scanner does the same; the lifecycle worker then deletes the remote tier
|
||||||
|
object idempotently and only afterwards removes the local record. Heal walks
|
||||||
|
include free-version records in metadata healing. Transition planning,
|
||||||
|
replication, restore, GET, listings, and usage aggregation never depend on
|
||||||
|
them.
|
||||||
|
|
||||||
|
### Decommission Handling
|
||||||
|
|
||||||
|
The exact decommission inventory loader (`load_file_info_versions_exact` via
|
||||||
|
`get_all_file_info_versions`) keeps free-version records inline in `versions`; it
|
||||||
|
never populates `free_versions`, so the source-cleanup preflight comparison of
|
||||||
|
`free_versions` is vacuous for decommission. The migration loop then routes every
|
||||||
|
record through the generic delete-marker handling:
|
||||||
|
|
||||||
|
- a record that is the only remaining version without replication is skipped by the
|
||||||
|
empty-delete-marker rule and counted as done;
|
||||||
|
- any other record is copied to the target pool as an ordinary delete marker with the
|
||||||
|
same version id and mod time.
|
||||||
|
|
||||||
|
In both cases the free-version flag and its remote-tier identity are dropped:
|
||||||
|
decommission neither preserves free-version semantics nor performs or reschedules the
|
||||||
|
pending remote-tier delete. Source cleanup then removes the original records together
|
||||||
|
with the source xl.meta.
|
||||||
|
|
||||||
|
Allowed physical-delete timing: the source record may be removed once the migration
|
||||||
|
loop has dispositioned it (copied as a plain marker or skipped as lone), which
|
||||||
|
happens regardless of whether its remote-tier delete was ever performed.
|
||||||
|
|
||||||
|
### Reference-Audit Result
|
||||||
|
|
||||||
|
No cluster-local consumer resolves a free version after decommission finishes: GET,
|
||||||
|
listing, transition planning, replication, restore, and heal operate either on
|
||||||
|
user-visible versions or while the record still exists. The remote exposure is
|
||||||
|
bounded:
|
||||||
|
|
||||||
|
- On every user-facing delete path the remote-delete obligation is durably carried
|
||||||
|
by the committed tier-journal entry, which the tier sweeper processes
|
||||||
|
independently of xl.meta; the free-version record is an idempotent second
|
||||||
|
pointer, not the only one. Dropping it during decommission therefore does not
|
||||||
|
orphan the remote object.
|
||||||
|
- Residual exposure: for records whose version state decoded as `Unknown` no
|
||||||
|
journal entry exists, so dropping the unconsumed record loses that cleanup hint
|
||||||
|
and the remote-tier object is orphaned. The same applies to any future internal
|
||||||
|
delete path that removes transitioned versions without a journal entry.
|
||||||
|
|
||||||
|
Copying a pending record as an ordinary delete marker also adds a user-visible
|
||||||
|
tombstone to the target pool's version history that the source never exposed.
|
||||||
|
|
||||||
|
Because of the residual journal-less case, decommission must account for every
|
||||||
|
free-version record instead of omitting it silently:
|
||||||
|
|
||||||
|
- `decommission_free_versions_skipped` counts the records per decommission entry;
|
||||||
|
- entries with a non-zero count log `state = "free_versions_skipped"` with reason
|
||||||
|
`tier_free_version_not_migrated`.
|
||||||
|
|
||||||
|
Regression guard:
|
||||||
|
|
||||||
|
- `decommission_free_version_accounting_reports_skipped_records`
|
||||||
|
|
||||||
## Regression Guard
|
## Regression Guard
|
||||||
|
|
||||||
The queued multi-pool contract is guarded by:
|
The queued multi-pool contract is guarded by:
|
||||||
|
|||||||
Reference in New Issue
Block a user