Skip to content

fix: make service compaction deterministic and idempotent - #447

Merged
tinswzy merged 2 commits into
masterfrom
fix/deterministic-compaction-446
Oct 22, 2026
Merged

tinswzy merged 2 commits into
masterfrom
fix/deterministic-compaction-446

Conversation

@tinswzy

@tinswzy tinswzy commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Service replicas can have identical confirmed records but different local flush boundaries. Grouping whole source blocks can then produce different m_<id>.blk layouts, so concurrent compactions can overwrite objects referenced by another attempt's published footer.

This change scans and validates the confirmed DataRecord stream and reblocks it deterministically. It ignores source boundaries, counts encoded DataRecord bytes against the configured target, keeps exact fits together, and isolates oversized records in a single block. Output IDs, headers, indexes, and footer bytes are independent of upload scheduling. Uploads remain bounded and parallel; every upload is drained on failure/cancellation, and the footer is published only after all uploads succeed. Memory admission now accounts for the source buffer, bounded uploads, and the output builder.

Repeated compaction of an already-Sealed segment succeeds. A metadata revision conflict is treated as idempotent only after a refresh confirms that another attempt sealed the same LAC; different ranges, truncation, deletion, refresh failures, and unrelated errors remain visible.

Fixes #446.

Compatibility and rollout

  • Existing object names, footer encoding, readers, and supported RPCs are unchanged. Existing published compactions are reused; historical segments do not need migration.
  • Output equivalence assumes identical confirmed records, cluster configuration, and compaction algorithm. Drain old service compaction tasks before enabling the new algorithm; do not overlap old and new algorithms on the same segment during rollout.
  • The service compaction target counts encoded DataRecord bytes; fixed output headers are additional. A single oversized record is retained intact.

Regression coverage

  • Compare all objects byte for byte for all 32 flush partitions of six records, plus layouts covering exact-size blocks, oversized records, and a cropped unconfirmed tail. The partition regression fails against the original planner (verified with a Go source overlay).
  • Start two real compactions against shared storage with different local block layouts. Publish one complete result, then control every overwrite by the other attempt. Inject a failure before each of five block PUTs and the footer PUT, and cover successful publication by both attempts.
  • At every overwrite checkpoint, verify complete ordered reads and confirmed-LAC EOF through cached and fresh readers. Hold a real block GET across the entire overwrite phase, then verify that the in-flight read also returns the same complete records.
  • Verify input gaps, overlaps, CRC corruption, truncated local files, empty segments, retries, upload cancellation, memory bounds, and metadata-conflict handling. The controlled store rejects conditional writes.
  • Add TestStagedStorageService_ConcurrentAuditorsCompaction to the existing E2E Service failover selection. Two real writer/auditor instances survive takeover and automatically compact the same segment through real gRPC services, etcd and MinIO. Stopped replicas are reblocked into 1, 8 and 24 local blocks and restarted. Cover a real same-LAC etcd CAS conflict and a full auditor pass failing after successful server execution because all three RPC responses are lost. Verify both auditors keep cycling, SDK reads remain complete, and the new writer can append/read the next segment. No manual SegmentHandle.Compact calls drive this E2E.

Validation

  • go test -race ./tests/integration -run '^TestStagedStorageService_ConcurrentAuditorsCompaction$' -count=3 -timeout=7m -v

  • go test -race ./server/storage/stagedstorage ./server/storage/objectstorage ./woodpecker/segment ./server/processor -count=1 -timeout=5m

  • Selected integration tests against external MinIO and etcd, with -race: TestCompactionMemoryEstimate_HoldsAgainstARealCompaction, TestStagedFileWriter_CompactOperation, TestStagedFileReader_ReadLocalAndCompacted, and TestDataCorruptionService_Active_TwoReplicasTruncated.

  • The real compaction memory test measured a 16,797,992-byte reservation and 22,991,512-byte sampled heap growth (1.37x, within its 4x regression bound).

  • golangci-lint run --config .golangci.yml --timeout=5m ./server/storage/stagedstorage/... ./server/storage/... ./woodpecker/segment/...

  • git diff --check

@github-actions

Copy link
Copy Markdown

CI commands

Comment any of these to rerun checks on this pull request:

Command Reruns
/rerun every failed workflow on the latest commit
/rerun-lint Lint
/rerun-mod-tidy Go Mod Tidy Check
/rerun-unit-test Unit Test
/rerun-e2e-local E2E Local
/rerun-e2e-objectstorage E2E Object Storage
/rerun-e2e-service E2E Service
/rerun-component-test Component Integration Test
/rerun-chaos Chaos Test
/rerun-service-stability-process Service Stability (Process)
/rerun-service-stability-docker Service Stability (Docker)
/rerun-operator Operator Test
/rerun-upgrade-local Upgrade Compat Local
/rerun-upgrade-objectstorage Upgrade Compat Object Storage
/rerun-upgrade-service Upgrade Compat Service

The ci-passed label is applied automatically once every required check passes.

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.04110% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.15%. Comparing base (f22b676) to head (d3f5a3f).

Files with missing lines Patch % Lines
server/storage/stagedstorage/compaction.go 88.05% 9 Missing and 7 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #447      +/-   ##
==========================================
+ Coverage   84.10%   84.15%   +0.05%     
==========================================
  Files         221      222       +1     
  Lines       31311    31245      -66     
==========================================
- Hits        26333    26294      -39     
+ Misses       3736     3718      -18     
+ Partials     1242     1233       -9     
Components Coverage Δ
Server 83.69% <88.14%> (+0.04%) ⬆️
Client 91.04% <100.00%> (+0.20%) ⬆️
Meta 85.58% <ø> (ø)
Common 88.26% <ø> (-0.04%) ⬇️
Files with missing lines Coverage Δ
server/storage/compaction_memory.go 100.00% <ø> (ø)
server/storage/stagedstorage/writer_impl.go 79.54% <100.00%> (-1.06%) ⬇️
woodpecker/segment/segment_handle.go 93.41% <100.00%> (+0.94%) ⬆️
server/storage/stagedstorage/compaction.go 88.05% <88.05%> (ø)

... and 7 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@czs007

czs007 commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

Must-fix issues introduced by this PR: 0
Merge recommendation: Mergeable as-is — this review found no must-fix issue introduced by this PR.

Adversarial review found no issues requiring changes.

Verified:

  • compaction.go:204-214: Blocks are split only on the encoded DataRecord byte count against targetBytes, never on source block boundaries or upload timing. Replicas and flush partitions therefore produce byte-identical objects. Exactly-full blocks stay intact, and an oversized record gets a block of its own.
  • compaction.go:113-117: The reused source buffer cannot corrupt output, because append(output, record...) copies each record before the next ReadAt. output is also reset to nil after each flush (compaction.go:192), so an upload already in progress never sees its data modified.
  • compaction.go:222-235: A scan error cancels the upload context and waits for every upload to finish. A genuine scan error takes priority over an upload error. The footer is written only after all block uploads succeed.
  • compaction.go:101-136: Input must start at entry 0 with no gaps or overlaps. extractCompactedDataRecords checks CRC, length and record type and trims an unconfirmed tail. The final check next == expected+1 makes sure the full confirmed range was covered.
  • segment_handle.go:2254-2257: A segment that is already Sealed returns nil before the CAS runs. No non-test caller depends on ErrSegmentHandleSegmentStateInvalid being returned in that state.
  • segment_handle.go:2352-2366: A CAS conflict counts as success only when the refresh succeeds and the segment is Sealed with the same LAC. Truncated, deleted, different-LAC and refresh-failure cases still return an error. The metric now records an error on CAS failure instead of a success.

@tinswzy
tinswzy merged commit 27e7032 into master Oct 22, 2026
19 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

enhance: Make compaction output deterministic and idempotent across replicas

2 participants