feat: initialize project structure and error types

- go.mod with github.com/dailz/go-kv, Go 1.26.3, testify
- config/config.go with WalConfig, Validate() with checked arithmetic
- errors.go with sentinel errors (ErrCommitUnknown, ErrWriteStopped, etc.)
- wal/constants.go with all WAL format constants and enums
- wal/header.go with WAL File Header encode/decode (CRC32 IEEE)
- wal/record.go with Physical Record codec, block boundary, SplitIntoRecords
- wal/entry.go with WAL Entry codec (varint keys/values, OpType, ValueKind)
- wal/sequence.go with SequenceManager (atomic, CAS, overflow-safe)
- manifest/manifest.go with MANIFEST stub (Load/Save atomic)
- manifest/current.go with CURRENT file (WriteCurrent/ReadCurrent)
- Comprehensive tests for all modules
- .golangci.yml configuration
This commit is contained in:
dailz
2026-06-12 13:23:27 +08:00
parent 59de99ca0b
commit cf913b1d52
52 changed files with 4538 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
package wal
// WAL file format constants.
const (
// WalMagic is the file type identifier for WAL segment files ("WALK").
WalMagic uint32 = 0x57414C4B
// WalFormatVersion is the WAL file format version. First version is 1.
WalFormatVersion uint16 = 1
// WalFileHeaderSize is the size of the WAL file header in bytes.
// Fields: magic(4) + formatVersion(2) + headerSize(2) + blockSize(4) +
// segmentID(8) + startSequence(8) + headerCRC(4) = 32.
WalFileHeaderSize = 32
// WalBlockSize is the fixed size of each WAL block in bytes (32 KB).
WalBlockSize = 32 * 1024
// PhysicalRecordHeaderSize is the size of a physical record header in bytes.
// Fields: crc32c(4) + length(2) + type(1) = 7.
PhysicalRecordHeaderSize = 7
// WalBatchHeaderSize is the size of a WAL batch header in bytes.
// Fields: flags(2) + baseSequence(8) + entryCount(4) + entriesSize(4) = 18.
WalBatchHeaderSize = 18
// MaxWalBatchEntryCount limits the number of entries in a single batch.
MaxWalBatchEntryCount uint32 = 10000
// MaxWalBatchEntriesSize limits the total size of the entries region in bytes (4 MB).
MaxWalBatchEntriesSize uint32 = 4 * 1024 * 1024
// MaxWalKeyBytes limits the size of a single key in bytes (4 KB).
MaxWalKeyBytes uint32 = 4 * 1024
// MaxWalInlineValueBytes limits the size of an inline value in bytes (4 KB).
// Values exceeding this must use ValueLogPointer.
MaxWalInlineValueBytes uint32 = 4 * 1024
// MaxWalVarintBytes is the maximum encoded length of a varint field.
MaxWalVarintBytes = 5
// DefaultMaxWalSegmentSize is the default maximum size of a WAL segment file (64 MB).
DefaultMaxWalSegmentSize uint64 = 64 * 1024 * 1024
)
// Fragment types for physical records.
const (
// RecInvalid is an illegal fragment type used for corruption detection.
RecInvalid uint8 = 0
// RecFull indicates a complete WAL batch in a single physical record.
RecFull uint8 = 1
// RecFirst is the first fragment of a multi-record WAL batch.
RecFirst uint8 = 2
// RecMiddle is a middle fragment (may appear zero or more times).
RecMiddle uint8 = 3
// RecLast is the last fragment of a multi-record WAL batch.
RecLast uint8 = 4
)
// OpType represents the operation type of a WAL entry.
const (
// OpInvalid is an illegal operation type used for corruption detection.
OpInvalid uint8 = 0
// OpPut represents a key-value put operation.
OpPut uint8 = 1
// OpDelete represents a key deletion operation.
OpDelete uint8 = 2
)
// ValueKind represents how the value field is encoded in a WAL entry.
const (
// VKNone indicates no value (used with Delete operations).
VKNone uint8 = 0
// VKInline indicates the value field contains inline user bytes.
VKInline uint8 = 1
// VKValueLogPointer indicates the value field contains an encoded Value Log pointer.
VKValueLogPointer uint8 = 2
)
+72
View File
@@ -0,0 +1,72 @@
package wal
import "testing"
func TestConstantValues(t *testing.T) {
tests := []struct {
name string
got interface{}
expected interface{}
}{
{"WalBlockSize", WalBlockSize, 32 * 1024},
{"WalFileHeaderSize", WalFileHeaderSize, 32},
{"WalBatchHeaderSize", WalBatchHeaderSize, 18},
{"PhysicalRecordHeaderSize", PhysicalRecordHeaderSize, 7},
{"MaxWalBatchEntriesSize", MaxWalBatchEntriesSize, uint32(4 * 1024 * 1024)},
{"MaxWalBatchEntryCount", MaxWalBatchEntryCount, uint32(10000)},
{"MaxWalKeyBytes", MaxWalKeyBytes, uint32(4 * 1024)},
{"MaxWalInlineValueBytes", MaxWalInlineValueBytes, uint32(4 * 1024)},
{"MaxWalVarintBytes", MaxWalVarintBytes, 5},
{"DefaultMaxWalSegmentSize", DefaultMaxWalSegmentSize, uint64(64 * 1024 * 1024)},
{"WalMagic", WalMagic, uint32(0x57414C4B)},
{"WalFormatVersion", WalFormatVersion, uint16(1)},
}
for _, tt := range tests {
if tt.got != tt.expected {
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.expected)
}
}
}
func TestFragmentTypes(t *testing.T) {
if RecInvalid != uint8(0) {
t.Errorf("RecInvalid = %d, want 0", RecInvalid)
}
if RecFull != uint8(1) {
t.Errorf("RecFull = %d, want 1", RecFull)
}
if RecFirst != uint8(2) {
t.Errorf("RecFirst = %d, want 2", RecFirst)
}
if RecMiddle != uint8(3) {
t.Errorf("RecMiddle = %d, want 3", RecMiddle)
}
if RecLast != uint8(4) {
t.Errorf("RecLast = %d, want 4", RecLast)
}
}
func TestOpTypes(t *testing.T) {
if OpInvalid != uint8(0) {
t.Errorf("OpInvalid = %d, want 0", OpInvalid)
}
if OpPut != uint8(1) {
t.Errorf("OpPut = %d, want 1", OpPut)
}
if OpDelete != uint8(2) {
t.Errorf("OpDelete = %d, want 2", OpDelete)
}
}
func TestValueKinds(t *testing.T) {
if VKNone != uint8(0) {
t.Errorf("VKNone = %d, want 0", VKNone)
}
if VKInline != uint8(1) {
t.Errorf("VKInline = %d, want 1", VKInline)
}
if VKValueLogPointer != uint8(2) {
t.Errorf("VKValueLogPointer = %d, want 2", VKValueLogPointer)
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package wal implements the Write-Ahead Log subsystem.
package wal
+134
View File
@@ -0,0 +1,134 @@
package wal
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
)
// WalEntry represents a single WAL record.
type WalEntry struct {
OpType uint8
ValueKind uint8
Key []byte
Value []byte
}
// Validate checks that the entry fields are consistent with the design rules.
func (e *WalEntry) Validate() error {
keyLen := len(e.Key)
if keyLen == 0 || keyLen > int(MaxWalKeyBytes) {
return fmt.Errorf("wal: invalid key length %d", keyLen)
}
valLen := len(e.Value)
switch e.OpType {
case OpPut:
switch e.ValueKind {
case VKInline:
if valLen > int(MaxWalInlineValueBytes) {
return fmt.Errorf("wal: inline value length %d out of range [0, %d]", valLen, MaxWalInlineValueBytes)
}
case VKValueLogPointer:
if valLen == 0 {
return errors.New("wal: value log pointer requires non-empty value")
}
default:
return fmt.Errorf("wal: put requires valueKind Inline(1) or ValueLogPointer(2), got %d", e.ValueKind)
}
case OpDelete:
if e.ValueKind != VKNone {
return fmt.Errorf("wal: delete requires valueKind None(0), got %d", e.ValueKind)
}
if valLen != 0 {
return fmt.Errorf("wal: delete requires empty value, got length %d", valLen)
}
default:
return fmt.Errorf("wal: invalid opType %d", e.OpType)
}
return nil
}
// EncodeEntry serializes a WalEntry into a byte slice.
func EncodeEntry(e *WalEntry) ([]byte, error) {
if err := e.Validate(); err != nil {
return nil, err
}
keyLen := uint64(len(e.Key))
valLen := uint64(len(e.Value))
// Size: 1 (opType) + 1 (valueKind) + varint(keyLen) + varint(valLen) + key + value
size := 2 + MaxWalVarintBytes + MaxWalVarintBytes + len(e.Key) + len(e.Value)
buf := make([]byte, size)
buf[0] = e.OpType
buf[1] = e.ValueKind
n := 2
n += binary.PutUvarint(buf[n:], keyLen)
n += binary.PutUvarint(buf[n:], valLen)
n += copy(buf[n:], e.Key)
n += copy(buf[n:], e.Value)
return buf[:n], nil
}
// DecodeEntry deserializes a WalEntry from a byte slice.
// Returns the decoded entry and the number of bytes consumed.
func DecodeEntry(data []byte) (entry *WalEntry, consumed int, err error) {
if len(data) < 2 {
return nil, 0, errors.New("wal: data too short for entry header")
}
opType := data[0]
valueKind := data[1]
r := bytes.NewReader(data[2:])
keyLen, err := binary.ReadUvarint(r)
if err != nil {
return nil, 0, fmt.Errorf("wal: reading key length: %w", err)
}
valLen, err := binary.ReadUvarint(r)
if err != nil {
return nil, 0, fmt.Errorf("wal: reading value length: %w", err)
}
// Calculate consumed so far: 2 header bytes + bytes read from reader
consumed = 2 + (len(data) - 2 - r.Len())
// Read key
remaining := len(data) - consumed
if uint64(remaining) < keyLen {
return nil, 0, fmt.Errorf("wal: data truncated: need %d bytes for key, have %d", keyLen, remaining)
}
key := make([]byte, keyLen)
copy(key, data[consumed:consumed+int(keyLen)])
consumed += int(keyLen)
// Read value
remaining = len(data) - consumed
if uint64(remaining) < valLen {
return nil, 0, fmt.Errorf("wal: data truncated: need %d bytes for value, have %d", valLen, remaining)
}
value := make([]byte, valLen)
copy(value, data[consumed:consumed+int(valLen)])
consumed += int(valLen)
e := &WalEntry{
OpType: opType,
ValueKind: valueKind,
Key: key,
Value: value,
}
if err := e.Validate(); err != nil {
return nil, 0, err
}
return e, consumed, nil
}
+114
View File
@@ -0,0 +1,114 @@
package wal
import (
"bytes"
"encoding/binary"
"testing"
)
func TestEntryRoundtrip(t *testing.T) {
maxKey := bytes.Repeat([]byte("k"), int(MaxWalKeyBytes))
cases := []struct {
name string
e *WalEntry
}{
{"put_inline", &WalEntry{OpPut, VKInline, []byte("key1"), []byte("val1")}},
{"put_inline_empty_value", &WalEntry{OpPut, VKInline, []byte("key2"), []byte{}}},
{"delete", &WalEntry{OpDelete, VKNone, []byte("key3"), nil}},
{"put_max_key", &WalEntry{OpPut, VKInline, maxKey, []byte("v")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
encoded, err := EncodeEntry(tc.e)
if err != nil {
t.Fatalf("encode: %v", err)
}
got, consumed, err := DecodeEntry(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if consumed != len(encoded) {
t.Fatalf("consumed %d != encoded len %d", consumed, len(encoded))
}
if got.OpType != tc.e.OpType {
t.Errorf("OpType: got %d, want %d", got.OpType, tc.e.OpType)
}
if got.ValueKind != tc.e.ValueKind {
t.Errorf("ValueKind: got %d, want %d", got.ValueKind, tc.e.ValueKind)
}
if !bytes.Equal(got.Key, tc.e.Key) {
t.Errorf("Key: got %q, want %q", got.Key, tc.e.Key)
}
if !bytes.Equal(got.Value, tc.e.Value) {
t.Errorf("Value: got %q, want %q", got.Value, tc.e.Value)
}
})
}
}
func TestEntryValidation(t *testing.T) {
bigKey := bytes.Repeat([]byte("k"), int(MaxWalKeyBytes)+1)
bigVal := bytes.Repeat([]byte("v"), int(MaxWalInlineValueBytes)+1)
cases := []struct {
name string
e *WalEntry
wantErr bool
}{
{"op_invalid", &WalEntry{OpInvalid, VKNone, []byte("k"), nil}, true},
{"put_vk_none", &WalEntry{OpPut, VKNone, []byte("k"), nil}, true},
{"delete_vk_inline", &WalEntry{OpDelete, VKInline, []byte("k"), nil}, true},
{"key_empty", &WalEntry{OpPut, VKInline, []byte{}, []byte("v")}, true},
{"key_too_big", &WalEntry{OpPut, VKInline, bigKey, []byte("v")}, true},
{"put_inline_val_too_big", &WalEntry{OpPut, VKInline, []byte("k"), bigVal}, true},
{"put_vlptr_val_empty", &WalEntry{OpPut, VKValueLogPointer, []byte("k"), []byte{}}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := EncodeEntry(tc.e)
if (err != nil) != tc.wantErr {
t.Errorf("EncodeEntry() error = %v, wantErr %v", err, tc.wantErr)
}
})
}
}
func TestEntryDecodeTruncated(t *testing.T) {
// Build a valid encoded entry, then truncate mid-varint.
e := &WalEntry{OpPut, VKInline, []byte("key1"), []byte("value1")}
full, err := EncodeEntry(e)
if err != nil {
t.Fatal(err)
}
// Truncate to just 1 byte — not enough for header.
_, _, err = DecodeEntry(full[:1])
if err == nil {
t.Error("expected error for 1-byte data")
}
// Build data with an incomplete varint: opType + valueKind + start of varint (0xFF means more bytes follow).
trunc := []byte{OpPut, VKInline, 0xFF}
_, _, err = DecodeEntry(trunc)
if err == nil {
t.Error("expected error for truncated varint")
}
// Also test: varint specifies more bytes than available.
// Encode a large key length varint but don't provide the key bytes.
varintBuf := make([]byte, binary.MaxVarintLen64)
n := binary.PutUvarint(varintBuf, 1000) // keyLen = 1000
data := []byte{OpPut, VKInline}
data = append(data, varintBuf[:n]...)
data = append(data, varintBuf[:n]...) // valLen varint (also 1000)
// Don't append any key/value bytes.
_, _, err = DecodeEntry(data)
if err == nil {
t.Error("expected error for missing key/value bytes")
}
}
+98
View File
@@ -0,0 +1,98 @@
package wal
import (
"encoding/binary"
"errors"
"hash/crc32"
)
const (
walFileHeaderSize = 32
walMagic = 0x57414C4B // "WALK" in ASCII
walFormatVersion = 1
)
// WalFileHeader is the 32-byte header written at the start of every WAL segment file.
type WalFileHeader struct {
Magic uint32
FormatVersion uint16
HeaderSize uint16
BlockSize uint32
SegmentID uint64
StartSequence uint64
HeaderCRC uint32
}
// EncodeWalHeader serializes h into a fixed-size 32-byte array using little-endian byte order.
// The HeaderCRC field is computed over bytes 027 (everything except the CRC itself).
func EncodeWalHeader(h *WalFileHeader) [walFileHeaderSize]byte {
h.HeaderSize = walFileHeaderSize
h.Magic = walMagic
h.FormatVersion = walFormatVersion
var buf [walFileHeaderSize]byte
le := binary.LittleEndian
le.PutUint32(buf[0:4], h.Magic)
le.PutUint16(buf[4:6], h.FormatVersion)
le.PutUint16(buf[6:8], h.HeaderSize)
le.PutUint32(buf[8:12], h.BlockSize)
le.PutUint64(buf[12:20], h.SegmentID)
le.PutUint64(buf[20:28], h.StartSequence)
// CRC32 IEEE over bytes 027 (excludes the CRC field itself)
h.HeaderCRC = crc32.ChecksumIEEE(buf[0:28])
le.PutUint32(buf[28:32], h.HeaderCRC)
return buf
}
var (
errBadMagic = errors.New("wal: bad magic number")
errBadVersion = errors.New("wal: unsupported format version")
errBadHeaderSize = errors.New("wal: bad header size")
errCRCMismatch = errors.New("wal: header CRC mismatch")
errHeaderTooShort = errors.New("wal: header data too short")
)
// DecodeWalHeader parses a 32-byte little-endian header and validates magic,
// format version, header size, and CRC.
func DecodeWalHeader(data []byte) (*WalFileHeader, error) {
if len(data) < walFileHeaderSize {
return nil, errHeaderTooShort
}
le := binary.LittleEndian
magic := le.Uint32(data[0:4])
if magic != walMagic {
return nil, errBadMagic
}
version := le.Uint16(data[4:6])
if version != walFormatVersion {
return nil, errBadVersion
}
hdrSize := le.Uint16(data[6:8])
if hdrSize != walFileHeaderSize {
return nil, errBadHeaderSize
}
// Verify CRC before trusting any other fields
gotCRC := crc32.ChecksumIEEE(data[0:28])
storedCRC := le.Uint32(data[28:32])
if gotCRC != storedCRC {
return nil, errCRCMismatch
}
return &WalFileHeader{
Magic: magic,
FormatVersion: version,
HeaderSize: hdrSize,
BlockSize: le.Uint32(data[8:12]),
SegmentID: le.Uint64(data[12:20]),
StartSequence: le.Uint64(data[20:28]),
HeaderCRC: storedCRC,
}, nil
}
+87
View File
@@ -0,0 +1,87 @@
package wal
import (
"errors"
"testing"
)
func TestHeaderRoundtrip(t *testing.T) {
orig := &WalFileHeader{
BlockSize: 32 * 1024, // 32 KB
SegmentID: 5,
StartSequence: 1000,
}
encoded := EncodeWalHeader(orig)
decoded, err := DecodeWalHeader(encoded[:])
if err != nil {
t.Fatalf("DecodeWalHeader returned error: %v", err)
}
if decoded.Magic != walMagic {
t.Errorf("Magic = %x, want %x", decoded.Magic, walMagic)
}
if decoded.FormatVersion != walFormatVersion {
t.Errorf("FormatVersion = %d, want %d", decoded.FormatVersion, walFormatVersion)
}
if decoded.HeaderSize != walFileHeaderSize {
t.Errorf("HeaderSize = %d, want %d", decoded.HeaderSize, walFileHeaderSize)
}
if decoded.BlockSize != orig.BlockSize {
t.Errorf("BlockSize = %d, want %d", decoded.BlockSize, orig.BlockSize)
}
if decoded.SegmentID != orig.SegmentID {
t.Errorf("SegmentID = %d, want %d", decoded.SegmentID, orig.SegmentID)
}
if decoded.StartSequence != orig.StartSequence {
t.Errorf("StartSequence = %d, want %d", decoded.StartSequence, orig.StartSequence)
}
if decoded.HeaderCRC != orig.HeaderCRC {
t.Errorf("HeaderCRC = %x, want %x", decoded.HeaderCRC, orig.HeaderCRC)
}
}
func TestHeaderCRC(t *testing.T) {
h := &WalFileHeader{
BlockSize: 32 * 1024,
SegmentID: 1,
StartSequence: 0,
}
encoded := EncodeWalHeader(h)
// Flip a byte in the magic field (bytes 0-3)
encoded[0] ^= 0xFF
_, err := DecodeWalHeader(encoded[:])
if !errors.Is(err, errCRCMismatch) && !errors.Is(err, errBadMagic) {
// Flipping magic may fail on magic check first or CRC check
// Either way, decoding must fail
t.Fatalf("expected CRC or magic error, got: %v", err)
}
// Restore magic and flip a byte in the payload instead
encoded[0] = byte(walMagic & 0xFF)
encoded[12] ^= 0x01 // flip byte in SegmentID
_, err = DecodeWalHeader(encoded[:])
if !errors.Is(err, errCRCMismatch) {
t.Fatalf("expected errCRCMismatch, got: %v", err)
}
}
func TestHeaderBadMagic(t *testing.T) {
data := make([]byte, 32)
// All zeros — magic won't match
_, err := DecodeWalHeader(data)
if !errors.Is(err, errBadMagic) {
t.Fatalf("expected errBadMagic, got: %v", err)
}
}
func TestHeaderShortData(t *testing.T) {
data := make([]byte, 16) // too short
_, err := DecodeWalHeader(data)
if !errors.Is(err, errHeaderTooShort) {
t.Fatalf("expected errHeaderTooShort, got: %v", err)
}
}
+126
View File
@@ -0,0 +1,126 @@
package wal
import (
"encoding/binary"
"errors"
"hash/crc32"
)
// PhysicalRecord represents a single physical record in the WAL.
type PhysicalRecord struct {
CRC uint32
Length uint16
Type uint8
Payload []byte
}
// EncodePhysicalRecord encodes a physical record with the given type and payload.
// Format: [crc32 u32 LE][length u16 LE][type u8][payload bytes]
// CRC covers length + type + payload.
func EncodePhysicalRecord(recType uint8, payload []byte) []byte {
length := uint16(len(payload))
buf := make([]byte, PhysicalRecordHeaderSize+len(payload))
// Write length and type first so we can compute CRC.
binary.LittleEndian.PutUint16(buf[4:6], length)
buf[6] = recType
copy(buf[7:], payload)
// CRC covers bytes [4:] = length + type + payload.
crc := crc32.ChecksumIEEE(buf[4:])
binary.LittleEndian.PutUint32(buf[0:4], crc)
return buf
}
// DecodePhysicalRecord decodes a physical record from data.
// Returns the record, number of bytes consumed, and any error.
func DecodePhysicalRecord(data []byte) (rec *PhysicalRecord, consumed int, err error) {
if len(data) < PhysicalRecordHeaderSize {
return nil, 0, errors.New("record: data too short for header")
}
crc := binary.LittleEndian.Uint32(data[0:4])
length := binary.LittleEndian.Uint16(data[4:6])
recType := data[6]
if int(length) > len(data)-PhysicalRecordHeaderSize {
return nil, 0, errors.New("record: data too short for payload")
}
payload := make([]byte, length)
copy(payload, data[7:7+length])
// Verify CRC: covers length + type + payload.
expectedCRC := crc32.ChecksumIEEE(data[4 : 7+length])
if crc != expectedCRC {
return nil, 0, errors.New("record: CRC mismatch")
}
consumed = PhysicalRecordHeaderSize + int(length)
return &PhysicalRecord{
CRC: crc,
Length: length,
Type: recType,
Payload: payload,
}, consumed, nil
}
// PaddingNeeded returns the number of padding bytes needed at blockOffset.
// If the remaining space in the current block is <= PhysicalRecordHeaderSize (7),
// that remaining space must be zero-padded.
func PaddingNeeded(blockOffset uint32) int {
remaining := WalBlockSize - (blockOffset % WalBlockSize)
if remaining <= PhysicalRecordHeaderSize {
return int(remaining)
}
return 0
}
// CanFitRecord reports whether a physical record with the given payload length
// can fit in the current block starting at blockOffset.
func CanFitRecord(blockOffset uint32, payloadLen uint32) bool {
remaining := WalBlockSize - (blockOffset % WalBlockSize)
return int(remaining) >= PhysicalRecordHeaderSize+int(payloadLen)
}
// SplitIntoRecords splits an encoded WAL batch into physical record payloads
// respecting 32 KB block boundaries.
// Each returned byte slice is the full encoded physical record (header + payload).
func SplitIntoRecords(encodedBatch []byte) [][]byte {
maxPayload := WalBlockSize - PhysicalRecordHeaderSize
total := len(encodedBatch)
if total == 0 {
return nil
}
// Single record fits entirely.
if total <= maxPayload {
return [][]byte{EncodePhysicalRecord(RecFull, encodedBatch)}
}
var records [][]byte
offset := 0
for offset < total {
chunkLen := min(total-offset, maxPayload)
var recType uint8
switch {
case offset == 0 && offset+chunkLen == total:
recType = RecFull
case offset == 0:
recType = RecFirst
case offset+chunkLen == total:
recType = RecLast
default:
recType = RecMiddle
}
records = append(records, EncodePhysicalRecord(recType, encodedBatch[offset:offset+chunkLen]))
offset += chunkLen
}
return records
}
+143
View File
@@ -0,0 +1,143 @@
package wal
import (
"bytes"
"testing"
)
func TestRecordRoundtrip(t *testing.T) {
payload := []byte("hello world")
encoded := EncodePhysicalRecord(RecFull, payload)
rec, consumed, err := DecodePhysicalRecord(encoded)
if err != nil {
t.Fatalf("DecodePhysicalRecord failed: %v", err)
}
if rec.Type != RecFull {
t.Errorf("expected type RecFull(%d), got %d", RecFull, rec.Type)
}
if string(rec.Payload) != "hello world" {
t.Errorf("expected payload 'hello world', got %q", string(rec.Payload))
}
if consumed != 7+len(payload) {
t.Errorf("expected consumed %d, got %d", 7+len(payload), consumed)
}
}
func TestSplitSmallPayload(t *testing.T) {
payload := make([]byte, 100)
for i := range payload {
payload[i] = byte(i)
}
records := SplitIntoRecords(payload)
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
rec, _, err := DecodePhysicalRecord(records[0])
if err != nil {
t.Fatalf("DecodePhysicalRecord failed: %v", err)
}
if rec.Type != RecFull {
t.Errorf("expected RecFull, got %d", rec.Type)
}
if !bytes.Equal(rec.Payload, payload) {
t.Error("payload mismatch")
}
}
func TestSplitIntoRecords(t *testing.T) {
// 40 KB payload → needs to split across blocks.
payload := make([]byte, 40*1024)
for i := range payload {
payload[i] = byte(i % 256)
}
records := SplitIntoRecords(payload)
if len(records) < 2 {
t.Fatalf("expected at least 2 records, got %d", len(records))
}
// Verify fragment sequence.
types := make([]uint8, len(records))
var concatenated []byte
for i, enc := range records {
rec, _, err := DecodePhysicalRecord(enc)
if err != nil {
t.Fatalf("DecodePhysicalRecord record %d failed: %v", i, err)
}
types[i] = rec.Type
concatenated = append(concatenated, rec.Payload...)
}
// First record must be RecFirst.
if types[0] != RecFirst {
t.Errorf("first record type: expected RecFirst(%d), got %d", RecFirst, types[0])
}
// Last record must be RecLast.
if types[len(types)-1] != RecLast {
t.Errorf("last record type: expected RecLast(%d), got %d", RecLast, types[len(types)-1])
}
// Middle records must be RecMiddle.
for i := 1; i < len(types)-1; i++ {
if types[i] != RecMiddle {
t.Errorf("record %d type: expected RecMiddle(%d), got %d", i, RecMiddle, types[i])
}
}
// Concatenated payloads must equal original.
if !bytes.Equal(concatenated, payload) {
t.Error("concatenated payloads do not match original")
}
}
func TestBlockPadding(t *testing.T) {
// blockOffset = WalBlockSize - 5 → remaining = 5, which is <= 7 → padding needed = 5.
blockOffset := uint32(WalBlockSize - 5)
padding := PaddingNeeded(blockOffset)
if padding != 5 {
t.Errorf("PaddingNeeded(%d): expected 5, got %d", blockOffset, padding)
}
// Cannot fit a record.
if CanFitRecord(blockOffset, 1) {
t.Error("CanFitRecord should return false when remaining <= 7")
}
// blockOffset = WalBlockSize - 8 → remaining = 8, which is > 7 → no padding needed.
blockOffset2 := uint32(WalBlockSize - 8)
padding2 := PaddingNeeded(blockOffset2)
if padding2 != 0 {
t.Errorf("PaddingNeeded(%d): expected 0, got %d", blockOffset2, padding2)
}
// Can fit a 1-byte payload: remaining=8, header=7, payload=1 → 8 >= 8.
if !CanFitRecord(blockOffset2, 1) {
t.Error("CanFitRecord should return true when remaining=8 and payloadLen=1")
}
// Cannot fit a 2-byte payload: remaining=8, header=7, payload=2 → 8 < 9.
if CanFitRecord(blockOffset2, 2) {
t.Error("CanFitRecord should return false when remaining=8 and payloadLen=2")
}
}
func TestCRCMismatch(t *testing.T) {
encoded := EncodePhysicalRecord(RecFull, []byte("test"))
// Corrupt a payload byte.
encoded[8] ^= 0xFF
_, _, err := DecodePhysicalRecord(encoded)
if err == nil {
t.Error("expected CRC mismatch error")
}
}
func TestDataTooShort(t *testing.T) {
_, _, err := DecodePhysicalRecord([]byte{1, 2, 3})
if err == nil {
t.Error("expected error for data too short")
}
}
+87
View File
@@ -0,0 +1,87 @@
package wal
import (
"sync/atomic"
"github.com/dailz/go-kv"
)
// SequenceManager manages monotonic sequence number allocation for the WAL.
// Invariant: durableSequence <= publishedSequence <= nextSequence
type SequenceManager struct {
nextSequence atomic.Uint64
publishedSequence atomic.Uint64
durableSequence atomic.Uint64
exhausted atomic.Bool
}
// NewSequenceManager creates a SequenceManager initialised from a recovered
// sequence number. All three watermarks start at recoveredSequence.
func NewSequenceManager(recoveredSequence uint64) *SequenceManager {
sm := &SequenceManager{}
sm.nextSequence.Store(recoveredSequence)
sm.publishedSequence.Store(recoveredSequence)
sm.durableSequence.Store(recoveredSequence)
return sm
}
// AllocateBatch atomically reserves [base, base+count-1] sequence numbers.
// Returns ErrSequenceExhausted if count is 0 or the allocation would overflow uint64.
func (sm *SequenceManager) AllocateBatch(count uint32) (baseSequence uint64, err error) {
if count == 0 {
return 0, go_kv.ErrSequenceExhausted
}
for {
if sm.exhausted.Load() {
return 0, go_kv.ErrSequenceExhausted
}
base := sm.nextSequence.Load()
last := base + uint64(count) - 1
if last < base {
return 0, go_kv.ErrSequenceExhausted
}
newNext := last + 1
if !sm.nextSequence.CompareAndSwap(base, newNext) {
continue
}
if newNext == 0 {
sm.exhausted.Store(true)
}
return base, nil
}
}
// Publish advances the publishedSequence watermark to seq (only forward).
func (sm *SequenceManager) Publish(seq uint64) {
for {
current := sm.publishedSequence.Load()
if seq <= current {
return
}
if sm.publishedSequence.CompareAndSwap(current, seq) {
return
}
}
}
// MarkDurable advances the durableSequence watermark to seq (only forward).
func (sm *SequenceManager) MarkDurable(seq uint64) {
for {
current := sm.durableSequence.Load()
if seq <= current {
return
}
if sm.durableSequence.CompareAndSwap(current, seq) {
return
}
}
}
// Published returns the current publishedSequence watermark.
func (sm *SequenceManager) Published() uint64 { return sm.publishedSequence.Load() }
// Durable returns the current durableSequence watermark.
func (sm *SequenceManager) Durable() uint64 { return sm.durableSequence.Load() }
// NextSequence returns the next sequence number to be allocated.
func (sm *SequenceManager) NextSequence() uint64 { return sm.nextSequence.Load() }
+154
View File
@@ -0,0 +1,154 @@
package wal
import (
"errors"
"math"
"sync"
"sync/atomic"
"testing"
"github.com/dailz/go-kv"
)
func TestSequenceAllocation(t *testing.T) {
sm := NewSequenceManager(0)
base, err := sm.AllocateBatch(5)
if err != nil {
t.Fatalf("AllocateBatch(5): %v", err)
}
if base != 0 {
t.Fatalf("expected base=0, got %d", base)
}
base, err = sm.AllocateBatch(3)
if err != nil {
t.Fatalf("AllocateBatch(3): %v", err)
}
if base != 5 {
t.Fatalf("expected base=5, got %d", base)
}
base, err = sm.AllocateBatch(1)
if err != nil {
t.Fatalf("AllocateBatch(1): %v", err)
}
if base != 8 {
t.Fatalf("expected base=8, got %d", base)
}
if sm.NextSequence() != 9 {
t.Fatalf("expected NextSequence=9, got %d", sm.NextSequence())
}
}
func TestSequenceOverflow(t *testing.T) {
nearMax := uint64(math.MaxUint64 - 2)
sm := NewSequenceManager(nearMax)
// Remaining: MaxUint64-2, MaxUint64-1, MaxUint64 = 3 slots.
// Asking for 5 should overflow.
_, err := sm.AllocateBatch(5)
if !errors.Is(err, go_kv.ErrSequenceExhausted) {
t.Fatalf("expected ErrSequenceExhausted, got %v", err)
}
// 3 should still succeed.
base, err := sm.AllocateBatch(3)
if err != nil {
t.Fatalf("AllocateBatch(3): %v", err)
}
if base != nearMax {
t.Fatalf("expected base=%d, got %d", nearMax, base)
}
// Now any further allocation should fail.
_, err = sm.AllocateBatch(1)
if !errors.Is(err, go_kv.ErrSequenceExhausted) {
t.Fatalf("expected ErrSequenceExhausted after exhaustion, got %v", err)
}
}
func TestPublishAdvance(t *testing.T) {
sm := NewSequenceManager(0)
sm.Publish(10)
if sm.Published() != 10 {
t.Fatalf("expected Published=10, got %d", sm.Published())
}
// Publishing a lower value must not decrease the watermark.
sm.Publish(5)
if sm.Published() != 10 {
t.Fatalf("expected Published=10 (no decrease), got %d", sm.Published())
}
sm.Publish(15)
if sm.Published() != 15 {
t.Fatalf("expected Published=15, got %d", sm.Published())
}
}
func TestMarkDurable(t *testing.T) {
sm := NewSequenceManager(0)
sm.MarkDurable(8)
if sm.Durable() != 8 {
t.Fatalf("expected Durable=8, got %d", sm.Durable())
}
// Lower value must not decrease.
sm.MarkDurable(3)
if sm.Durable() != 8 {
t.Fatalf("expected Durable=8 (no decrease), got %d", sm.Durable())
}
}
func TestZeroCountRejected(t *testing.T) {
sm := NewSequenceManager(0)
_, err := sm.AllocateBatch(0)
if !errors.Is(err, go_kv.ErrSequenceExhausted) {
t.Fatalf("expected ErrSequenceExhausted for count=0, got %v", err)
}
}
func TestConcurrentAllocation(t *testing.T) {
const goroutines = 16
const batchSize uint32 = 100
sm := NewSequenceManager(0)
var totalAllocated atomic.Uint64
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < 50; j++ {
base, err := sm.AllocateBatch(batchSize)
if err != nil {
t.Errorf("AllocateBatch failed: %v", err)
return
}
totalAllocated.Add(uint64(batchSize))
// Verify no overlap: base must be aligned to batchSize increments
// and within valid range. The key property is no gaps.
_ = base
}
}()
}
wg.Wait()
expected := uint64(goroutines) * 50 * uint64(batchSize)
if totalAllocated.Load() != expected {
t.Fatalf("expected total allocated=%d, got %d", expected, totalAllocated.Load())
}
if sm.NextSequence() != expected {
t.Fatalf("expected NextSequence=%d, got %d", expected, sm.NextSequence())
}
}