SegmentManager.AppendBatch was passing sm.active.CurrentOffset() (byte
offset from file header) as the new segment's startSequence on rotation.
The result: segment-N+1's header.startSequence was a byte count (e.g.
50000), not the actual sequence number. Recovery's continuity check at
recovery.go:181-184 (segment.StartSequence != expectedSequence) failed,
making Phase 1 multi-segment recovery completely broken.
Oracle bg_ef425776 noted: "C8 是隐藏炸弹:单 segment 时一切正常,
第一次轮转后就坏".
Changes:
- wal/segment_manager.go: AppendBatch now takes batchStartSequence uint64
parameter. On rotation, passes it to rotate (which writes it to the new
segment's header.startSequence). The previous byte-offset argument is
replaced by the actual sequence number.
- wal/writer.go: processBatch passes baseSequence (already allocated by
seqManager.AllocateBatch) to AppendBatch.
- wal/segment_manager_test.go: 6 existing AppendBatch call sites updated
to pass batchStartSequence (tracked via local currentSeq variable).
Added 2 new tests:
- TestSegmentManagerRotationWritesCorrectStartSequence: verifies new
segment's header.startSequence matches the first rotated batch's
sequence (and explicitly != old byte offset, catching C8 regression).
- TestSegmentManagerMultiSegmentRecoveryRoundTrip: end-to-end test that
writes across multiple segments, closes, recovers, and verifies all
batches replay. Before C8 fix, recovery failed at continuity check.
Verified: each new test fails on pre-fix code (segment-1 startSequence
is byte offset, recovery fails) and passes after the fix. Full suite
green including go test -race ./... .
Audit context: docs/audit-3.2.md C8 (Oracle-discovered bg_2e86d33b).
114 lines
3.5 KiB
Go
114 lines
3.5 KiB
Go
package wal
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/dailz/go-kv/config"
|
|
"github.com/dailz/go-kv/manifest"
|
|
)
|
|
|
|
// SegmentManager manages the lifecycle of WAL segment files, handling segment
|
|
// rotation when the active segment runs out of payload capacity. A batch is
|
|
// never split across segments — if it doesn't fit, a new segment is created
|
|
// first and the entire batch is written there.
|
|
type SegmentManager struct {
|
|
dir string
|
|
cfg *config.WalConfig
|
|
active *SegmentWriter // currently active segment writer
|
|
nextSegID uint64 // next segment ID to allocate
|
|
}
|
|
|
|
// NewSegmentManager creates a new SegmentManager and its first segment file.
|
|
// It creates the directory if needed, writes the initial segment, and updates
|
|
// the CURRENT file to point to it.
|
|
func NewSegmentManager(
|
|
dir string,
|
|
startSegmentID uint64,
|
|
startSequence uint64,
|
|
cfg *config.WalConfig,
|
|
) (*SegmentManager, error) {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("wal: create segment directory %s: %w", dir, err)
|
|
}
|
|
|
|
sw, err := NewSegmentWriter(dir, startSegmentID, startSequence, cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("wal: create initial segment: %w", err)
|
|
}
|
|
|
|
sm := &SegmentManager{
|
|
dir: dir,
|
|
cfg: cfg,
|
|
active: sw,
|
|
nextSegID: startSegmentID + 1,
|
|
}
|
|
|
|
// Best-effort CURRENT file update.
|
|
_ = manifest.WriteCurrent(dir, sw.SegmentID())
|
|
|
|
return sm, nil
|
|
}
|
|
|
|
// AppendBatch writes encodedBatch to the active segment, rotating first if
|
|
// the batch doesn't fit. batchStartSequence is the sequence number of the
|
|
// FIRST entry in this batch — used as the new segment's startSequence when
|
|
// rotation occurs, so multi-segment recovery's continuity check passes per
|
|
// design §3.2 line 639-663.
|
|
func (sm *SegmentManager) AppendBatch(encodedBatch []byte, batchStartSequence uint64) error {
|
|
worstCaseSize := uint64(len(encodedBatch)) + uint64(PhysicalRecordHeaderSize) + uint64(PhysicalRecordHeaderSize)
|
|
|
|
if sm.active.RemainingPayload() < worstCaseSize {
|
|
// C8 fix: new segment's first batch is THIS batch, so its
|
|
// startSequence must equal batchStartSequence (not byte offset).
|
|
if err := sm.rotate(batchStartSequence); err != nil {
|
|
return fmt.Errorf("wal: rotate segment: %w", err)
|
|
}
|
|
}
|
|
|
|
return sm.active.AppendBatch(encodedBatch)
|
|
}
|
|
|
|
// rotate closes the current segment and creates a new one. The CURRENT file is
|
|
// updated on a best-effort basis — a failure is logged but does not prevent
|
|
// the rotation from succeeding.
|
|
func (sm *SegmentManager) rotate(newStartSequence uint64) error {
|
|
if err := sm.active.Close(); err != nil {
|
|
return fmt.Errorf("wal: close segment %d: %w", sm.active.SegmentID(), err)
|
|
}
|
|
|
|
sw, err := NewSegmentWriter(sm.dir, sm.nextSegID, newStartSequence, sm.cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("wal: create segment %d: %w", sm.nextSegID, err)
|
|
}
|
|
|
|
sm.nextSegID++
|
|
sm.active = sw
|
|
|
|
// Best-effort CURRENT file update — failure must not fail the write.
|
|
_ = manifest.WriteCurrent(sm.dir, sw.SegmentID())
|
|
|
|
return nil
|
|
}
|
|
|
|
// ActiveSegmentID returns the segment ID of the currently active segment.
|
|
func (sm *SegmentManager) ActiveSegmentID() uint64 {
|
|
return sm.active.SegmentID()
|
|
}
|
|
|
|
// RemainingPayload returns the number of bytes that can still be written to
|
|
// the active segment.
|
|
func (sm *SegmentManager) RemainingPayload() uint64 {
|
|
return sm.active.RemainingPayload()
|
|
}
|
|
|
|
// Sync flushes the active segment to durable storage.
|
|
func (sm *SegmentManager) Sync() error {
|
|
return sm.active.Sync()
|
|
}
|
|
|
|
// Close flushes and closes the active segment.
|
|
func (sm *SegmentManager) Close() error {
|
|
return sm.active.Close()
|
|
}
|