test(longhaul): add retention pruner to bound workload collection disk usage#422
Conversation
…k usage An unbounded long-haul write test always eventually fills a fixed PVC (the westus2 cluster exhausted its 10Gi volume after ~24.9M writes, crash-looping Postgres). Add a count-based retention pruner that keeps only the most recent LONGHAUL_RETAIN_PER_WRITER documents per writer (default 2,000,000; 0 disables). The pruner deletes strictly below the verifier's confirmed floor, so it can never remove a document the durability oracle has not already accounted for. The verifier now publishes a per-writer confirmed floor via atomics and, on startup, seeds each writer's expected seq from the collection's current minimum seq so a post-restart scan does not misread the pruned prefix as a giant gap (false data loss). - config: LONGHAUL_RETAIN_PER_WRITER env + validation - workload: Pruner (index-backed range DeleteMany), Verifier.ConfirmedFloor + DB-min startup seed, DocsPruned metric - main: wire pruner (gated on RetainPerWriter > 0) - report/deploy: surface Docs Pruned; default retention in deployment ConfigMap - tests: pruner (incl. safety invariant), verifier floor, config load/validate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: badfdbf1-0fe9-43da-9921-854304654217 Signed-off-by: Wenting Wu <wentingwu@microsoft.com>
|
🤖 Auto-triaged by documentdb-triage-tool. Applied: Reasoningcomponent from path globs (test); effort from diff stats (457+1 LOC, 10 files); LLM: Adds a retention pruner to the long-haul test workload to prevent unbounded disk growth that previously caused a real cluster outage (CrashLoopBackOff), touching multiple files across the test workload package including pruner logic, verifier restart safety, wiring, metrics, and unit tests. If a label is wrong, remove it manually and ping |
The pruner keeps only the most recent documents per writer, which means a corrupt (checksum-mismatch) document would eventually be deleted ~55h after detection, destroying the evidence needed to debug a real durability failure. Freeze pruning permanently the first time the verifier reports any data loss (gap or checksum mismatch). The run is already FAIL at that point, so bounding disk no longer matters; instead the collection is left intact so the corrupt document and its neighbours stay on disk for post-mortem. HasDataLoss() is monotonic within a run, so once frozen the pruner stays frozen, and the "pruning frozen" warning is journaled exactly once. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: badfdbf1-0fe9-43da-9921-854304654217 Signed-off-by: Wenting Wu <wentingwu@microsoft.com>
Docs pruned is operational, not part of the durability verdict, and is already redundant with the pruner's per-prune and freeze journal events that appear in the report's event log. Remove the report row to keep the report focused on pass/fail. The DocsPruned counter (incremented by the pruner and journaled) stays as the source of truth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: badfdbf1-0fe9-43da-9921-854304654217 Signed-off-by: Wenting Wu <wentingwu@microsoft.com>
With the Docs Pruned report row gone, MetricsSnapshot.DocsPruned had no reader. Remove the field and its copy in Snapshot(). The Metrics.DocsPruned atomic counter stays — the pruner increments it and journals each prune. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: badfdbf1-0fe9-43da-9921-854304654217 Signed-off-by: Wenting Wu <wentingwu@microsoft.com>
The Metrics.DocsPruned atomic counter had no production reader after the report row and snapshot field were removed; the pruner already journals each prune (and freeze) event, which is the real observable signal. Remove the counter and rewire pruner tests to assert on delete behavior (backend calls) instead of the counter. Pruner.metrics is retained for the freeze-on-failure check (Snapshot().HasDataLoss()). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: badfdbf1-0fe9-43da-9921-854304654217 Signed-off-by: Wenting Wu <wentingwu@microsoft.com>
Add the retention-pruner knob to the env-var reference table so the new config option is discoverable alongside the others. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: badfdbf1-0fe9-43da-9921-854304654217 Signed-off-by: Wenting Wu <wentingwu@microsoft.com>
main.go and pruner.go both logged the retain count on startup. Keep the detailed line in the pruner's own Run (paired with its "pruner stopped"), and make the main-scoped line bare to match the sibling verifier log. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: badfdbf1-0fe9-43da-9921-854304654217 Signed-off-by: Wenting Wu <wentingwu@microsoft.com>
There was a problem hiding this comment.
Pull request overview
This PR adds count-based retention to the long-haul test workload so the write driver no longer grows the workload collection (and its backing PVC) without bound during infinite-duration runs.
Changes:
- Add a retention pruner that periodically deletes already-verified history beyond a per-writer retention window.
- Make the verifier restart-safe under pruning by seeding expected sequence from the collection’s current minimum seq and publishing a per-writer “confirmed floor” for safe pruning.
- Wire a new
LONGHAUL_RETAIN_PER_WRITERconfiguration knob through docs, deployment defaults, env parsing, validation, and main startup logic.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| test/longhaul/workload/verifier.go | Publishes a per-writer verified floor (atomics) and seeds expected seq from DB-min on startup to avoid false gaps after pruning + restarts. |
| test/longhaul/workload/verifier_test.go | Adds unit tests for ConfirmedFloor behavior. |
| test/longhaul/workload/pruner.go | Introduces the periodic per-writer retention pruner (range DeleteMany based on verifier floor). |
| test/longhaul/workload/pruner_test.go | Adds unit tests for pruning boundaries and the “never delete at/above the floor” invariant. |
| test/longhaul/README.md | Documents LONGHAUL_RETAIN_PER_WRITER and its default/disable behavior. |
| test/longhaul/deploy/deployment.yaml | Sets a default retention window in the long-haul deployment ConfigMap. |
| test/longhaul/config/config.go | Adds retention env var constant, default, config field, env loading, and validation. |
| test/longhaul/config/config_test.go | Adds tests for retention env parsing and validation. |
| test/longhaul/cmd/longhaul/main.go | Starts the pruner when configured and passes the verifier as the floor provider. |
|
|
||
| Describe("ConfirmedFloor", func() { | ||
| It("returns 0 for a writer registered but not yet verified", func() { | ||
| v := &Verifier{floor: map[string]*atomic.Int64{"w000": {}}} |
There was a problem hiding this comment.
Not a compile error — this is valid Go. Inside a composite literal whose element type is a pointer (*atomic.Int64), the &T is elided, so {} constructs &atomic.Int64{} (Go spec: Composite literals). go vet ./workload/... passes and the test binary compiles/runs green. Leaving as-is.
| v := &Verifier{floor: map[string]*atomic.Int64{ | ||
| "w000": {}, "w001": {}, | ||
| }} |
There was a problem hiding this comment.
Same as above — {} in a map[string]*atomic.Int64 literal elides &atomic.Int64{} and compiles cleanly (per the Go spec on composite literals). Verified with go vet + go test ./workload/.... No change needed.
| deleted, err := p.backend.deleteThrough(ctx, writerID, throughSeq) | ||
| if err != nil { | ||
| p.journal.Warn("pruner", fmt.Sprintf("prune failed for writer %s: %v", writerID, err)) | ||
| return | ||
| } |
There was a problem hiding this comment.
Good catch on the discrepancy — but the resolution is to fix the description, not the code. DocsPruned was intentionally removed during review (report row → snapshot field → counter) because it had no production reader once the report row was dropped; the pruner already journals every prune and any freeze-on-failure, which is the real observable. Updated the PR description to drop the stale DocsPruned claim.
xgerman
left a comment
There was a problem hiding this comment.
Summary
Reviewed against the actual verifier/pruner semantics (built, vetted, all package tests pass). The core design is genuinely good — floor-based deletion + freeze-on-data-loss is a safe, well-reasoned way to bound disk without ever touching the durability verdict:
Verified correct:
- Pruner only ever deletes
seq <= confirmedFloor - retainPerWriter, i.e. strictly below what the verifier has already accounted for; the delete rides the existing(writer_id, seq)unique index (bounded range delete, not a scan). ✅ - Any gap/checksum failure freezes pruning permanently to preserve evidence; freeze is checked before pruning and
HasDataLoss()is monotonic, and the verifier increments the loss counters before it publishes the advanced floor — so the pruner can never observe a floor past a gap without also seeing the freeze. ✅ - Concurrency is sound:
flooris a read-onlymap[string]*atomic.Int64built once at construction; only the atomics are Load/Store-d across goroutines. ✅ - The streaming
auditorrefactor is O(1) per cycle and now surfacescursor.Err()— a latent truncated-read → false-tail-loss bug that the old slice path silently swallowed. ✅ - Config gating (
RetainPerWriter==0disables), default (2M docs/writer), and validation all look right. Pruner tests are thorough — boundaries, the never-delete-at-or-above-floor safety invariant, and all three freeze cases.
One blocking bug in the new startup seed, plus a test gap and a nit.
| // are still detected. Done once per writer; steady-state cycles never | ||
| // re-read below nextSeq. | ||
| if !v.seeded[writerID] { | ||
| v.seeded[writerID] = true |
There was a problem hiding this comment.
🟠 Major — the one-time seed is marked done before it succeeds, so a transient error produces a false data-loss FAIL on a pruned+restarted collection.
v.seeded[writerID] = true is set unconditionally, before the minSeq query. If that query errors transiently at startup (network blip, majority-read timeout), the warning is logged but seeded stays true, so the seed is never retried. The very next scan then runs from expectedSeq = 1 against a collection whose pruned prefix no longer exists → the surviving minSeq document registers as one enormous internal gap → HasDataLoss() flips to FAIL for the rest of the run, and (ironically) that freezes the pruner.
This is exactly the restart-after-pruning path this PR introduces — before pruning, a scan-from-1 after restart was always correct, so the seed has to be robust to be safe.
Fix: only mark seeded on success, so a transient error retries next cycle (minSeq returning (0, nil) for an empty collection counts as success):
if !v.seeded[writerID] {
if minSeq, err := v.minSeq(ctx, writerID); err != nil {
v.journal.Warn("verifier", fmt.Sprintf("min-seq seed failed for writer %s (will retry): %v", writerID, err))
} else {
v.seeded[writerID] = true
if minSeq > expectedSeq {
expectedSeq = minSeq
}
}
}| Skip("verifyAll requires a *mongo.Database; covered by long-haul integration runs") | ||
| }) | ||
|
|
||
| Describe("ConfirmedFloor", func() { |
There was a problem hiding this comment.
🟡 Minor — the startup min-seq seed has no test, which is precisely where the Major bug above lives. The suite covers ConfirmedFloor/publishFloor and the auditor, but not the restart seed. A test that pre-populates the collection starting at seq N, constructs a fresh verifier (empty nextSeq), runs one cycle, and asserts no gap is counted would exercise the happy path — plus an error-injection variant asserting the seed is retried (not permanently marked done) would lock in the fix.
What
Adds a count-based retention pruner to the long-haul test driver so the endurance write load no longer grows the DocumentDB collection (and its PVC) without bound.
Why
An infinite-duration write test on a fixed disk always eventually fills it. The westus2 long-haul cluster exhausted its 10Gi volume after ~24.9M writes, which put Postgres into a
CrashLoopBackOff("Not enough disk space") and tripped the scheduled Long Haul Monitor. This bounds steady-state disk use so it can't recur.How
test/longhaul/workload/pruner.go): every 5m, per writer, deletes documents older than the most-recentLONGHAUL_RETAIN_PER_WRITER(default 2,000,000;0disables). Uses an index-backed rangeDeleteManyon the existing unique(writer_id, seq)index.main.gostarts the pruner only whenRetainPerWriter > 0; each prune (and any freeze-on-failure) is recorded in the run journal; default set in the deployment ConfigMap.Testing
go build ./...,go vet ./...,gofmt -lcleango test ./...(all packages) +go test -race ./workload/...passConfirmedFloor, config load/validate for the new env varNotes
The already-running westus2 cluster was recovered separately by expanding its PVC to 64Gi; this PR prevents the refill from happening again. Opening as draft for review.