Add low-yield compaction backoff to stop wasteful copy-forward on all-live data - #2083
Conversation
…-live data Lookup/Scan compaction on all-live, larger-than-memory data copies nearly every live record forward to reclaim only segment-alignment slack, then repeats every cycle because the legacy "netReclaimed > 0" test misreads that alignment noise as progress. On a write-once dataset this pins the device at a high write duty even at idle, starving reads of NVMe bandwidth. This adds a yield-ratio gate: a cycle counts as productive only if it reclaims at least CompactionLowYieldReclaimPercent (default 20) of the begin-address range it advanced. After a low-yield cycle, compaction is parked until the log tail grows by CompactionLowYieldBackoffSegments segments (i.e. real new garbage arrives), then automatically resumes. Foreground writes re-arm it. - CompactionPolicy.cs: new CompactionState (skip/resume/RecordCycle) + helpers - DatabaseManagerBase.cs: gate the compaction loop + emit per-cycle telemetry (begin advance, tail growth, net reclaimed, ratio, duration) - GarnetServerOptions.cs: CompactionLowYieldBackoffSegments, CompactionLowYieldReclaimPercent - Options.cs + defaults.conf: expose --compaction-low-yield-backoff-segments (default 0 = disabled/opt-in) - GarnetDatabase.cs: per-database CompactionState - CompactionPolicyTests.cs: unit tests for the gate Co-authored-by: GitHub Copilot <223556219+Copilot@users.noreply.github.com>
|
@microsoft-github-policy-service agree company="Microsoft" |
There was a problem hiding this comment.
Pull request overview
Adds opt-in low-yield compaction backoff to reduce wasteful copy-forward writes on mostly live datasets.
Changes:
- Tracks compaction yield and pauses low-yield cycles until tail growth.
- Adds server and host configuration for the backoff.
- Adds policy unit tests and compaction telemetry.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
libs/server/Databases/CompactionPolicy.cs |
Implements yield and retry policy. |
libs/server/Databases/DatabaseManagerBase.cs |
Integrates backoff and telemetry. |
libs/server/GarnetDatabase.cs |
Stores per-database compaction state. |
libs/server/Servers/GarnetServerOptions.cs |
Adds policy settings. |
libs/host/Configuration/Options.cs |
Exposes the backoff option. |
libs/host/defaults.conf |
Disables host backoff by default. |
test/standalone/Garnet.test/CompactionPolicyTests.cs |
Tests policy calculations and state. |
Suppressed comments (2)
libs/server/Databases/DatabaseManagerBase.cs:498
- This tail delta includes concurrent foreground appends as well as records copied by compaction. A garbage-rich cycle can therefore appear low-yield whenever clients append enough data while the scan runs, causing compaction to park even though it reclaimed substantial space. Track compaction-generated tail bytes separately (or otherwise exclude foreground growth) before using this value for the yield decision.
var beginAddressAdvance = beginAddressAfter - beginAddressBefore;
var tailAddressGrowth = tailAddressAfter - tailAddressBefore;
var netReclaimedBytes = beginAddressAdvance - tailAddressGrowth;
libs/server/Databases/DatabaseManagerBase.cs:514
- The warning is inaccurate:
RecordCyclealso parks cycles that reclaim a positive amount below the configured percentage. Report that the yield was below threshold rather than that no space was reclaimed, otherwise operators may misdiagnose the telemetry.
"Compaction reclaimed no space; pausing until tail reaches {retryTailAddress}; DB ID = {id}",
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| var untilAddress = CompactionPolicy.GetUntilAddress(beginAddressBefore, readOnlyAddress, segmentSize, | ||
| mainStoreMaxSegments, numSegmentsToCompact, lowYieldBackoffEnabled); |
There was a problem hiding this comment.
Addressed in bac84f0. The single bounded pass is now a drain-fully loop: while the log is over the limit, compaction keeps compacting bounded chunks. Productive chunks continue draining so a high-churn workload that generates more than one chunk of garbage per interval can no longer outrun compaction, while the first low-yield chunk parks compaction until the tail grows again (the all-live copy-forward case this PR targets). The non-backoff path keeps the original single unbounded pass, and there is a safety break if a chunk classified as productive fails to advance the begin address. Added ProductiveChunksDrainThenLowYieldParks covering the state sequence.
| namespace Garnet.test | ||
| { | ||
| [TestFixture] | ||
| internal class CompactionPolicyTests |
| [IntRangeValidation(0, int.MaxValue)] | ||
| [Option("compaction-low-yield-backoff-segments", Required = false, HelpText = "Number of log segments the tail must grow before retrying after a compaction cycle that reclaimed no space. 0 = disabled.")] | ||
| public int CompactionLowYieldBackoffSegments { get; set; } |
| /// Number of log segments the tail must grow before retrying after a compaction cycle that | ||
| /// reclaimed no space. 0 disables low-yield backoff. |
| public int CompactionMaxSegments { get; set; } | ||
|
|
||
| [IntRangeValidation(0, int.MaxValue)] | ||
| [Option("compaction-low-yield-backoff-segments", Required = false, HelpText = "Number of log segments the tail must grow before retrying after a compaction cycle that reclaimed no space. 0 = disabled.")] |
| /* Number of log segments created on disk before compaction triggers. */ | ||
| "CompactionMaxSegments" : 32, | ||
|
|
||
| /* Number of log segments the tail must grow before retrying after a compaction cycle that reclaimed no space. 0 = disabled. */ |
- Docs/help/log now describe the percentage-based low-yield gate instead of "reclaimed no space" (defaults.conf, Options.cs, GarnetServerOptions.cs, and the backoff LogWarning in DatabaseManagerBase.cs), since cycles with a small positive reclaim below CompactionLowYieldReclaimPercent also park. - CompactionPolicyTests now inherits TestBase so its cases are included in the repository's running-test diagnostics, matching the other fixtures. - Add CompactionLowYieldBackoffSegmentsParsing to GarnetServerConfigTests: covers the default (0), a positive CLI value, a positive JSON value, and rejection of a negative value, verifying each reaches GarnetServerOptions. Co-authored-by: GitHub Copilot <223556219+Copilot@users.noreply.github.com>
…kloads cannot outrun compaction When low-yield backoff is enabled, compaction previously ran a single bounded chunk per interval. A workload producing more than one chunk of garbage per interval could grow the log unbounded because compaction only ever reclaimed one chunk at a time. Convert the single-pass compaction block into a drain-fully loop: keep compacting bounded chunks while the log is over the configured limit. Productive chunks continue draining so high-churn workloads stay bounded; the first low-yield chunk parks compaction until the tail grows again (the all-live copy-forward case). The non-backoff path preserves the original single unbounded pass. Added a safety break for a productive chunk that fails to advance the begin address. Added ProductiveChunksDrainThenLowYieldParks documenting the drain-then-park state sequence the loop relies on. Co-authored-by: GitHub Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Lookup/Scan log compaction wastes NVMe write bandwidth on all-live,
write-once, larger-than-memory (LTM) datasets. On such data a compaction
cycle copies almost every live record forward to reclaim only a few hundred
bytes of segment-alignment slack, and then repeats on the next cycle — because
the legacy productivity test (
netReclaimed > 0) misreads that alignment noiseas forward progress. The result is a compaction loop that pins the storage
device at a high write duty even when the server is otherwise idle,
starving foreground reads of device bandwidth.
This PR adds a low-yield compaction backoff: a cycle is treated as
productive only if it reclaims a meaningful fraction of the range it scanned.
When a cycle reclaims essentially nothing, compaction is parked until the log
tail grows (i.e. genuinely new garbage arrives), then resumes automatically.
Problem detail
LogCompactionType.Lookup(andScan) advance the log begin address bycopying still-live records forward to the tail. On a dataset that is entirely
live (write-once, no overwrites/deletes), the copy-forward re-writes nearly
every byte it truncates, so:
netReclaimed sits at a few hundred bytes (segment-alignment rounding) even
after moving tens of GB. The old gate only checked
netReclaimed > 0, so itclassified the cycle as progress and immediately compacted again on the next
trigger — forever.
Observed on a 100M × 1KB O_DIRECT dataset (working set ~94% on disk): stock
Lookup compaction sustained ~470 MB/s of copy-forward writes (~73% device
write-duty) at idle, purely to reclaim alignment slack.
Fix
A yield-ratio gate in a new
CompactionState:netReclaimed = beginAddressAdvance - tailAddressGrowth.netReclaimed >= ceil(beginAddressAdvance * CompactionLowYieldReclaimPercent / 100)(default 20%) for the cycle to countas productive.
CompactionLowYieldBackoffSegmentssegments, then resume. Foreground writegrowth re-arms it, so real garbage is always eventually collected.
The gate is correctness-preserving: it only defers compaction when there
is nothing worth reclaiming; any genuinely reclaimable garbage re-triggers it.
Configuration
--compaction-low-yield-backoff-segments(CompactionLowYieldBackoffSegments)0(disabled)0disables the feature.CompactionLowYieldReclaimPercent20The feature is opt-in via config (
defaults.confshipsCompactionLowYieldBackoffSegments = 0). The in-codeGarnetServerOptionsfield defaults to
32so that embedders which constructGarnetServerOptionsdirectly (without binding from
defaults.conf) still get the protection; thestandard host + config path leaves it disabled unless the operator opts in.
Evidence
Single-cell A/B/C on a 100M × 1KB O_DIRECT dataset, 16 GB log memory (~94% of
the working set on disk), client on a separate VM:
LookupCompactionType NoneLookup+ backoff)The fix matches "compaction off": +31% throughput and ~9× better p99 versus
stock, by eliminating the wasteful copy-forward writes that were competing with
reads for device bandwidth. The win is largest when the working set is
device-bound (LTM); RAM-resident workloads see little client-visible change
because reads don't touch the device.
Changes
libs/server/Databases/CompactionPolicy.cs(new) —CompactionState(
ShouldSkip/TryResume/RecordCycle) andCompactionPolicyhelpers(
GetBackoffBytes,GetUntilAddress).libs/server/Databases/DatabaseManagerBase.cs— gate the main-storecompaction loop on
CompactionState; emit per-cycle telemetry (beginadvance, tail growth, net reclaimed, ratio, duration).
libs/server/Servers/GarnetServerOptions.cs— newCompactionLowYieldBackoffSegmentsandCompactionLowYieldReclaimPercent.libs/host/Configuration/Options.cs+libs/host/defaults.conf— expose--compaction-low-yield-backoff-segments(default0).libs/server/GarnetDatabase.cs— per-databaseCompactionState.test/standalone/Garnet.test/CompactionPolicyTests.cs(new) — unit testsfor the yield-ratio gate (7 cases, incl. tiny-positive-reclaim backs off).
Testing
dotnet testonCompactionPolicyTests— 7/7 pass, includingTinyPositiveReclaimBacksOff(verifies a cycle that reclaims a few bytes afteradvancing the begin address is treated as unproductive and parks).
Notes for reviewers
Version.propsis intentionally not bumped here.CompactionLowYieldReclaimPercentis currently a code-level default (20) andnot surfaced as a CLI/config option; happy to expose it if reviewers prefer.