Files
go-kv/wal/record.go
T
dailz 94da39bb79 fix: use CRC-32C (Castagnoli) instead of IEEE for WAL (C1)
Per design §3.2 line 359, all WAL CRC computations must use crc32c
(Castagnoli polynomial 0x82F63B78). The previous code used crc32.IEEE
(Ethernet/PNG polynomial 0xEDB88320) in 5 places. While the system was
self-consistent (encode + decode both used IEEE), it diverged from the
design spec and lost SSE4.2 hardware acceleration (the native CRC32
instruction only supports Castagnoli).

Changes:
- wal/crc32c.go (new): package-level crc32cTable = crc32.MakeTable(
  crc32.Castagnoli). Central definition prevents future drift.
- wal/header.go: 2 ChecksumIEEE calls replaced with crc32.Checksum(
  data, crc32cTable). Comment updated to reference design §3.2 line 341, 359.
- wal/record.go: 2 ChecksumIEEE calls replaced.
- wal/block_writer_test.go: 1 ChecksumIEEE call in test helper replaced.
- wal/crc32c_test.go (new): 4 regression guards:
  - TestCRC32CStandardVector: RFC 3720 fixed vector (crc32c("123456789")
    = 0xE3069283).
  - TestCRC32CEdistinctFromIEEE: confirms IEEE produces different value.
  - TestHeaderCRCUsesCastagnoli: direct assertion on stored header CRC
    (catches paired encode/decode reversion that round-trip tests miss).
  - TestPhysicalRecordCRCUsesCastagnoli: same for physical record CRC.

BREAKING CHANGE: WAL files written before this fix (with IEEE CRC)
cannot be read after this fix (expects crc32c). Phase 1 has not been
released, so no real data migration is needed. Production users
post-release would need to drain + re-create the database.

Developers pulling this change should delete any local Phase-1 WAL
directories (`rm -rf <db-dir>/segment-*.wal`) before running the code;
old IEEE-encoded WALs will fail recovery on local dev machines.

Verified: all existing round-trip tests pass (encode + decode both use
crc32c, still self-consistent). Full suite green including
go test -race ./... .

Audit context: docs/audit-3.2.md C1.
2026-06-18 11:08:00 +08:00

128 lines
3.5 KiB
Go

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. Castagnoli polynomial
// per design §3.2 line 359.
crc := crc32.Checksum(buf[4:], crc32cTable)
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.Checksum(data[4 : 7+length], crc32cTable)
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
}