Skip to main content

loonfs_core/checkpoint/
reorganize.rs

1//! Bounded metadata reorganization: the background half of the
2//! checkpoint/compaction split.
3//!
4//! Checkpoint publication only ever appends an L0 delta run, so its cost
5//! follows the WAL delta, never the namespace. Folding those L0 runs into
6//! the base happens here instead, one **family group** at a time: the
7//! group's rows are merged from an oldest-first, budgeted subset of complete
8//! runs, rows no retained sequence can observe are dropped, new base
9//! segments are written, and a manifest publishes that swaps just those
10//! references. Families whose rows must stay mutually consistent compact
11//! together — bind, child bind, and unbind rows form one group because their
12//! drop rules read each other; revisions travel with their descending index
13//! so index parity holds within every unit.
14//!
15//! There is no progress record: each unit ends in a durable manifest, so a
16//! crashed or interrupted reorganization resumes by reading the live
17//! manifest and picking the next group that still has L0 rows. Unit
18//! selection is deterministic (most L0 rows first, then group order). A
19//! concurrent checkpoint racing a unit wins at the root compare-and-swap;
20//! the unit's segments are left unreferenced for garbage collection and the
21//! next step retries against the fresh manifest.
22
23use super::block_fetch::load_segment_index_for_reorganization;
24use super::build::{
25    build_manifest_tables_from_rows, debug_assert_manifest_table_segments_do_not_overlap,
26    MetadataTableSegmentation,
27};
28use super::error::ManifestLoadError;
29use super::flush::{ensure_metadata_publication_budget, next_manifest_id_after};
30use super::load::{
31    load_namespace_manifest_envelope_if_present, load_verified_manifest_tables,
32    validate_direntry_child_bind_index, validate_revision_by_inode_desc_index,
33};
34use super::publish::{publish_metadata_root, write_namespace_manifest, ManifestPublicationOutcome};
35use super::runs::{
36    flatten_manifest_tables, l0_run_count, MetadataLsmPolicy, MetadataRunManifest,
37    CHECKPOINT_BASE_RUN_LEVEL, CHECKPOINT_L0_RUN_LEVEL,
38};
39use super::scan::VerifiedMetadataTables;
40use crate::context::MutationContext;
41use crate::error::{CoreError, MetadataProjectionLoadError, Result};
42use crate::limits::CONTENTION_RETRY_LIMIT;
43use crate::namespace::basis::resolve_retention_floor_seq;
44use crate::namespace::control::{read_head_object, read_metadata_root_object_if_present};
45use crate::timing::{MonotonicTimer, StdMonotonicTimer};
46use loonfs_api::wire::manifest::{
47    ActiveDeletionRowAction, MetadataFileRef, MetadataRow, MetadataTableFamily,
48    NamespaceManifestEnvelope, NamespaceManifestPayload,
49};
50use loonfs_api::{ChangeSeq, InodeId, ManifestId, ManifestObjectId, NamespaceId};
51use loonfs_objectstore::keys::metadata_manifest_object;
52use loonfs_objectstore::ObjectStore;
53use std::collections::{BTreeMap, BTreeSet};
54
55/// Families whose rows merge in one reorganization unit. Families that read
56/// each other's rows to decide what to drop (see
57/// `drop_rows_below_retention_floor`) must compact together, and a secondary
58/// index always travels with its canonical family.
59const REORGANIZE_FAMILY_GROUPS: [&[MetadataTableFamily]; 6] = [
60    &[
61        MetadataTableFamily::DirentryBinds,
62        MetadataTableFamily::DirentryChildBinds,
63        MetadataTableFamily::DirentryUnbinds,
64    ],
65    &[
66        MetadataTableFamily::Revisions,
67        MetadataTableFamily::RevisionsByInodeDesc,
68    ],
69    &[MetadataTableFamily::Inodes],
70    &[MetadataTableFamily::Tombstones],
71    // Active deletions fold alone: a removal marker is cancelled by the
72    // listed row it names, and both live in this family.
73    &[MetadataTableFamily::ActiveDeletions],
74    &[MetadataTableFamily::CommitReceipts],
75];
76
77/// What one reorganization step did.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum MetadataReorganizeOutcome {
80    /// The manifest's L0 run count is below the policy trigger; nothing to
81    /// fold yet.
82    NotNeeded { l0_runs: usize },
83    /// One bounded complete-run subset for a family group folded into new
84    /// base segments and the manifest advanced.
85    UnitPublished {
86        families: Vec<MetadataTableFamily>,
87        folded_l0_rows: u64,
88        input_runs: usize,
89        decoded_input_rows: u64,
90        decoded_input_bytes: u64,
91        manifest_id: ManifestId,
92    },
93    /// The trigger fired, but no oldest-first subset that would make
94    /// progress fit the hard per-step input budgets.
95    BudgetExhausted {
96        families: Vec<MetadataTableFamily>,
97        l0_runs: usize,
98    },
99    /// A concurrent publication moved the root while this unit ran; its
100    /// output is unreferenced (garbage collection reclaims it) and the next
101    /// step retries against the fresh manifest.
102    Superseded,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct MetadataReorganizeReport {
107    pub namespace_id: NamespaceId,
108    pub outcome: MetadataReorganizeOutcome,
109}
110
111/// Runs at most one reorganization unit against the namespace's current
112/// manifest. Callers (the maintenance step) invoke this repeatedly; each
113/// call re-reads durable state, so any two calls compose — including across
114/// process restarts.
115#[tracing::instrument(
116    level = "info",
117    name = "loonfs.phase",
118    err,
119    skip_all,
120    fields(phase = "reorganize_metadata", key_class = "manifest")
121)]
122pub(crate) async fn reorganize_metadata_step<S: ObjectStore + ?Sized>(
123    store: &S,
124    namespace_id: &NamespaceId,
125    context: &MutationContext,
126    policy: MetadataLsmPolicy,
127) -> Result<MetadataReorganizeReport> {
128    let timer = StdMonotonicTimer::default();
129    reorganize_metadata_step_with_timer(store, namespace_id, context, policy, &timer).await
130}
131
132pub(super) async fn reorganize_metadata_step_with_timer<S: ObjectStore + ?Sized>(
133    store: &S,
134    namespace_id: &NamespaceId,
135    context: &MutationContext,
136    policy: MetadataLsmPolicy,
137    timer: &dyn MonotonicTimer,
138) -> Result<MetadataReorganizeReport> {
139    // The publication budget covers the whole unit: measurement starts
140    // before any table object is written and gates the root
141    // compare-and-swap below.
142    let publication_started_ms = timer.monotonic_now_ms();
143    // A namespace that has published no manifest of its own has no runs to
144    // fold: reorganization has nothing to do until its first flush.
145    let Some(root) = read_metadata_root_object_if_present(store, namespace_id)
146        .await
147        .map_err(CoreError::load_head)?
148        .map(|loaded| loaded.envelope.state)
149    else {
150        return Ok(MetadataReorganizeReport {
151            namespace_id: namespace_id.clone(),
152            outcome: MetadataReorganizeOutcome::NotNeeded { l0_runs: 0 },
153        });
154    };
155    let tables = load_verified_manifest_tables(store, namespace_id, &root.manifest_object_id)
156        .await
157        .map_err(|error| {
158            CoreError::MetadataProjection(MetadataProjectionLoadError::ManifestLoad(error))
159        })?;
160    let previous = tables.manifest();
161
162    let l0_runs = l0_run_count(&previous.payload);
163    if l0_runs < policy.max_l0_runs.get()
164        && !manifest_has_partial_reorganization(tables.scan_runs.as_ref())
165    {
166        return Ok(MetadataReorganizeReport {
167            namespace_id: namespace_id.clone(),
168            outcome: MetadataReorganizeOutcome::NotNeeded { l0_runs },
169        });
170    }
171    let Some(group) = select_family_group(&previous.payload) else {
172        // L0 runs exist but hold no rows (empty families); nothing to fold.
173        return Ok(MetadataReorganizeReport {
174            namespace_id: namespace_id.clone(),
175            outcome: MetadataReorganizeOutcome::NotNeeded { l0_runs },
176        });
177    };
178    let Some(input) = select_reorganization_input(&tables, group, policy)
179        .await
180        .map_err(|error| {
181            CoreError::MetadataProjection(MetadataProjectionLoadError::ManifestLoad(error))
182        })?
183    else {
184        return Ok(MetadataReorganizeReport {
185            namespace_id: namespace_id.clone(),
186            outcome: MetadataReorganizeOutcome::BudgetExhausted {
187                families: group.to_vec(),
188                l0_runs,
189            },
190        });
191    };
192
193    // Merge only the selected complete runs. The scan reads exactly the
194    // manifest's tables — never the WAL tail — and the unselected
195    // descriptors remain in the replacement manifest unchanged.
196    let mut rows_by_family = BTreeMap::<MetadataTableFamily, Vec<MetadataRow>>::new();
197    for family in group {
198        let rows = tables
199            .scan_prefix_in_runs(&input.runs, *family, "")
200            .await
201            .map_err(|error| {
202                CoreError::MetadataProjection(MetadataProjectionLoadError::ManifestLoad(error))
203            })?;
204        rows_by_family.insert(*family, rows);
205    }
206    // The paired groups exist to preserve index parity; verify it on the
207    // selected complete runs before writing anything.
208    if group.contains(&MetadataTableFamily::DirentryBinds) {
209        validate_direntry_child_bind_index(
210            root.manifest_object_id.as_ref(),
211            rows_by_family
212                .get(&MetadataTableFamily::DirentryBinds)
213                .map_or(&[], Vec::as_slice),
214            rows_by_family
215                .get(&MetadataTableFamily::DirentryChildBinds)
216                .map_or(&[], Vec::as_slice),
217        )
218        .map_err(|error| {
219            CoreError::MetadataProjection(MetadataProjectionLoadError::ManifestLoad(error))
220        })?;
221    }
222    if group.contains(&MetadataTableFamily::Revisions) {
223        validate_revision_by_inode_desc_index(
224            root.manifest_object_id.as_ref(),
225            rows_by_family
226                .get(&MetadataTableFamily::Revisions)
227                .map_or(&[], Vec::as_slice),
228            rows_by_family
229                .get(&MetadataTableFamily::RevisionsByInodeDesc)
230                .map_or(&[], Vec::as_slice),
231        )
232        .map_err(|error| {
233            CoreError::MetadataProjection(MetadataProjectionLoadError::ManifestLoad(error))
234        })?;
235    }
236    let head = read_head_object(store, namespace_id)
237        .await
238        .map_err(CoreError::load_head)?
239        .envelope
240        .state;
241    let floor_seq = resolve_retention_floor_seq(store, &head)
242        .await
243        .map_err(CoreError::load_head)?;
244    drop_rows_below_retention_floor(&mut rows_by_family, floor_seq)?;
245
246    let run_tables = build_manifest_tables_from_rows(
247        store,
248        namespace_id,
249        previous.payload.head_seq,
250        CHECKPOINT_BASE_RUN_LEVEL,
251        |family| rows_by_family.remove(&family).unwrap_or_default(),
252        MetadataTableSegmentation::Base {
253            max_rows_per_segment: policy.max_rows_per_segment,
254        },
255    )
256    .await?;
257    debug_assert_manifest_table_segments_do_not_overlap(&run_tables);
258
259    let mut metadata_files: Vec<_> = previous
260        .payload
261        .metadata_files
262        .iter()
263        .filter(|descriptor| {
264            !group.contains(&descriptor.family)
265                || !input
266                    .run_ids
267                    .contains(&(descriptor.run_seq, descriptor.level))
268        })
269        .cloned()
270        .collect();
271    metadata_files.extend(flatten_manifest_tables(run_tables));
272    // `base_seq` is the manifest's oldest-run marker: every referenced run
273    // must sit at or above it, including L0 runs other groups have not
274    // folded yet.
275    let base_seq = metadata_files
276        .iter()
277        .map(|descriptor| descriptor.run_seq)
278        .min()
279        .unwrap_or(previous.payload.base_seq);
280
281    let manifest =
282        write_reorganized_manifest(store, namespace_id, previous, metadata_files, base_seq, {
283            let mut payload_floor = previous.payload.retention_floor_seq;
284            if floor_seq > payload_floor {
285                payload_floor = floor_seq;
286            }
287            payload_floor
288        })
289        .await?;
290
291    ensure_metadata_publication_budget(timer, publication_started_ms, namespace_id)?;
292    match publish_metadata_root(
293        store,
294        namespace_id,
295        &manifest,
296        Some(root.manifest_object_id.clone()),
297        context.now_ms,
298    )
299    .await?
300    {
301        ManifestPublicationOutcome::Published(_) => Ok(MetadataReorganizeReport {
302            namespace_id: namespace_id.clone(),
303            outcome: MetadataReorganizeOutcome::UnitPublished {
304                families: group.to_vec(),
305                folded_l0_rows: input.folded_l0_rows,
306                input_runs: input.runs.len(),
307                decoded_input_rows: input.decoded_rows,
308                decoded_input_bytes: input.decoded_bytes,
309                manifest_id: manifest.payload.manifest_id,
310            },
311        }),
312        ManifestPublicationOutcome::Superseded(_) | ManifestPublicationOutcome::RootCasRaceLost => {
313            Ok(MetadataReorganizeReport {
314                namespace_id: namespace_id.clone(),
315                outcome: MetadataReorganizeOutcome::Superseded,
316            })
317        }
318    }
319}
320
321struct ReorganizationInput {
322    runs: Vec<MetadataRunManifest>,
323    run_ids: BTreeSet<(ChangeSeq, u32)>,
324    folded_l0_rows: u64,
325    decoded_rows: u64,
326    decoded_bytes: u64,
327}
328
329/// Selects the existing compacted accumulator followed by L0 runs
330/// oldest-first. Index sections are read before row payloads so the
331/// decoded-byte budget is known exactly from each data block's durable
332/// `decoded_len`; a run that would cross a budget is not decoded or
333/// partially included.
334async fn select_reorganization_input<S: ObjectStore + ?Sized>(
335    tables: &VerifiedMetadataTables<'_, S>,
336    group: &[MetadataTableFamily],
337    policy: MetadataLsmPolicy,
338) -> std::result::Result<Option<ReorganizationInput>, ManifestLoadError> {
339    let mut candidates = tables
340        .scan_runs
341        .iter()
342        .filter(|run| run_has_group_rows(run, group))
343        .collect::<Vec<_>>();
344    candidates.sort_by(|left, right| {
345        let left_is_l0 = left.level == CHECKPOINT_L0_RUN_LEVEL;
346        let right_is_l0 = right.level == CHECKPOINT_L0_RUN_LEVEL;
347        left_is_l0
348            .cmp(&right_is_l0)
349            .then(left.run_seq.cmp(&right.run_seq))
350            .then(right.level.cmp(&left.level))
351    });
352    let candidate_count = candidates.len();
353    let row_budget =
354        u64::try_from(policy.max_decoded_input_rows_per_step.get()).unwrap_or(u64::MAX);
355    let byte_budget =
356        u64::try_from(policy.max_decoded_input_bytes_per_step.get()).unwrap_or(u64::MAX);
357
358    let mut runs = Vec::new();
359    let mut decoded_rows = 0u64;
360    let mut decoded_bytes = 0u64;
361    let mut folded_l0_rows = 0u64;
362    for run in candidates
363        .into_iter()
364        .take(policy.max_input_runs_per_step.get())
365    {
366        let run_rows = group_run_descriptors(run, group)
367            .map(|descriptor| descriptor.row_count)
368            .sum::<u64>();
369        if decoded_rows.saturating_add(run_rows) > row_budget {
370            break;
371        }
372        let run_bytes = decoded_group_run_bytes(tables, run, group).await?;
373        if decoded_bytes.saturating_add(run_bytes) > byte_budget {
374            break;
375        }
376        if run.level == CHECKPOINT_L0_RUN_LEVEL {
377            folded_l0_rows = folded_l0_rows.saturating_add(run_rows);
378        }
379        decoded_rows = decoded_rows.saturating_add(run_rows);
380        decoded_bytes = decoded_bytes.saturating_add(run_bytes);
381        runs.push(run.clone());
382    }
383
384    let selected_l0 = runs.iter().any(|run| run.level == CHECKPOINT_L0_RUN_LEVEL);
385    let makes_progress = selected_l0 && (runs.len() > 1 || candidate_count == 1);
386    if !makes_progress {
387        return Ok(None);
388    }
389    let run_ids = runs.iter().map(|run| (run.run_seq, run.level)).collect();
390    Ok(Some(ReorganizationInput {
391        runs,
392        run_ids,
393        folded_l0_rows,
394        decoded_rows,
395        decoded_bytes,
396    }))
397}
398
399/// A bounded fold stamps its output at the manifest head. If older or
400/// same-seq L0 runs remain, that ordering is the durable resume marker. A
401/// fresh L0 appended after a completed fold is strictly newer than every
402/// base-tier run and therefore does not bypass the normal trigger.
403fn manifest_has_partial_reorganization(runs: &[MetadataRunManifest]) -> bool {
404    let Some(oldest_l0_seq) = runs
405        .iter()
406        .filter(|run| run.level == CHECKPOINT_L0_RUN_LEVEL)
407        .map(|run| run.run_seq)
408        .min()
409    else {
410        return false;
411    };
412    runs.iter()
413        .any(|run| run.level != CHECKPOINT_L0_RUN_LEVEL && run.run_seq >= oldest_l0_seq)
414}
415
416fn run_has_group_rows(run: &MetadataRunManifest, group: &[MetadataTableFamily]) -> bool {
417    group_run_descriptors(run, group).next().is_some()
418}
419
420fn group_run_descriptors<'a>(
421    run: &'a MetadataRunManifest,
422    group: &'a [MetadataTableFamily],
423) -> impl Iterator<Item = &'a MetadataFileRef> {
424    run.tables
425        .iter()
426        .filter(|table| group.contains(&table.family))
427        .flat_map(|table| &table.segments)
428}
429
430async fn decoded_group_run_bytes<S: ObjectStore + ?Sized>(
431    tables: &VerifiedMetadataTables<'_, S>,
432    run: &MetadataRunManifest,
433    group: &[MetadataTableFamily],
434) -> std::result::Result<u64, ManifestLoadError> {
435    let mut decoded_bytes = 0u64;
436    for descriptor in group_run_descriptors(run, group) {
437        let index = load_segment_index_for_reorganization(
438            tables.store,
439            tables.table_cache,
440            &tables.block_memo,
441            descriptor,
442        )
443        .await?;
444        for entry in index.iter() {
445            decoded_bytes = decoded_bytes.saturating_add(u64::from(entry.block.decoded_len));
446        }
447    }
448    Ok(decoded_bytes)
449}
450
451/// The family group with the most L0 rows to fold; ties resolve in group
452/// order. `None` when no group has L0 rows.
453fn select_family_group(
454    payload: &NamespaceManifestPayload,
455) -> Option<&'static [MetadataTableFamily]> {
456    REORGANIZE_FAMILY_GROUPS
457        .into_iter()
458        .map(|group| (group_l0_rows(payload, group), group))
459        .filter(|(rows, _)| *rows > 0)
460        .max_by(|(left_rows, left), (right_rows, right)| {
461            left_rows.cmp(right_rows).then_with(|| {
462                // On ties the EARLIER group must win; comparing positions
463                // reversed makes max_by pick it.
464                position_of(right).cmp(&position_of(left))
465            })
466        })
467        .map(|(_, group)| group)
468}
469
470fn position_of(group: &[MetadataTableFamily]) -> usize {
471    REORGANIZE_FAMILY_GROUPS
472        .iter()
473        .position(|candidate| candidate.as_ptr() == group.as_ptr())
474        .unwrap_or(usize::MAX)
475}
476
477fn group_l0_rows(payload: &NamespaceManifestPayload, group: &[MetadataTableFamily]) -> u64 {
478    payload
479        .metadata_files
480        .iter()
481        .filter(|descriptor| {
482            descriptor.level == CHECKPOINT_L0_RUN_LEVEL && group.contains(&descriptor.family)
483        })
484        .map(|descriptor| descriptor.row_count)
485        .sum()
486}
487
488async fn write_reorganized_manifest<S: ObjectStore + ?Sized>(
489    store: &S,
490    namespace_id: &NamespaceId,
491    previous: &NamespaceManifestEnvelope,
492    metadata_files: Vec<MetadataFileRef>,
493    base_seq: ChangeSeq,
494    retention_floor_seq: ChangeSeq,
495) -> Result<NamespaceManifestEnvelope> {
496    let manifest_id = next_manifest_id_after(previous.payload.manifest_id)?;
497    for _allocation_attempt in 0..CONTENTION_RETRY_LIMIT {
498        let manifest_object_id = ManifestObjectId::generate(manifest_id);
499        let manifest_key = metadata_manifest_object(namespace_id.as_str(), &manifest_object_id);
500        match load_namespace_manifest_envelope_if_present(
501            store,
502            namespace_id,
503            &manifest_object_id,
504            &manifest_key,
505        )
506        .await
507        {
508            Ok(Some(_existing)) => continue,
509            Ok(None) => {}
510            Err(error) => {
511                return Err(CoreError::MetadataProjection(
512                    MetadataProjectionLoadError::ManifestLoad(error),
513                ))
514            }
515        }
516        let manifest = NamespaceManifestEnvelope::from_payload(NamespaceManifestPayload {
517            namespace_id: namespace_id.clone(),
518            manifest_id,
519            manifest_object_id,
520            head_seq: previous.payload.head_seq,
521            head_commit_id: previous.payload.head_commit_id.clone(),
522            base_seq,
523            writer_epoch: previous.payload.writer_epoch,
524            next_inode_id: previous.payload.next_inode_id,
525            retention_floor_seq,
526            metadata_files: metadata_files.clone(),
527        })
528        .map_err(|err| {
529            CoreError::Internal(format!("failed to build reorganized manifest: {err}"))
530        })?;
531        match write_namespace_manifest(store, &manifest).await {
532            Ok(()) => return Ok(manifest),
533            Err(MetadataProjectionLoadError::ManifestLoad(
534                ManifestLoadError::ManifestConflict { .. },
535            )) => continue,
536            Err(error) => return Err(CoreError::MetadataProjection(error)),
537        }
538    }
539    Err(CoreError::Internal(
540        "reorganized manifest allocation retry exhausted".to_owned(),
541    ))
542}
543
544/// Drops rows that no retained sequence can observe (format spec,
545/// "Compaction"). Conservative subset: superseded or unbound bindings and
546/// spent unbind markers at or below the retention floor, and cancelled
547/// active-deletion pairs. Revision rows are never dropped — file history is
548/// durable data retained independently of the replay floor — and tombstone
549/// and inode rows are always retained until reachability-based dropping is
550/// designed.
551pub(super) fn drop_rows_below_retention_floor(
552    rows_by_family: &mut BTreeMap<MetadataTableFamily, Vec<MetadataRow>>,
553    retention_floor_seq: ChangeSeq,
554) -> Result<()> {
555    // At the floor only the latest non-unbound bind per (parent, name) slot
556    // is visible; an unbind marker at or below the floor has finished its
557    // work once every bind it covered is gone.
558    // Unbind identity here omits child_inode_id (the read path also matches
559    // it); the 4-tuple is already unique for writer-produced rows, so the
560    // predicates agree on every legal history.
561    let mut unbound_at_floor = BTreeSet::new();
562    for row in rows_by_family
563        .get(&MetadataTableFamily::DirentryUnbinds)
564        .into_iter()
565        .flatten()
566    {
567        if let MetadataRow::DirentryUnbind {
568            parent_inode_id,
569            name_key,
570            bind_seq,
571            bind_delta_index,
572            unbind_seq,
573            ..
574        } = row
575        {
576            if *unbind_seq <= retention_floor_seq {
577                unbound_at_floor.insert((
578                    *parent_inode_id,
579                    name_key.clone(),
580                    *bind_seq,
581                    *bind_delta_index,
582                ));
583            }
584        }
585    }
586    let mut latest_bind_at_floor = BTreeMap::new();
587    for row in rows_by_family
588        .get(&MetadataTableFamily::DirentryBinds)
589        .into_iter()
590        .flatten()
591    {
592        if let MetadataRow::DirentryBind {
593            parent_inode_id,
594            name_key,
595            bind_seq,
596            bind_delta_index,
597            ..
598        } = row
599        {
600            if *bind_seq <= retention_floor_seq {
601                let candidate = (*bind_seq, *bind_delta_index);
602                let latest = latest_bind_at_floor
603                    .entry((*parent_inode_id, name_key.clone()))
604                    .or_insert(candidate);
605                if candidate > *latest {
606                    *latest = candidate;
607                }
608            }
609        }
610    }
611    // Load-bearing writer invariant: a bind is only ever superseded by an
612    // operation that also unbinds it, so every non-latest bind at or below
613    // the floor must have a matching unbind at or below the floor. The drop
614    // is only visibility-preserving under that rule; refuse to compact state
615    // that violates it.
616    for row in rows_by_family
617        .get(&MetadataTableFamily::DirentryBinds)
618        .into_iter()
619        .flatten()
620    {
621        if let MetadataRow::DirentryBind {
622            parent_inode_id,
623            name_key,
624            bind_seq,
625            bind_delta_index,
626            ..
627        } = row
628        {
629            if *bind_seq <= retention_floor_seq
630                && latest_bind_at_floor.get(&(*parent_inode_id, name_key.clone()))
631                    != Some(&(*bind_seq, *bind_delta_index))
632                && !unbound_at_floor.contains(&(
633                    *parent_inode_id,
634                    name_key.clone(),
635                    *bind_seq,
636                    *bind_delta_index,
637                ))
638            {
639                return Err(CoreError::NamespaceCorrupt(format!(
640                    "bind at seq `{bind_seq}` delta {bind_delta_index} for parent `{parent_inode_id}` is superseded at or below the retention floor without an unbind; refusing to drop rows"
641                )));
642            }
643        }
644    }
645
646    let retain_bind = |row: &MetadataRow| match row {
647        MetadataRow::DirentryBind {
648            parent_inode_id,
649            name_key,
650            bind_seq,
651            bind_delta_index,
652            ..
653        } => {
654            *bind_seq > retention_floor_seq
655                || (latest_bind_at_floor.get(&(*parent_inode_id, name_key.clone()))
656                    == Some(&(*bind_seq, *bind_delta_index))
657                    && !unbound_at_floor.contains(&(
658                        *parent_inode_id,
659                        name_key.clone(),
660                        *bind_seq,
661                        *bind_delta_index,
662                    )))
663        }
664        _ => true,
665    };
666    for family in [
667        MetadataTableFamily::DirentryBinds,
668        MetadataTableFamily::DirentryChildBinds,
669    ] {
670        if let Some(rows) = rows_by_family.get_mut(&family) {
671            rows.retain(retain_bind);
672        }
673    }
674    if let Some(rows) = rows_by_family.get_mut(&MetadataTableFamily::DirentryUnbinds) {
675        rows.retain(|row| match row {
676            MetadataRow::DirentryUnbind { unbind_seq, .. } => *unbind_seq > retention_floor_seq,
677            _ => true,
678        });
679    }
680
681    // Active deletions are current state, not history, so the retention
682    // floor has NO say over them: a deletion stays listed and recoverable
683    // however far the floor advances — that is the product promise, and
684    // dropping a row at the floor would silently retire a recoverable
685    // deletion. The only rows that go are the pairs that cancelled each
686    // other. A removal marker's listed row is always in the same merged set:
687    // the deletion commits before the undelete, runs merge oldest-first, and
688    // the selected subset is a prefix of that order, so a marker can never
689    // outlive the row it names.
690    if let Some(rows) = rows_by_family.get_mut(&MetadataTableFamily::ActiveDeletions) {
691        let revoked: BTreeSet<(ChangeSeq, InodeId)> = rows
692            .iter()
693            .filter_map(|row| match row {
694                MetadataRow::ActiveDeletion {
695                    root_inode_id,
696                    deleted_at_seq,
697                    action: ActiveDeletionRowAction::Removed { .. },
698                } => Some((*deleted_at_seq, *root_inode_id)),
699                _ => None,
700            })
701            .collect();
702        rows.retain(|row| match row {
703            MetadataRow::ActiveDeletion {
704                root_inode_id,
705                deleted_at_seq,
706                action,
707            } => match action {
708                ActiveDeletionRowAction::Removed { .. } => false,
709                ActiveDeletionRowAction::Listed { .. } => {
710                    !revoked.contains(&(*deleted_at_seq, *root_inode_id))
711                }
712            },
713            _ => true,
714        });
715    }
716
717    // The idempotency horizon is the retention floor: a receipt dropped
718    // here makes its id indistinguishable from one never used, so a commit
719    // retried from below the floor commits AGAIN as a new mutation (format
720    // spec §3.3; pinned by `a_retry_past_the_receipt_horizon_commits_again`).
721    // Replay is guaranteed exactly as long as retained history.
722    if let Some(rows) = rows_by_family.get_mut(&MetadataTableFamily::CommitReceipts) {
723        rows.retain(|row| match row {
724            MetadataRow::CommitReceipt { committed_seq, .. } => {
725                *committed_seq >= retention_floor_seq
726            }
727            _ => true,
728        });
729    }
730    Ok(())
731}