Overwrite Pool Affinity: Why #245 Was Not Adopted
Decision, October 24, 2026: do not merge the tested pool-affinity changes (PR #245). The experiment addressed a real space-efficiency problem, but neither the broad policy nor the narrower unversioned-only policy achieved an acceptable performance trade-off. Both experimental branches (codex/healthy-pool-affinity and codex/unversioned-pool-affinity) were retained for reference while keeping main unchanged.
Denis Babchuk proposed keeping overwrites in the pool that already holds the object. Review identified and fixed a request-specific write-quorum bug, tightened the health policy, and validated the behavior. Performance measurements then changed the recommendation: the broad policy made deep version histories substantially more expensive, and a second iteration restricted to ordinary unversioned overwrites still regressed small-object writes.
The subsequent multi-pool deletion fix (#257) was merged independently on October 24: while retaining capacity-weighted allocation, it cleans up non-authoritative copies before deleting the authoritative copy so a mid-delete failure cannot expose an overwritten value.
The problem: an overwrite can leave its old copy in another pool
Consider an unversioned key, reports/latest.json, in pool A. A subsequent PUT writes its replacement to pool B. Reads select the newest logical object, while the old physical copy in A continues to occupy space because ordinary overwrites do not clean up obsolete copies across other pools.
The baseline looks up the existing object and then sends a readable result through capacity-weighted pool selection again. Depending on pool usage, repeated writes can eventually leave a pool-local record (xl.meta) in both pools.
This behavior has a specific history:
- Upstream MinIO issue #21087 reported that single-pool uploads could bypass the minimum free-space threshold. PR #21115, merged on July 19, 2025 as
83b2ad418b, enforced capacity checks by reselecting a pool for readable existing objects, which dropped their earlier placement affinity. - SILO issue #199 and PR #207 evaluate conditional writes against the current logical object across pools while keeping the capacity-weighted allocation policy.
- Pool migration uses separate safeguards against stale payloads and delete-marker loss; see Multi-Pool Object Consistency.
The space benefit of overwrite affinity applies specifically to multi-pool deployments with repeated overwrites of the same unversioned keys. Single-pool deployments have no cross-pool duplicates, new keys still require capacity-weighted allocation across pools, and versioned buckets must retain their historical versions regardless of which pool stores them.
What the original PR changed
The proposal reused the existing object lookup and lock boundary. If the latest readable object was already present, its pool became the preferred destination, subject to write quorum and the existing hard free-space check. New keys retained capacity-weighted allocation. When the existing object’s set could not accept a write, allocation excluded that pool before considering the remaining pools.
The production change in cmd/erasure-server-pool.go added 73 lines and removed 3 lines, accompanied by 331 lines of placement tests, covering PUT and multipart initialization on the shared allocation path.
Two capacity rules apply during selection:
- Soft allocation reserve (15%): when alternative pools exist, the allocator filters out pools above roughly 85% usage so emptier pools fill first.
- Hard space check (
hasSpaceFor): checks write size, per-drive capacity, and other constraints againstdiskFillFraction(0.99).
Affinity bypasses the soft reserve when the existing object’s pool is healthy and has room, while preserving the hard capacity check. After a pool expansion, existing keys continue updating in their older, fuller pool rather than migrating to the new pool on overwrite.
Simply deleting the condition that reselects existing objects would lose the capacity checks and fallback introduced in 83b2ad418b. Checking space alone is also insufficient: an erasure set can still have read quorum for an old object while lacking enough online drives to reach write quorum for its replacement. Because pool selection runs before consuming the request body, an unreplayable upload stream cannot be transparently retried in another pool after a write failure.
Correctness review and implementation improvements
Write quorum must match the request
The original helper inferred quorum from the STANDARD storage class for every write. A controlled two-pool test exposed a regression when overwriting an object with REDUCED_REDUNDANCY (RRS):
| Condition | Test setup |
|---|---|
| Storage policy | optimize=capacity; STANDARD=EC:4, REDUCED_REDUNDANCY=EC:2 |
| Existing object | Created with ordinary STANDARD parity |
| Existing object’s set | 13 of 16 drives online; reported usage 90% |
| Other pool | 16 drives online, ample space; an actual RRS probe write succeeded |
| New request | Overwrite using RRS, through PUT or new multipart initialization |
The helper required 12 writers for EC:4 and selected the original pool, whereas the actual EC:2 write required 14 online drives and failed with InsufficientWriteQuorum (HTTP 503). Because lower parity leaves more data shards, RRS requires more online drives for a write. The baseline’s reserve filter skipped the 90%-full pool, selected the healthy second pool, and succeeded across all four pool/entry-point combinations (12/12 attempts failed on the unpatched PR).
The inverse mismatch occurred with PUT’s internal MaxParity option: a write eligible for EC:8 was evaluated against EC:4 and unnecessarily moved to another pool.
A healthy preference, with explicit fallback
The updated implementation passed the request’s storage class and PUT MaxParity into placement, matching the writer’s availability/capacity parity rules (including the extra drive required when data and parity shard counts are equal) while preserving multipart initialization parity semantics:
| Existing object’s target set | Action |
|---|---|
| Fully healthy, writable for this request, and enough space | Prefer that pool |
| Degraded but still writable, with space | Use the existing capacity allocator; the original pool remains a candidate |
| Known to be unwritable or lacking space | Exclude it before reserve filtering, then select another pool |
| No alternative has space, but the original set has space and lacks write quorum | Let the original set report InsufficientWriteQuorum rather than misreporting StorageFull |
| No usable space | Preserve the StorageFull result |
Health and capacity checks shared a single set-level DiskInfo snapshot. A drive counted as a usable writer only if it was online and returned a positive capacity result; drives with a failed DiskInfo call were treated as unhealthy.
Regression tests were added for request parity, known disk faults, degraded sets, scan counts, heterogeneous set sizes, latest-object selection, and existing multipart sessions. The multipart fallback test was also updated to seed its own initial object rather than reusing a prior PUT target that had already moved pools.
The second design: only ordinary unversioned overwrites
After the first performance round showed high version-history rewrite costs, a second branch (codex/unversioned-pool-affinity) narrowed the policy to ordinary unversioned overwrites:
| Request or bucket state | Narrowed behavior |
|---|---|
Ordinary unversioned PUT replacing a readable object |
Apply the healthy-pool preference above |
| New multipart upload over an ordinary unversioned object | Apply it only when initialization reaches pool selection; preserve existing upload binding |
| Versioning enabled or suspended | Retain baseline allocation |
Explicit version ID, including null |
Exclude from affinity |
| Replication, replica-lock reconciliation, or data movement | Exclude from affinity |
| Internal metadata buckets | Exclude from affinity |
| New keys or a single pool | Retain the original path and capacity behavior |
| Existing multipart sessions, ordinary COPY routing, latest delete markers, or read-quorum-error routing | Preserve their existing paths |
Eligibility checked both the bucket’s versioning configuration and the request options. A version-enabled bucket with prefix-excluded unversioned writes, or a version-suspended bucket with mixed history, remained on the baseline allocator.
Functional validation, separated by revision
| Stage | Evidence | Scope |
|---|---|---|
| Initial PR review | Base/head comparison reproduced normal affinity, STANDARD fallback, the RRS failure, and the MaxParity mismatch |
Object-layer unit tests on macOS |
| Improved full implementation | Full Go test suite: 52 packages, 1,823 top-level tests (8,233 with subtests) passed, 12 skipped; targeted -race suite: 35 top-level tests (201 with subtests) passed |
Full-affinity branch on an isolated APFS test volume |
| Full implementation through mcli | mcli 20261018 verified ordinary overwrite/readback, 4 distinct retained versions with historical reads, and multipart overwrite/checksum |
Single-host local-directory deployment |
| Narrowed implementation | Linux targeted suite: 37 top-level tests (209 with subtests) passed across placement, parity, health/capacity fallback, version-state exclusions, PUT/MPU, and conditional writes |
Targeted placement and overwrite suite |
| Build and static checks | Linux amd64 build, make verifiers, lint (0 issues), and whitespace checks passed |
Repository static verification |
The targeted regression filter was:
Benchmark method and what the numbers mean
Both performance rounds ran on m3 (AMD Ryzen AI MAX+ 395, 32 logical CPUs, 61 GiB RAM, Linux 7.0.0-38, CT2000P310SSD8 NVMe with XFS). Two pools each used 16 directories and one erasure set (EC 12+4), all backed by the same XFS filesystem on the shared NVMe SSD.
Both variants were built with Go 1.27.3 (linux/amd64, CGO_ENABLED=0). The server ran on CPUs 4–11 (GOMAXPROCS=8), and the minio-go v7.4.1 benchmark client ran on CPUs 12–15 (GOMAXPROCS=4) over loopback HTTP.
- Throughput: completed object operations per second (for multipart, one operation includes initialization, two part uploads, and completion).
- P50 / P99: medians across runs of each run’s 50th and 99th percentile request latencies.
- CPU cost: server CPU time per completed operation.
- Block I/O: process-accounted reads and writes from
/proc/PID/io. - Active-bucket space: object files, metadata, and directory overhead excluding temporary files.
Round one: the full policy cost more than expected
Round one compared the full candidate (0aa870115d7d) against the baseline (789a2327ba8c) across 54 measured runs (243,718 completed operations, 0 failures). Basic workloads ran 3 pairs of 15-second runs, with 2 additional 60-second pairs for new-key and 4 KiB overwrite workloads, 3 fixed-count runs of 4,000 versions on a single seeded object, and a 4-run ABBA binary-switch sequence on existing dual-copy data:
| Workload | Baseline → full candidate, operations/s | Change | P50, ms | P99, ms | Sample |
|---|---|---|---|---|---|
| Two pools, 4 KiB overwrite; 256 keys, concurrency 16 | 236.9 → 174.5 | −26.3% | 30.4 → 74.4 | 277.9 → 307.4 | 2 × 60 s per variant |
| Two pools, 1 MiB overwrite; 64 keys, concurrency 8 | 140.9 → 132.2 | −6.2% | 34.2 → 41.3 | 169.3 → 176.2 | 3 × 15 s |
| Two pools, 16 MiB multipart overwrite; 16 keys, concurrency 4 | 25.8 → 26.4 | +2.1% | 144.0 → 141.7 | 265.3 → 272.7 | 3 × 15 s |
| Two pools, one 4 KiB key; concurrency 1 | 135.2 → 136.4 | +1.0% | 6.9 → 6.8 | 12.0 → 10.6 | 3 × 15 s |
| Two pools, 4 KiB new keys; concurrency 16 | 290.7 → 296.8 | +2.1% | 46.9 → 48.7 | 170.2 → 165.9 | 2 × 60 s |
| Single pool, 4 KiB overwrite; concurrency 16 | 166.2 → 159.2 | −4.2% | 83.9 → 89.4 | 296.8 → 293.2 | 3 × 15 s |
| Two pools, one 4 KiB key, 4,000 new versions | 76.6 → 53.0 | −30.8% | 11.4 → 17.8 | 47.7 → 59.5 | 3 fixed-count runs |
| Existing dual-copy data, binary-switch ABBA | 198.6 → 202.0 | +1.7% | — | 291.2 → 290.9 | 2 × 20 s |
For ordinary 4 KiB overwrites, both 60-second pairs were slower (−30.7% and −21.8%), as were all three 15-second pairs, even though CPU per operation fell from 5.85 to 5.33 ms and block writes stayed near 128 KiB per operation.
The −30.8% version-history regression had a direct structural cause. A pool-local xl.meta stores the object’s version metadata and inline payload for small objects, and adding a version reserializes and rewrites the entire file. Capacity allocation split the 4,000 versions roughly evenly across both pools (~2,000 versions per xl.meta), whereas affinity concentrated all 4,001 versions into a single pool, doubling the xl.meta rewrite size on every PUT:
- CPU per write: 26.61 ms → 39.29 ms
- Block reads per write: 12.20 MiB → 24.42 MiB
- Block writes per write: 12.27 MiB → 24.49 MiB (cumulative block writes over 4,000 operations rose from 47.9 GiB to 95.7 GiB for ~15.6 MiB of uploaded payload)
- Active data at 4,001 versions: ~48.90 MiB in both variants
- Mean latency over the final 1,000 writes: 18.4 ms → 28.5 ms
Because versioned buckets must keep all historical versions, concentrating them in one pool doubled the metadata rewrite amplification without saving disk space. This motivated narrowing the policy to unversioned overwrites.
Round two: narrowing fixed one cost, but not the acceptance problem
Before running round two (codex/unversioned-pool-affinity vs. 789a2327ba8c), the acceptance threshold for the experiment was set at ≤ 5% ordinary-overwrite throughput loss and ≤ 10% P99 latency growth, with no material P50 or excluded-scope regression.
Four workloads ran 5 pairs each in alternating AB/BA order (5-second warmup and 60-second measurement for timed workloads; 4,000 operations for versioned control; fresh server and data directories per run, no profilers attached):
| Workload | Baseline → narrowed, operations/s | Change | P50, ms | P99, ms |
|---|---|---|---|---|
| Fresh 4 KiB overwrite; 256 keys, concurrency 16 | 167.9 → 158.2 | −5.7% | 66.2 → 75.8 | 470.2 → 428.7 |
| Fresh 1 MiB overwrite; 64 keys, concurrency 8 | 79.45 → 79.58 | +0.2% | 94.0 → 95.6 | 521.5 → 483.2 |
| One 4 KiB key, 4,000 new versions; control | 48.5 → 59.6 | +22.8% | 12.7 → 12.4 | 180.9 → 135.4 |
| New 4 KiB keys; concurrency 16; control | 289.8 → 243.1 | −16.1% | 40.4 → 49.2 | 314.9 → 170.0 |
On the primary 4 KiB overwrite workload, pairwise throughput changes across the five pairs were +11.9%, −14.9%, −1.4%, −10.3%, and −12.8% (median paired change −10.3%; ratio of variant medians −5.7%; P50 +14.4%). Baseline throughput ranged from 141.4 to 202.6 ops/s (CV 13.0%) and narrowed throughput from 146.3 to 176.6 ops/s.
Excluding versioned buckets eliminated the round-one xl.meta write amplification: block writes per version were 12.272 → 12.268 MiB, block reads 12.201 → 12.197 MiB, and CPU 26.98 → 27.26 ms (in the versioned control, the second run of each pair was faster regardless of binary order, and new-key control pairs varied from −44.3% to +15.3% due to shared-SSD order effects).
Restore the same starting data before each run
Because fresh runs let the two placement policies build different on-disk layouts over time, an additional 8-run fixture test restored an identical snapshot before each run—either one pool-local copy per key (256 records) or copies in both pools (512 records)—followed by a 30-second settle period, 5-second warmup, and 60-second measurement:
| Starting state, 256 keys | Baseline → narrowed, operations/s | Change | P50, ms | P99, ms | Ending pool-local records, baseline → narrowed |
|---|---|---|---|---|---|
| One copy per key | 188.0 → 160.1 | −14.9% | 51.9 → 68.0 | 521.0 → 466.4 | 512 → 256 |
| Two copies per key | 185.7 → 140.1 | −24.5% | 37.6 → 79.7 | 460.0 → 629.9 | 512 → 512 |
The single-copy pairs changed by −0.9% and −23.5%. The dual-copy pairs declined by −22.6% and −26.5%, with P50 rising +111.9% and P99 rising +36.9%, while leaving all 512 existing records on disk. On clusters that already have cross-pool duplicates, the narrowed policy paid a steep write penalty without reclaiming any existing space.
Diagnosis and the I/O optimization attempt
Phase timing measured the new health check at 0.014–0.016 ms per call, compared with milliseconds for existing-object lookup and tens of milliseconds for erasure writes. In round two, 4 KiB overwrite CPU time fell from 5.75 to 5.36 ms per operation while throughput dropped, pointing to I/O and scheduling wait rather than CPU overhead.
Go execution traces showed syscall waits concentrated in RenameData → writeAllInternal → write, alongside read and goroutine scheduling waits.
A follow-up experiment routed small metadata writes (~791 bytes, below the 4 KiB alignment threshold where CopyAligned disables O_DIRECT) from the Linux O_DSYNC path to writeAllDirect, turning the write into a buffered write followed by fdatasync. Across five runs, narrowed affinity plus the I/O change averaged 179.4 ops/s versus the baseline’s 167.9 ops/s (+6.9%, P99 −1.5%, pairwise range −19.2% to +35.9%). However, when the same I/O change was applied to both baseline and candidate in two exploratory pairs, baseline-plus-I/O reached 212.5 ops/s versus 185.2 ops/s for affinity-plus-I/O (−12.8%, pairwise +37.7% and −49.1%). Because changing small-metadata sync paths affects the entire storage engine and did not isolate the affinity regression, the overlay was discarded.
What it buys, and what it costs
In fresh two-pool tests, 256 ordinary 4 KiB keys ended with 512 → 256 pool-local records and active logical size of 6.18 → 3.09 MiB. The 1 MiB workload ended with 128 → 64 records and 171.56 → 85.78 MiB.
At the cluster level, if a fraction $f$ of logical data would otherwise accumulate two copies across two pools, avoiding the second copy reduces steady-state space by $f / (1 + f)$ (for example, ~9.1% at $f = 10%$). Per-request write volume is unchanged: ordinary 4 KiB overwrites generated ~128 KiB of block writes per operation in both variants.
| Aspect | Benefit | Cost or limit |
|---|---|---|
| Capacity | Prevents new stale copies during eligible unversioned overwrites | Does not reclaim existing copies; fallback can still leave copies across pools |
| Write performance | A healthy preferred pool skips a full capacity-selection pass | Placement alters I/O contention; small-object overwrite regressions remained (−5.7% fresh, −24.5% dual-copy fixture) |
| Expansion and hot keys | Existing objects keep a stable pool location | Older pools retain growing/hot keys after expansion instead of shedding overwrites to newer pools |
| API and format compatibility | No wire, configuration, dependency, or disk-format change | Pool distribution changes operational balance under the same S3 interface |
| Availability and correctness | Request-aware quorum and fault checks avoid unsafe pool preference | Preflight state can change before write completion |
| Maintenance | Narrow scope avoids versioned xl.meta amplification |
Parity, bucket state, internal writes, reserve rules, PUT, and multipart routing must stay synchronized |
| Rollback | Returning to baseline requires no format conversion | Stale copies created before or during rollout remain on disk |
Final decision and conditions for revisiting it
- Full affinity (
codex/healthy-pool-affinity): rejected because concentrating version histories in one pool doubledxl.metarewrite I/O and cut 4,000-version throughput by 30.8% without saving space. - Narrowed unversioned affinity (
codex/unversioned-pool-affinity): rejected because ordinary 4 KiB overwrites still regressed by 5.7% on fresh data (median paired change −10.3%) and by 24.5% (P50 +111.9%) on restored dual-copy fixtures.
We therefore kept the mainline allocator unchanged and archived the benchmark records (54 runs / 243,718 operations in round one; 53 runs / 495,745 operations in round two). Any future revisit should start from production duplicate-space measurements and multi-disk hardware benchmarks, and evaluate whether background stale-copy cleanup or an opt-in placement policy better addresses the workload.
Source and evidence identification
Public references: PR #245, upstream PR #21115, Multi-Pool Object Consistency, and the baseline storage implementation in cmd/erasure-server-pool.go, cmd/xl-storage-format-v2.go, and cmd/xl-storage.go.
| Artifact | Exact identity |
|---|---|
| Initial independently reviewed base / PR head | 856f8f4c5580d1c5adacbd8ef2aedf3a4fbc507c / 3a9cdc731528e31bc4f9e61256025a1baae863e8 |
| PR head observed when documenting, after rebase | 0a6cad318409451b52dfcef18efb476c3927b6f8 |
| Baseline for both m3 rounds | 789a2327ba8c2283cda7192eb65f8374a7f919ba |
| Full local branch | codex/healthy-pool-affinity at 0aa870115d7d0904791104b25ad66f3430579de4 |
| Narrowed local branch | codex/unversioned-pool-affinity at 76db3626eb9068b29e4565e78ad22f7720012e65 |
| Narrowed benchmark’s production semantics | bb4b987825985481e5047f94c60f560be85b9c19 |
Round-two results/analysis.json SHA-256 |
68eec465749b625c691abcace90fc71f103b5826ccab4c40a120f39e29ded368 |
| Complete candidate patch SHA-256 | af833ec4b7bbbc22fca8539a6da40f4fe5fa93d61febabbd5b08a63ae1ecbe1c |