Files
rustfs/docs/architecture/minio-scanner-compat.md
T
Cursor Agent 5819ce0412 docs(scanner): compare MinIO data-scanner implementation and gaps
Record heal-fidelity, bucket-order, cache-format, and alert-wire
differences against minio/minio, plus independent improvement contracts.

Co-authored-by: RustFS <hello@rustfs.com>
2026-08-18 02:00:50 +00:00

22 KiB

MinIO ↔ RustFS Data Scanner Comparison

Assesses how closely the RustFS background data scanner matches MinIO's cmd/data-scanner.go implementation: cycle leadership, namespace walk, usage accounting, ILM admission, replication repair, heal/bitrot selection, alerts, and operator surfaces. This is a durable gap analysis. It changes no scanner code. Every claim cites the code that backs it.

MinIO sources below are the public minio/minio master tree as of 2026-08-18 (cmd/data-scanner.go, cmd/erasure.go, cmd/xl-storage.go, cmd/data-usage-cache.go, internal/config/scanner/scanner.go). They are not files in this repository.

Operator runtime knobs already documented here stay in scanner-runtime-controls.md. This page does not duplicate that runbook.

Executive Summary

The two scanners share the same skeleton. Both run one cluster-wide leader loop, persist a cycle counter in .bloomcycle.bin, walk folders with a 1-in-16 compacted-leaf schedule, select objects for heal with a 1-in-1024 hash, compact usage trees at the same child thresholds, evaluate ILM through a lifecycle evaluator, enqueue replication heals, emit excess-version and excess-folder alerts, and throttle with a proportional sleeper.

The remaining gaps are not "the scanner is missing". They are heal fidelity, cross-set scheduling, on-disk cache interop, and operator/notification wire names. The highest-severity difference is that MinIO heals a selected object inline and then cleans abandoned parts, while RustFS admits a low-priority heal request that can be dropped and never calls check_abandoned_parts on that path.

RustFS also has several load-bearing additions MinIO does not: dirty-usage fast wake, cycle budgets, leader-epoch fencing, remote NS-scanner protocol v6, checkpoint resume, and clean-idle backoff. Those should be preserved.

Area Verdict Why it matters
Cycle / leadership / bitrot mode Close Same .bloomcycle.bin counter, same deep-scan window of healObjectSelectProb cycles.
Folder walk, compact, 1/16 + 1/1024 selection Close Constants and mod / modAlt schedule match.
ILM eval + expiry/transition enqueue Close Same action set; RustFS additionally gates metrics on queue admission.
Bucket replication repair Close Both call queueReplicationHeal / queue_heal.
Scanner-selected object heal Gap MinIO HealObject is synchronous; RustFS async admission can drop the check.
Abandoned-part cleanup on selected objects Gap MinIO calls CheckAbandonedParts after heal; RustFS scanner path does not.
Bucket order across erasure sets Gap MinIO shuffles per set; RustFS is deterministic dirty→new→existing.
.usage-cache.bin bytes Incompatible MinIO is zstd+msgp v8; RustFS is raw MessagePack. Reconstructable, not reusable.
Excess-folder default Differs MinIO 50000; RustFS 65538.
Alert event names Differs MinIO s3:ObjectManyVersions; RustFS s3:Scanner:ManyVersions.

Architecture Overlay

Both stacks are init → leader lock → cycle → NSScanner → per-set disk walk → scanDataFolder → applyActions.

initDataScanner / init_data_scanner
        │
        ▼
runDataScanner / run_data_scanner          (cluster leader lock)
        │
        ├─ load .bloomcycle.bin cycle state
        ├─ getCycleScanMode (Normal vs Deep bitrot)
        └─ NSScanner(wantCycle, scanMode)
                │
                ▼
        per erasure set  (MinIO: er.nsScanner; RustFS: scanner_io)
                │
                ├─ load set .usage-cache.bin
                ├─ bucket order (new first, then existing)
                └─ per disk: NSScanner / scan_data_folder
                        │
                        ├─ lifecycle + replication config
                        ├─ folder walk, compact, 1/16 skip
                        ├─ getSize → applyActions
                        │       ├─ ILM eval
                        │       ├─ heal selected versions
                        │       └─ healReplication
                        └─ abandoned-children heal walk
Stage MinIO RustFS
Startup initDataScanner goroutine, random sleep ≥ 1s between runDataScanner calls init_data_scanner in crates/scanner/src/scanner.rs; optional cold-cache / replication skip of start delay
Leader globalLeaderLock.GetLock (blocks) leader.lock write lock with timeout; contended cycle returns and retries
Cycle persist LE uint64 + msgp currentScannerCycle at .bloomcycle.bin LE uint64 + optional RSCYC001 epoch header + msgpack CurrentCycle
Set walk erasureObjects.nsScanner in MinIO cmd/erasure.go crates/scanner/src/scanner_io.rs
Folder walk folderScanner.scanFolder crates/scanner/src/scanner_folder.rs
Object actions scannerItem.applyActions ScannerItem::apply_actions
Usage publish storeDataUsageInBackend.usage.json store_data_usage_in_backend.usage.v2.json (legacy .usage.json read-only; scanner-usage-v2 in compat-cleanup-register.md)

What Already Matches

These are not gaps. Treat regressions here as MinIO-parity bugs.

Cycle constants and heal selection

Constant MinIO RustFS Evidence
Folder sleep quantum 1ms sleeper MIN_SLEEP 1ms MinIO dataScannerSleepPerFolder; crates/scanner/src/sleeper.rs
Compacted-leaf visit period 16 16 (RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES) MinIO dataUsageUpdateDirCycles; crates/scanner/src/scanner_folder.rs DATA_USAGE_UPDATE_DIR_CYCLES
Heal object probability 1024 1024 (RUSTFS_HEAL_OBJECT_SELECT_PROB) MinIO healObjectSelectProb; DEFAULT_HEAL_OBJECT_SELECT_PROB
Compact least objects 500 500 both dataScannerCompactLeastObject / DATA_SCANNER_COMPACT_LEAST_OBJECT
Compact at children 10000 10000 both
Compact at folders 2500 2500 children/4
Force compact folders 250000 250000 both
Start delay default 1 minute speed-preset derived (default 1 minute) MinIO dataScannerStartDelay; RustFS speed preset
Excess versions 100 100 MinIO scannerExcessObjectVersions; DEFAULT_SCANNER_ALERT_EXCESS_VERSIONS
Excess version size 1 TiB 1 TiB MinIO scannerExcessObjectVersionsTotalSize; DEFAULT_SCANNER_ALERT_EXCESS_VERSION_SIZE

Folder skip uses hash.mod(nextCycle, 16). Object heal uses hash.modAlt(nextCycle/div, healObjectSelect/div). Compacted folders raise objectHealProbDiv to 16 so the 1/1024 overall probability still holds. RustFS copies this in scan_folder (mod_ + object_heal_prob_div).

Path identity is the cleaned path string, not a digest. MinIO hashPath is path.Clean; RustFS hash_path in crates/data-usage/src/data_usage.rs cleans the same way. xxhash is only used in MinIO mod / modAlt.

Bitrot cycle window

Both enter Deep scan when:

  • bitrot cycle is 0 (always deep), or
  • current - bitrotStartCycle < healObjectSelectProb, or
  • wall time since bitrotStartTime exceeds the configured bitrot cycle.

MinIO: getCycleScanMode in cmd/data-scanner.go. RustFS: get_cycle_scan_mode in crates/scanner/src/scanner.rs. Both persist backgroundHealInfo / BackgroundHealInfo and skip it on single-disk (globalIsErasureSD / scanner_is_erasure_sd).

ILM action coverage

apply_actions covers the same action enum MinIO does: delete, delete version, delete restored, delete-all, del-marker-delete-all, transition, and none (heal + replication). Evaluator is constructed with lock retention and replication config. Free versions are swept through enqueue_free_version / enqueue_runtime_free_version. Noncurrent versions batch through enqueueNoncurrentVersions / enqueue_runtime_newer_noncurrent.

Speed presets

fastest / fast / default / slow / slowest map to the same delay, max-wait, and cycle defaults MinIO LookupConfig uses (0/0/1s, 1/100ms/1m, 2/1s/1m, 10/15s/1m, 100/15s/30m).

Abandoned-children folder heal

When a previously cached child is missing from the current directory listing, both scanners quorum-list the prefix and enqueue bucket/object heals. RustFS keeps this walk in scan_folder after the new/existing folder scans.

Read-path heal still exists

MinIO also heals from GET/HEAD and MRF; the scanner is not the only heal source (MinIO PR 18050). RustFS GET decode errors enqueue HealRequestSource::ReadRepair in crates/ecstore/src/set_disk/read.rs. Scanner-heal gaps therefore delay background repair, not all repair.


Gaps

Severity is the operator-visible failure if the gap is left as-is.

G1 — Scanner object heal is async and droppable (high)

MinIO applyHealing calls ObjectLayer.HealObject and waits. The folder walker then treats getSize as having already healed the object (cmd/data-scanner.go, comment on abandonedChildren deletion). After a successful heal it always runs CheckAbandonedParts with Remove: healDeleteDangling.

RustFS heal_actions always returns the original actual_size and, when heal is selected, calls enqueue_healsend_heal_request_with_admission at HealChannelPriority::Low. Full and Dropped admissions are logged and skipped. RUSTFS_SCANNER_INLINE_HEAL_ENABLE only warns inline_heal_rollback_unsupported (warn_inline_heal_compat_requested in crates/scanner/src/scanner_folder.rs).

Failure: a 1/1024-selected object with a missing shard can remain unhealed for many more cycles if the heal channel is full. Bitrot Deep selection has the same drop window. Usage accounting is unchanged by heal outcome, so a reconstructed size never replaces the pre-heal size in that cycle.

Do not "fix" this by making every scanner heal inline on the walk goroutine. MinIO can afford that because HealObject is the storage layer. RustFS already has a heal worker pool and admission. The missing contract is: scanner-selected heals must be durable (pending_heals retry) and must not be silent-dropped without a later guaranteed retry.

Pending heals already exist for some metadata/abandoned-child failures (PendingScannerHeal in the usage cache). Object-selection heals that hit HealAdmissionResult::Full do not currently join that retry list.

G2 — No CheckAbandonedParts on the scanner object-heal path (high)

check_abandoned_parts is implemented on the store (crates/ecstore/src/store/heal.rs, crates/ecstore/src/set_disk/ops/heal.rs) and is in the object API. The scanner never calls it. The heal task processor (crates/heal/src/heal/task.rs) also does not call it after a scanner-originated heal_object.

MinIO records this as scannerMetricCleanAbandoned. RustFS defines Metric::CleanAbandoned in crates/common/src/metrics.rs but the scanner crate never records it.

Failure: leftover part.N files after a successful object heal stay until some other heal path notices them. Disk usage and bitrot surface area remain inflated.

G3 — Erasure-set bucket order is not shuffled (medium)

MinIO nsScanner builds a permutation of buckets, emits new buckets (absent from the old cache) first in that random order, then existing buckets in that random order. Comment: otherwise the same buckets are scanned across every erasure set at the same time.

RustFS bucket_usage_scan_order in crates/scanner/src/scanner_io.rs is deterministic: dirty buckets, then cache-miss (new) buckets, then cache-hit buckets, preserving ListBuckets order.

Dirty-first is a RustFS improvement (MinIO has no dirty-usage wake). The gap is the existing bucket tail: under many buckets and several sets, RustFS lock-steps ILM/heal/replication load onto the same prefixes.

G4 — Alert event names and audit channel (medium)

MinIO emits event.ObjectManyVersions, event.ObjectLargeVersions, event.PrefixManyFolders, plus auditLogInternal events scanner:manyversions / scanner:largeversions / scanner:manyprefixes.

RustFS emits s3:Scanner:ManyVersions, s3:Scanner:LargeVersions, s3:Scanner:BigPrefix (EVENT_SCANNER_* in crates/scanner/src/scanner_folder.rs; wire names in crates/s3-types/src/event_name.rs). Notifications are edge-held 24h (MinIO re-emits every cycle). There is no scanner audit-log counterpart.

Failure: notification destinations configured for MinIO event names miss RustFS scanner alerts. Audit pipelines that key on scanner:manyversions see nothing.

G5 — Excess-folder default differs (low)

MinIO scannerExcessFolders default is 50000 (internal/config/scanner/scanner.go). RustFS DEFAULT_SCANNER_ALERT_EXCESS_FOLDERS is 65538 (crates/config/src/constants/scanner.rs).

Failure: the same prefix is silent on MinIO and noisy on RustFS (or the reverse if an operator copied MinIO runbooks).

G6 — .usage-cache.bin is not MinIO-readable (medium for migration, low otherwise)

MinIO writes one version byte (dataUsageCacheVerCurrent = 8) plus zstd plus msgp (cmd/data-usage-cache.go serializeTo). RustFS DataUsageCache::save_inner writes uncompressed rmp_serde with no version byte (crates/scanner/src/data_usage_define.rs).

Both use the same object name .usage-cache.bin and a .bkp sibling. A MinIO disk set attached to RustFS rebuilds the tree on first scan; the bytes are not reused. The inverse is also true.

.bloomcycle.bin is closer: both start with a little-endian u64 next cycle. RustFS additionally writes RSCYC001 + leader epoch when fencing is active, and still reads a bare 8-byte or 8-byte+msgpack MinIO blob (decode_scanner_cycle_state). MinIO cannot consume the fenced form.

Cluster usage snapshots diverge on purpose: MinIO .usage.json, RustFS authoritative .usage.v2.json. That is already a compat register item, not a scanner-logic bug.

G7 — Heal-selected usage size ignores heal result (low)

MinIO healActions replaces actualSz with HealObject's res.ObjectSize when positive. RustFS heal_actions always returns actual_size. Wrong sizes persist until the next cycle that both selects the object and observes healed metadata.

This is secondary to G1: without a completed heal there is no new size.

G8 — Operator metric names and mc admin scanner info (low)

MinIO scannerMetric.String() is PascalCase (ReadMetadata, ScanObject, ILM). RustFS Metric::as_str is snake_case (read_metadata, scan_object, ilm). mc admin scanner info against a RustFS ScannerMetrics.life_time_ops map will not match MinIO dashboard keys.

RustFS exposes a richer /v3/scanner/status (freshness, runtime config sources, cycle schedule, admission). That is the supported operator surface; MinIO mc scanner info is not a compatibility target unless explicitly added.

G9 — Unversioned replication heal gate (low)

MinIO healReplication returns immediately when oi.VersionID == "". RustFS allows the call when the object is a delete marker or has a version-purge status even if version_id is none/nil (ScannerItem::heal_replication). This is likely more correct for purge/delete-marker repair on unversioned-looking entries, but it is a behavioral difference worth pinning with a test rather than copying MinIO's empty-VersionID skip blindly.


RustFS-Only Behavior To Keep

These are not MinIO gaps. Removing them to "match MinIO" would be a regression.

Addition Where Why keep it
Dirty-usage fast wake + superseded retry (5s base) crates/scanner/src/scanner.rs, scanner_io.rs record_dirty_usage_bucket Quota/usage lag after write bursts; MinIO waits a full cycle.
Cycle object/directory/runtime budgets crates/scanner/src/scanner_budget.rs Bounds scanner blast radius; MinIO only sleeps.
Leader epoch + CAS persist encode_scanner_cycle_state Split-brain cycle counters after lock loss.
Remote NS-scanner protocol v6 crates/scanner/src/remote_scanner.rs; compat ns-scanner-rpc-v3 Distributed disk walks with fencing.
Scan checkpoints / resume hints DataUsageScanCheckpoint Partial cycles after budget cancel.
Clean-idle backoff (single-disk / erasure) ScannerCleanIdleBackoff Stops minute-cadence full walks on idle namespaces.
Heal/replication admission metrics HealAdmissionResult, ScannerReplicationQueueAdmission Makes G1 observable; MinIO has no equivalent queue.
Alert emission cooldown 24h edge-hold Avoids notification storms MinIO still has.

Improvement Workstreams

These are contracts, not a checklist. Each workstream is independently shippable. Do not couple them into one "make scanner like MinIO" rewrite.

W1 — Durable scanner-selected heal (closes G1, G7)

Invariant: if an object is selected by modAlt in a cycle that should_heal(), that object/version is either healed, recorded in pending_heals for a later cycle, or the cycle is marked incomplete for heal work. Silent Full/Dropped is not a success.

Shape: keep the heal channel. On Full/Dropped, persist PendingScannerHeal (object, version, scan mode) the same way abandoned metadata heals already persist. Retry at high or at least non-droppable priority next cycle. When a heal completes, optionally replace the accounted size with the healed size (G7).

Do not: call HealObject inline from scan_folder as a default. The unsupported RUSTFS_SCANNER_INLINE_HEAL_ENABLE warning exists because that rollback fights the worker pool. An opt-in inline path is only justified if a measured admission-drop rate stays high after durable retry.

Tests: (a) selected object missing one shard, heal channel full → pending_heals non-empty, next cycle heals it; (b) Deep mode + recent mtime stays Normal (existing cooldown); (c) usage size updates only after heal success; (d) revert of pending_heals-on-drop fails the test.

W2 — Abandoned-part cleanup after scanner object heal (closes G2)

Invariant: a scanner-selected object heal that succeeds (or that the heal worker reports as already consistent) runs check_abandoned_parts with dangling removal, matching MinIO healDeleteDangling = true.

Shape: call it from the heal worker when source == Scanner, not from the folder walk. That keeps IO off the scanner hot path. Record Metric::CleanAbandoned so last-minute scanner metrics are not a dead enum.

Tests: object with an extra part.N after a valid heal → part removed; dry-run heal does not delete (existing set_disk tests stay authoritative); CleanAbandoned lifetime counter increments.

W3 — Per-set shuffle of existing buckets (closes G3)

Invariant: dirty and new buckets still go first (RustFS dirty-usage contract). The existing-bucket tail is shuffled per erasure set per cycle so sets do not scan the same prefix concurrently.

Shape: smallest change is bucket_usage_scan_order taking a per-set RNG seed (cycle + pool + set). Do not shuffle dirty buckets; that would delay quota/usage repair.

Tests: two sets, three existing buckets, same cycle → different existing tails; dirty bucket always index 0.

W4 — Notification and audit aliases (closes G4, optionally G5)

Invariant: a destination subscribed to MinIO names s3:ObjectManyVersions / s3:ObjectLargeVersions / s3:PrefixManyFolders receives RustFS scanner alerts. Keep the current s3:Scanner:* names as aliases, not replacements, until clients migrate.

Shape: dual-name parse in crates/s3-types/src/event_name.rs (already comments "corresponding to Go") plus dual emit, or a compatibility mapping at notify dispatch. Audit events are optional and should reuse the existing audit pipeline rather than a scanner-specific logger.

Align DEFAULT_SCANNER_ALERT_EXCESS_FOLDERS to 50000 only with a release note; 65538 is not a bug, it is a silent default drift.

W5 — Cache-format interop (closes G6 only if migration requires it)

Invariant for RustFS-only clusters: none. Rebuilding .usage-cache.bin on first scan is acceptable.

Invariant if MinIO disk import is a product goal: either detect MinIO v8 zstd+msgp and ignore/rebuild, or implement a one-shot importer. Writing MinIO-shaped cache from RustFS is not required for serving objects.

Document in operations that .usage-cache.bin is not a migration artifact. .bloomcycle.bin 8-byte prefix already round-trips.

W6 — Operator surface (closes G8)

Keep /v3/scanner/status as the source of truth. If mc admin scanner info support is required, add a madmin-shaped projection with PascalCase life_time_ops keys in addition to snake_case, behind a documented compat flag. Do not rename RustFS metrics; Prometheus and status JSON already use snake_case.


Suggested Verification (when a workstream ships)

Scanner changes are high-risk under AGENTS.md (lifecycle/tiering, heal, S3-visible usage). A workstream PR should run:

  • cargo fmt --all --check
  • cargo test -p rustfs-scanner (and heal tests for W2)
  • the crate's lifecycle integration tests when ILM admission changes
  • make doc-paths-check if this file's citations move

Do not run make pre-pr for documentation-only edits of this page.


Sources

RustFS:

  • crates/scanner/src/scanner.rs — leader loop, cycle fencing, bitrot mode
  • crates/scanner/src/scanner_folder.rs — folder walk, ILM, heal, alerts
  • crates/scanner/src/scanner_io.rs — NSScanner, bucket order, dirty usage
  • crates/scanner/src/scanner_budget.rs — cycle budgets
  • crates/scanner/src/sleeper.rs — proportional throttle
  • crates/scanner/src/data_usage_define.rs — cache persist
  • crates/scanner/src/runtime_config.rs — env/config resolution
  • crates/config/src/constants/scanner.rs — defaults
  • crates/common/src/metrics.rs — metric enum (MinIO-shaped)
  • rustfs/src/admin/handlers/scanner.rs/v3/scanner/status
  • compat-cleanup-register.mdscanner-usage-v2, ns-scanner-rpc-v3

MinIO (minio/minio master, 2026-08-18):

  • cmd/data-scanner.go — init/run, applyActions, healReplication, sleeper
  • cmd/data-scanner-metric.go — metric enum and mc report
  • cmd/erasure.gonsScanner shuffle and per-disk walk
  • cmd/xl-storage.go — disk NSScanner / getSize
  • cmd/data-usage-cache.go — hash mod, zstd+msgp cache
  • cmd/data-usage.go.usage.json / .bloomcycle.bin names
  • internal/config/scanner/scanner.go — speed presets and alert defaults