feat(wal,memtable): implement segment writer, block writer, and MemTable

- wal/block_writer.go: 32KB block buffer with padding and flush
- wal/segment_writer.go: WAL segment file with durable-ready protocol
- memtable/memtable.go: Arena+SkipList wrapper with publish/abort semantics
- Comprehensive tests for all modules, all pass with -race
This commit is contained in:
dailz
2026-06-12 13:43:24 +08:00
parent 3c0e3b14ff
commit 349063968b
8 changed files with 1407 additions and 4 deletions
+147
View File
@@ -0,0 +1,147 @@
package wal
import (
"errors"
"fmt"
"io"
)
// BlockWriter manages a single 32 KB block buffer for writing physical records.
// It handles block boundary padding and flushing complete blocks to an io.Writer.
type BlockWriter struct {
buf [WalBlockSize]byte
offset uint32 // current write position within the block
}
// NewBlockWriter creates a BlockWriter ready to write into a fresh block.
func NewBlockWriter() *BlockWriter {
return &BlockWriter{}
}
// BlockOffset returns the current write offset within the block (0..WalBlockSize).
func (bw *BlockWriter) BlockOffset() uint32 {
return bw.offset
}
// WriteRecord writes a single physical record into the block buffer.
// If the record (header + payload) does not fit in the remaining space,
// the current block is padded with zeros and flushed to w, then the record
// is written at the start of a fresh block.
//
// Precondition: payload length must be ≤ WalBlockSize - PhysicalRecordHeaderSize
// (the caller is responsible for splitting large batches into appropriately-sized chunks).
func (bw *BlockWriter) WriteRecord(recType uint8, payload []byte, w io.Writer) error {
recordSize := PhysicalRecordHeaderSize + len(payload)
if recordSize > WalBlockSize {
return fmt.Errorf("wal: record size %d exceeds block size %d",
recordSize, WalBlockSize)
}
// Check if padding is needed before writing this record.
pad := bw.paddingNeeded()
if pad > 0 {
// Pad remaining bytes with zeros and flush.
if err := bw.flushPadded(w, pad); err != nil {
return fmt.Errorf("wal: flushing padded block: %w", err)
}
}
// Check if the record fits in the current block.
remaining := WalBlockSize - bw.offset
if uint32(recordSize) > remaining {
// Not enough room — pad the rest and flush, then start a new block.
pad = int(remaining)
if err := bw.flushPadded(w, pad); err != nil {
return fmt.Errorf("wal: flushing partial block: %w", err)
}
}
// Encode physical record directly into the block buffer.
encoded := EncodePhysicalRecord(recType, payload)
copy(bw.buf[bw.offset:], encoded)
bw.offset += uint32(len(encoded))
// If the block is exactly full, flush it immediately.
if bw.offset == WalBlockSize {
if _, err := w.Write(bw.buf[:]); err != nil {
return fmt.Errorf("wal: writing full block: %w", err)
}
bw.offset = 0
}
return nil
}
// Flush writes the current block buffer to w, padding unused bytes with zeros.
// If the block is empty (offset == 0), this is a no-op.
func (bw *BlockWriter) Flush(w io.Writer) error {
if bw.offset == 0 {
return nil
}
return bw.flushPadded(w, int(WalBlockSize-bw.offset))
}
// Reset clears the block buffer, returning it to an empty state.
func (bw *BlockWriter) Reset() {
bw.offset = 0
// Zero the buffer so partial blocks are padded with zeros.
for i := range bw.buf {
bw.buf[i] = 0
}
}
// paddingNeeded returns the number of zero-padding bytes required at the current
// block offset. When the remaining space in the block is ≤ PhysicalRecordHeaderSize (7),
// that space cannot hold even a minimal physical record and must be zero-padded.
func (bw *BlockWriter) paddingNeeded() int {
remaining := WalBlockSize - bw.offset
if remaining <= PhysicalRecordHeaderSize {
return int(remaining)
}
return 0
}
// flushPadded pads the remaining bytes with zeros and writes the full block to w.
// pad is the number of trailing bytes to zero-fill (WalBlockSize - offset - pad already zero
// from initial state or previous Reset).
func (bw *BlockWriter) flushPadded(w io.Writer, pad int) error {
if pad <= 0 {
return nil
}
// Zero-fill padding region. The buffer was zeroed at init/reset,
// but we write explicitly for safety after partial record writes.
for i := uint32(0); i < uint32(pad); i++ {
bw.buf[bw.offset+i] = 0
}
if _, err := w.Write(bw.buf[:]); err != nil {
return err
}
bw.offset = 0
for i := range bw.buf {
bw.buf[i] = 0
}
return nil
}
// Bytes returns a copy of the current block contents up to the current offset.
// Useful for testing.
func (bw *BlockWriter) Bytes() []byte {
out := make([]byte, bw.offset)
copy(out, bw.buf[:bw.offset])
return out
}
// FullBlockBytes returns the full block buffer. Only valid when offset == WalBlockSize.
func (bw *BlockWriter) FullBlockBytes() []byte {
out := make([]byte, WalBlockSize)
copy(out, bw.buf[:])
return out
}
// errBlockWriterNil is returned when a nil writer is passed to write operations.
var errBlockWriterNil = errors.New("wal: writer must not be nil")
+353
View File
@@ -0,0 +1,353 @@
package wal
import (
"bytes"
"encoding/binary"
"hash/crc32"
"testing"
)
func TestBlockWriterSingleRecord(t *testing.T) {
bw := NewBlockWriter()
var buf bytes.Buffer
payload := []byte("hello world")
if err := bw.WriteRecord(RecFull, payload, &buf); err != nil {
t.Fatalf("WriteRecord: %v", err)
}
// Record should still be buffered (block not full).
if buf.Len() != 0 {
t.Fatalf("expected no flush yet, got %d bytes", buf.Len())
}
// Flush to get the data.
if err := bw.Flush(&buf); err != nil {
t.Fatalf("Flush: %v", err)
}
written := buf.Bytes()
// Verify the record is at the start of a full block.
if len(written) != WalBlockSize {
t.Fatalf("expected full block %d bytes, got %d", WalBlockSize, len(written))
}
// Decode and verify the physical record.
rec, consumed, err := DecodePhysicalRecord(written)
if err != nil {
t.Fatalf("DecodePhysicalRecord: %v", err)
}
if rec.Type != RecFull {
t.Errorf("type = %d, want RecFull(%d)", rec.Type, RecFull)
}
if string(rec.Payload) != "hello world" {
t.Errorf("payload = %q, want %q", rec.Payload, "hello world")
}
// Remaining bytes after the record should be zero padding.
recEnd := consumed
for i := recEnd; i < WalBlockSize; i++ {
if written[i] != 0 {
t.Errorf("padding byte [%d] = %d, want 0", i, written[i])
}
}
}
func TestBlockWriterPadding(t *testing.T) {
bw := NewBlockWriter()
var buf bytes.Buffer
payloadLen := WalBlockSize - PhysicalRecordHeaderSize
payload := make([]byte, payloadLen)
for i := range payload {
payload[i] = byte(i % 256)
}
if err := bw.WriteRecord(RecFull, payload, &buf); err != nil {
t.Fatalf("WriteRecord: %v", err)
}
if buf.Len() != WalBlockSize {
t.Fatalf("expected auto-flush of full block (%d bytes), got %d", WalBlockSize, buf.Len())
}
if bw.BlockOffset() != 0 {
t.Errorf("BlockOffset = %d, want 0 after full block write", bw.BlockOffset())
}
buf.Reset()
smallPayload := []byte("next")
if err := bw.WriteRecord(RecFull, smallPayload, &buf); err != nil {
t.Fatalf("WriteRecord after full block: %v", err)
}
if buf.Len() != 0 {
t.Fatalf("expected no flush for partial block, got %d bytes", buf.Len())
}
}
func TestBlockWriterPaddingNeeded(t *testing.T) {
tests := []struct {
name string
offset uint32
want int
}{
{"beginning", 0, 0},
{"mid_block", 100, 0},
{"7_remaining", WalBlockSize - 7, 7},
{"6_remaining", WalBlockSize - 6, 6},
{"1_remaining", WalBlockSize - 1, 1},
{"full_block", WalBlockSize, 0}, // would be reset
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bw := &BlockWriter{offset: tt.offset}
got := bw.paddingNeeded()
if got != tt.want {
t.Errorf("paddingNeeded() = %d, want %d", got, tt.want)
}
})
}
}
func TestBlockWriterCrossBlock(t *testing.T) {
bw := NewBlockWriter()
var buf bytes.Buffer
payloadLen := WalBlockSize - PhysicalRecordHeaderSize - 5
payload1 := make([]byte, payloadLen)
for i := range payload1 {
payload1[i] = byte('A' + i%26)
}
if err := bw.WriteRecord(RecFull, payload1, &buf); err != nil {
t.Fatalf("WriteRecord payload1: %v", err)
}
if buf.Len() != 0 {
t.Fatalf("expected no flush after first record, got %d bytes", buf.Len())
}
payload2 := []byte("second")
if err := bw.WriteRecord(RecFull, payload2, &buf); err != nil {
t.Fatalf("WriteRecord payload2: %v", err)
}
if buf.Len() != WalBlockSize {
t.Fatalf("expected %d bytes flushed, got %d", WalBlockSize, buf.Len())
}
firstBlock := buf.Bytes()[:WalBlockSize]
rec, _, err := DecodePhysicalRecord(firstBlock)
if err != nil {
t.Fatalf("DecodePhysicalRecord block 1: %v", err)
}
if rec.Type != RecFull {
t.Errorf("type = %d, want RecFull", rec.Type)
}
if len(rec.Payload) != payloadLen {
t.Errorf("payload len = %d, want %d", len(rec.Payload), payloadLen)
}
for i := WalBlockSize - 5; i < WalBlockSize; i++ {
if firstBlock[i] != 0 {
t.Errorf("padding byte [%d] = %d, want 0", i, firstBlock[i])
}
}
if err := bw.Flush(&buf); err != nil {
t.Fatalf("Flush: %v", err)
}
secondBlock := buf.Bytes()[WalBlockSize:]
if len(secondBlock) != WalBlockSize {
t.Fatalf("second block: expected %d bytes, got %d", WalBlockSize, len(secondBlock))
}
rec2, _, err := DecodePhysicalRecord(secondBlock)
if err != nil {
t.Fatalf("DecodePhysicalRecord block 2: %v", err)
}
if string(rec2.Payload) != "second" {
t.Errorf("payload2 = %q, want %q", rec2.Payload, "second")
}
}
func TestBlockWriterRecordTooLarge(t *testing.T) {
bw := NewBlockWriter()
var buf bytes.Buffer
// Payload that exceeds block capacity.
payload := make([]byte, WalBlockSize)
err := bw.WriteRecord(RecFull, payload, &buf)
if err == nil {
t.Fatal("expected error for oversized record")
}
}
func TestBlockWriterMultipleRecords(t *testing.T) {
bw := NewBlockWriter()
var buf bytes.Buffer
// Write several small records.
records := []struct {
recType uint8
payload []byte
}{
{RecFirst, []byte("part1")},
{RecMiddle, []byte("part2")},
{RecLast, []byte("part3")},
}
for _, r := range records {
if err := bw.WriteRecord(r.recType, r.payload, &buf); err != nil {
t.Fatalf("WriteRecord(%d, %q): %v", r.recType, r.payload, err)
}
}
if err := bw.Flush(&buf); err != nil {
t.Fatalf("Flush: %v", err)
}
data := buf.Bytes()
// Decode all three records from the block.
offset := 0
for i, expected := range records {
rec, consumed, err := DecodePhysicalRecord(data[offset:])
if err != nil {
t.Fatalf("record %d: DecodePhysicalRecord at offset %d: %v", i, offset, err)
}
if rec.Type != expected.recType {
t.Errorf("record %d: type = %d, want %d", i, rec.Type, expected.recType)
}
if string(rec.Payload) != string(expected.payload) {
t.Errorf("record %d: payload = %q, want %q", i, rec.Payload, expected.payload)
}
offset += consumed
}
}
func TestBlockWriterReset(t *testing.T) {
bw := NewBlockWriter()
var buf bytes.Buffer
if err := bw.WriteRecord(RecFull, []byte("data"), &buf); err != nil {
t.Fatalf("WriteRecord: %v", err)
}
if bw.BlockOffset() == 0 {
t.Fatal("expected non-zero offset after write")
}
bw.Reset()
if bw.BlockOffset() != 0 {
t.Errorf("BlockOffset after Reset = %d, want 0", bw.BlockOffset())
}
}
func TestBlockWriterFlushEmptyBlock(t *testing.T) {
bw := NewBlockWriter()
var buf bytes.Buffer
// Flushing an empty block should be a no-op.
if err := bw.Flush(&buf); err != nil {
t.Fatalf("Flush empty: %v", err)
}
if buf.Len() != 0 {
t.Errorf("expected 0 bytes, got %d", buf.Len())
}
}
func TestBlockWriterOffsetTracking(t *testing.T) {
bw := NewBlockWriter()
var buf bytes.Buffer
// Write a small record and verify offset.
payload := []byte("track-me")
if err := bw.WriteRecord(RecFull, payload, &buf); err != nil {
t.Fatalf("WriteRecord: %v", err)
}
expectedOffset := uint32(PhysicalRecordHeaderSize + len(payload))
if bw.BlockOffset() != expectedOffset {
t.Errorf("BlockOffset = %d, want %d", bw.BlockOffset(), expectedOffset)
}
// Flush should write exactly one full block.
if err := bw.Flush(&buf); err != nil {
t.Fatalf("Flush: %v", err)
}
if buf.Len() != WalBlockSize {
t.Errorf("flushed %d bytes, want %d", buf.Len(), WalBlockSize)
}
}
func TestBlockWriterAutoFlushFullBlock(t *testing.T) {
bw := NewBlockWriter()
var buf bytes.Buffer
// Fill the block exactly.
payloadLen := WalBlockSize - PhysicalRecordHeaderSize
payload := make([]byte, payloadLen)
for i := range payload {
payload[i] = byte(i)
}
if err := bw.WriteRecord(RecFull, payload, &buf); err != nil {
t.Fatalf("WriteRecord exact fill: %v", err)
}
// Block should have been auto-flushed.
if buf.Len() != WalBlockSize {
t.Errorf("expected auto-flush of %d bytes, got %d", WalBlockSize, buf.Len())
}
if bw.BlockOffset() != 0 {
t.Errorf("BlockOffset after auto-flush = %d, want 0", bw.BlockOffset())
}
// Verify CRC is correct by decoding.
data := buf.Bytes()
rec, _, err := DecodePhysicalRecord(data)
if err != nil {
t.Fatalf("DecodePhysicalRecord: %v", err)
}
if len(rec.Payload) != payloadLen {
t.Errorf("payload len = %d, want %d", len(rec.Payload), payloadLen)
}
}
func TestBlockWriterPhysicalRecordCRC(t *testing.T) {
bw := NewBlockWriter()
var buf bytes.Buffer
payload := []byte("crc-check")
if err := bw.WriteRecord(RecFull, payload, &buf); err != nil {
t.Fatalf("WriteRecord: %v", err)
}
if err := bw.Flush(&buf); err != nil {
t.Fatalf("Flush: %v", err)
}
data := buf.Bytes()
// Manually verify CRC: covers length + type + payload.
storedCRC := binary.LittleEndian.Uint32(data[0:4])
length := binary.LittleEndian.Uint16(data[4:6])
recType := data[6]
if recType != RecFull {
t.Errorf("type = %d, want RecFull", recType)
}
if int(length) != len(payload) {
t.Errorf("length = %d, want %d", length, len(payload))
}
// Verify CRC over [length, type, payload].
crcData := data[4 : 7+length]
computedCRC := crc32.ChecksumIEEE(crcData)
if storedCRC != computedCRC {
t.Errorf("CRC mismatch: stored %d, computed %d", storedCRC, computedCRC)
}
}
+170
View File
@@ -0,0 +1,170 @@
package wal
import (
"fmt"
"os"
"path/filepath"
"github.com/dailz/go-kv/config"
)
// SegmentWriter handles appending WAL batches to a single segment file.
// It manages block-aligned writes via BlockWriter and tracks file offset
// for segment rotation decisions.
type SegmentWriter struct {
fd *os.File
dir string
cfg *config.WalConfig
segmentID uint64
startSequence uint64
blockWriter *BlockWriter
currentOffset uint64 // total bytes written (starts at WalFileHeaderSize)
maxPayload uint64 // cfg.MaxSegmentSize - WalFileHeaderSize
}
// NewSegmentWriter creates a new WAL segment file and writes the file header.
// The segment file is created with a .tmp extension, the header is written and
// synced, then the file is atomically renamed to its final name and synced again.
func NewSegmentWriter(
dir string,
segmentID uint64,
startSequence uint64,
cfg *config.WalConfig,
) (*SegmentWriter, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("wal: invalid config: %w", err)
}
baseName := fmt.Sprintf("segment-%d.wal", segmentID)
tmpPath := filepath.Join(dir, baseName+".tmp")
finalPath := filepath.Join(dir, baseName)
// Create the temp file.
fd, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return nil, fmt.Errorf("wal: create segment temp file %s: %w", tmpPath, err)
}
// Build and write the file header.
hdr := &WalFileHeader{
BlockSize: cfg.BlockSize,
SegmentID: segmentID,
StartSequence: startSequence,
}
encoded := EncodeWalHeader(hdr)
if _, err := fd.Write(encoded[:]); err != nil {
fd.Close()
os.Remove(tmpPath)
return nil, fmt.Errorf("wal: write segment header: %w", err)
}
// Sync the header to disk.
if err := fd.Sync(); err != nil {
fd.Close()
os.Remove(tmpPath)
return nil, fmt.Errorf("wal: sync segment header: %w", err)
}
// Atomically rename temp file to final name.
if err := fd.Close(); err != nil {
os.Remove(tmpPath)
return nil, fmt.Errorf("wal: close temp file: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
return nil, fmt.Errorf("wal: rename segment file: %w", err)
}
// Open the final file for appending.
fd, err = os.OpenFile(finalPath, os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil, fmt.Errorf("wal: open segment file for append: %w", err)
}
// Sync directory to make rename durable (best-effort on Linux).
if dirFD, derr := os.Open(dir); derr == nil {
dirFD.Sync()
dirFD.Close()
}
maxPayload := cfg.MaxSegmentSize - WalFileHeaderSize
return &SegmentWriter{
fd: fd,
dir: dir,
cfg: cfg,
segmentID: segmentID,
startSequence: startSequence,
blockWriter: NewBlockWriter(),
currentOffset: WalFileHeaderSize,
maxPayload: maxPayload,
}, nil
}
// AppendBatch encodes the batch into physical records and appends them to the
// segment file. The encoded batch is split into block-aligned physical records
// using SplitIntoRecords.
func (sw *SegmentWriter) AppendBatch(encodedBatch []byte) error {
records := SplitIntoRecords(encodedBatch)
if len(records) == 0 {
return nil
}
for _, rec := range records {
if len(rec) < PhysicalRecordHeaderSize {
return fmt.Errorf("wal: corrupted physical record: size %d < header size %d",
len(rec), PhysicalRecordHeaderSize)
}
recType := rec[6] // type byte is at offset 6 in the encoded record
payload := rec[PhysicalRecordHeaderSize:]
if err := sw.blockWriter.WriteRecord(recType, payload, sw.fd); err != nil {
return fmt.Errorf("wal: writing physical record: %w", err)
}
sw.currentOffset += uint64(len(rec))
}
return nil
}
// Sync flushes the segment file to durable storage.
func (sw *SegmentWriter) Sync() error {
return sw.fd.Sync()
}
// Close flushes any partial block and closes the segment file.
func (sw *SegmentWriter) Close() error {
if err := sw.blockWriter.Flush(sw.fd); err != nil {
return fmt.Errorf("wal: flushing block writer on close: %w", err)
}
return sw.fd.Close()
}
// RemainingPayload returns the number of bytes that can still be written
// to this segment before it reaches its maximum size.
func (sw *SegmentWriter) RemainingPayload() uint64 {
if sw.currentOffset >= sw.cfg.MaxSegmentSize {
return 0
}
return sw.cfg.MaxSegmentSize - sw.currentOffset
}
// CurrentOffset returns the total number of bytes written to the segment file,
// including the file header.
func (sw *SegmentWriter) CurrentOffset() uint64 {
return sw.currentOffset
}
// SegmentID returns the segment identifier.
func (sw *SegmentWriter) SegmentID() uint64 {
return sw.segmentID
}
// SegmentPath returns the full filesystem path to the segment file.
func (sw *SegmentWriter) SegmentPath() string {
return filepath.Join(sw.dir, fmt.Sprintf("segment-%d.wal", sw.segmentID))
}
+339
View File
@@ -0,0 +1,339 @@
package wal
import (
"os"
"path/filepath"
"testing"
"github.com/dailz/go-kv/config"
)
func testWalConfig() *config.WalConfig {
cfg := config.Defaults()
return &cfg
}
func TestSegmentWriterCreation(t *testing.T) {
dir := t.TempDir()
cfg := testWalConfig()
sw, err := NewSegmentWriter(dir, 1, 100, cfg)
if err != nil {
t.Fatalf("NewSegmentWriter: %v", err)
}
expectedPath := filepath.Join(dir, "segment-1.wal")
if sw.SegmentPath() != expectedPath {
t.Errorf("SegmentPath = %q, want %q", sw.SegmentPath(), expectedPath)
}
if sw.SegmentID() != 1 {
t.Errorf("SegmentID = %d, want 1", sw.SegmentID())
}
if sw.CurrentOffset() != WalFileHeaderSize {
t.Errorf("CurrentOffset = %d, want %d", sw.CurrentOffset(), WalFileHeaderSize)
}
// Verify the file exists and has correct header.
data, err := os.ReadFile(expectedPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if len(data) != WalFileHeaderSize {
t.Errorf("file size = %d, want %d (header only)", len(data), WalFileHeaderSize)
}
hdr, err := DecodeWalHeader(data)
if err != nil {
t.Fatalf("DecodeWalHeader: %v", err)
}
if hdr.SegmentID != 1 {
t.Errorf("header SegmentID = %d, want 1", hdr.SegmentID)
}
if hdr.StartSequence != 100 {
t.Errorf("header StartSequence = %d, want 100", hdr.StartSequence)
}
if hdr.BlockSize != cfg.BlockSize {
t.Errorf("header BlockSize = %d, want %d", hdr.BlockSize, cfg.BlockSize)
}
// No .tmp file should remain.
tmpPath := filepath.Join(dir, "segment-1.wal.tmp")
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
t.Errorf("temp file %q should not exist", tmpPath)
}
if err := sw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
func TestSegmentWriterAppendBatch(t *testing.T) {
dir := t.TempDir()
cfg := testWalConfig()
sw, err := NewSegmentWriter(dir, 42, 0, cfg)
if err != nil {
t.Fatalf("NewSegmentWriter: %v", err)
}
t.Cleanup(func() { sw.Close() })
// Encode a small batch.
entries := []*WalEntry{
{OpType: OpPut, ValueKind: VKInline, Key: []byte("key1"), Value: []byte("val1")},
{OpType: OpPut, ValueKind: VKInline, Key: []byte("key2"), Value: []byte("val2")},
}
encoded, err := EncodeWalBatch(0, entries)
if err != nil {
t.Fatalf("EncodeWalBatch: %v", err)
}
if err := sw.AppendBatch(encoded); err != nil {
t.Fatalf("AppendBatch: %v", err)
}
if err := sw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// Read back the file and verify records.
data, err := os.ReadFile(sw.SegmentPath())
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
// Skip file header.
body := data[WalFileHeaderSize:]
// Use FragmentCollector to reassemble.
fc := NewFragmentCollector()
offset := 0
for offset < len(body) {
// Check for trailing zeros (block padding).
if body[offset] == 0 {
break
}
rec, consumed, err := DecodePhysicalRecord(body[offset:])
if err != nil {
t.Fatalf("DecodePhysicalRecord at offset %d: %v", offset, err)
}
if err := fc.Append(rec.Type, rec.Payload); err != nil {
t.Fatalf("FragmentCollector.Append: %v", err)
}
offset += consumed
}
if !fc.IsComplete() {
t.Fatal("fragment collector should be complete")
}
decoded, err := DecodeWalBatch(fc.BatchData())
if err != nil {
t.Fatalf("DecodeWalBatch: %v", err)
}
if decoded.EntryCount != 2 {
t.Errorf("EntryCount = %d, want 2", decoded.EntryCount)
}
if decoded.BaseSequence != 0 {
t.Errorf("BaseSequence = %d, want 0", decoded.BaseSequence)
}
}
func TestSegmentWriterMultipleBatches(t *testing.T) {
dir := t.TempDir()
cfg := testWalConfig()
sw, err := NewSegmentWriter(dir, 1, 0, cfg)
if err != nil {
t.Fatalf("NewSegmentWriter: %v", err)
}
for i := 0; i < 5; i++ {
entries := []*WalEntry{
{
OpType: OpPut,
ValueKind: VKInline,
Key: []byte("key"),
Value: []byte("val"),
},
}
encoded, err := EncodeWalBatch(uint64(i), entries)
if err != nil {
t.Fatalf("EncodeWalBatch %d: %v", i, err)
}
if err := sw.AppendBatch(encoded); err != nil {
t.Fatalf("AppendBatch %d: %v", i, err)
}
}
if err := sw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
data, err := os.ReadFile(sw.SegmentPath())
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
body := data[WalFileHeaderSize:]
fc := NewFragmentCollector()
batchCount := 0
offset := 0
for offset < len(body) {
if body[offset] == 0 {
break
}
rec, consumed, err := DecodePhysicalRecord(body[offset:])
if err != nil {
t.Fatalf("DecodePhysicalRecord at offset %d: %v", offset, err)
}
offset += consumed
if err := fc.Append(rec.Type, rec.Payload); err != nil {
t.Fatalf("FragmentCollector.Append: %v", err)
}
if fc.IsComplete() {
batchCount++
decoded, err := DecodeWalBatch(fc.BatchData())
if err != nil {
t.Fatalf("DecodeWalBatch %d: %v", batchCount, err)
}
if decoded.BaseSequence != uint64(batchCount-1) {
t.Errorf("batch %d BaseSequence = %d, want %d",
batchCount, decoded.BaseSequence, batchCount-1)
}
fc.Reset()
}
}
if batchCount != 5 {
t.Errorf("decoded %d batches, want 5", batchCount)
}
}
func TestSegmentWriterRemainingPayload(t *testing.T) {
dir := t.TempDir()
cfg := testWalConfig()
sw, err := NewSegmentWriter(dir, 1, 0, cfg)
if err != nil {
t.Fatalf("NewSegmentWriter: %v", err)
}
t.Cleanup(func() { sw.Close() })
initialRemaining := sw.RemainingPayload()
entries := []*WalEntry{
{OpType: OpPut, ValueKind: VKInline, Key: []byte("k"), Value: []byte("v")},
}
encoded, err := EncodeWalBatch(0, entries)
if err != nil {
t.Fatalf("EncodeWalBatch: %v", err)
}
if err := sw.AppendBatch(encoded); err != nil {
t.Fatalf("AppendBatch: %v", err)
}
if sw.RemainingPayload() >= initialRemaining {
t.Errorf("RemainingPayload should decrease after write, got %d >= %d",
sw.RemainingPayload(), initialRemaining)
}
}
func TestSegmentWriterLargeBatch(t *testing.T) {
dir := t.TempDir()
cfg := testWalConfig()
sw, err := NewSegmentWriter(dir, 1, 0, cfg)
if err != nil {
t.Fatalf("NewSegmentWriter: %v", err)
}
// Create a batch larger than one block payload (~32KB - 7 bytes).
largeValue := make([]byte, 40*1024)
for i := range largeValue {
largeValue[i] = byte(i % 256)
}
entries := []*WalEntry{
{OpType: OpPut, ValueKind: VKValueLogPointer, Key: []byte("large-key"), Value: largeValue},
}
encoded, err := EncodeWalBatch(0, entries)
if err != nil {
t.Fatalf("EncodeWalBatch: %v", err)
}
if err := sw.AppendBatch(encoded); err != nil {
t.Fatalf("AppendBatch: %v", err)
}
if err := sw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// Read back and verify.
data, err := os.ReadFile(sw.SegmentPath())
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
body := data[WalFileHeaderSize:]
fc := NewFragmentCollector()
offset := 0
fragCount := 0
for offset < len(body) {
if body[offset] == 0 {
break
}
rec, consumed, err := DecodePhysicalRecord(body[offset:])
if err != nil {
t.Fatalf("DecodePhysicalRecord at offset %d: %v", offset, err)
}
offset += consumed
fragCount++
if err := fc.Append(rec.Type, rec.Payload); err != nil {
t.Fatalf("FragmentCollector.Append type=%d: %v", rec.Type, err)
}
}
if !fc.IsComplete() {
t.Fatal("fragment collector should be complete after large batch")
}
if fragCount < 2 {
t.Errorf("expected multiple fragments for large batch, got %d", fragCount)
}
decoded, err := DecodeWalBatch(fc.BatchData())
if err != nil {
t.Fatalf("DecodeWalBatch: %v", err)
}
if decoded.EntryCount != 1 {
t.Errorf("EntryCount = %d, want 1", decoded.EntryCount)
}
}
func TestSegmentWriterSync(t *testing.T) {
dir := t.TempDir()
cfg := testWalConfig()
sw, err := NewSegmentWriter(dir, 1, 0, cfg)
if err != nil {
t.Fatalf("NewSegmentWriter: %v", err)
}
if err := sw.Sync(); err != nil {
t.Fatalf("Sync: %v", err)
}
if err := sw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}