feat(wal): implement WAL writer with group commit and recovery batch replay
- wal/commit_queue.go: bounded buffered channel for write requests - wal/writer.go: single-goroutine main loop implementing 11-step write flow with group commit, sequence allocation, MemTable publish/abort, write-stopped - wal/recovery.go: BatchReplayer interface, ReplayBatch, ReplaySegmentFile, RecoverFromSegments with fragment reassembly and tail corruption handling - Comprehensive tests for all modules, all pass with -race
This commit is contained in:
+28
-2
@@ -8,7 +8,7 @@
|
|||||||
"plan_name": "phase1-wal",
|
"plan_name": "phase1-wal",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"started_at": "2026-06-12T05:09:32.588Z",
|
"started_at": "2026-06-12T05:09:32.588Z",
|
||||||
"updated_at": "2026-06-12T05:49:50.501Z",
|
"updated_at": "2026-06-12T05:57:16.412Z",
|
||||||
"session_ids": [
|
"session_ids": [
|
||||||
"opencode:ses_145c3bae9ffeTB2zbsTym0Cev8"
|
"opencode:ses_145c3bae9ffeTB2zbsTym0Cev8"
|
||||||
],
|
],
|
||||||
@@ -94,6 +94,19 @@
|
|||||||
"status": "completed",
|
"status": "completed",
|
||||||
"ended_at": "2026-06-12T05:49:50.501Z",
|
"ended_at": "2026-06-12T05:49:50.501Z",
|
||||||
"elapsed_ms": 106010
|
"elapsed_ms": 106010
|
||||||
|
},
|
||||||
|
"todo:13": {
|
||||||
|
"task_key": "todo:13",
|
||||||
|
"task_label": "13",
|
||||||
|
"task_title": "Commit queue + WAL writer main loop",
|
||||||
|
"session_id": "opencode:ses_1459dbbd5ffewqrKSfu5f6t38q",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "deep",
|
||||||
|
"updated_at": "2026-06-12T05:57:16.412Z",
|
||||||
|
"started_at": "2026-06-12T05:56:53.263Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:57:16.412Z",
|
||||||
|
"elapsed_ms": 23149
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +114,7 @@
|
|||||||
"active_plan": "/home/dailz/workspace/src/go-kv/.omo/plans/phase1-wal.md",
|
"active_plan": "/home/dailz/workspace/src/go-kv/.omo/plans/phase1-wal.md",
|
||||||
"started_at": "2026-06-12T05:09:32.588Z",
|
"started_at": "2026-06-12T05:09:32.588Z",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"updated_at": "2026-06-12T05:49:50.501Z",
|
"updated_at": "2026-06-12T05:57:16.412Z",
|
||||||
"session_ids": [
|
"session_ids": [
|
||||||
"opencode:ses_145c3bae9ffeTB2zbsTym0Cev8"
|
"opencode:ses_145c3bae9ffeTB2zbsTym0Cev8"
|
||||||
],
|
],
|
||||||
@@ -187,6 +200,19 @@
|
|||||||
"status": "completed",
|
"status": "completed",
|
||||||
"ended_at": "2026-06-12T05:49:50.501Z",
|
"ended_at": "2026-06-12T05:49:50.501Z",
|
||||||
"elapsed_ms": 106010
|
"elapsed_ms": 106010
|
||||||
|
},
|
||||||
|
"todo:13": {
|
||||||
|
"task_key": "todo:13",
|
||||||
|
"task_label": "13",
|
||||||
|
"task_title": "Commit queue + WAL writer main loop",
|
||||||
|
"session_id": "opencode:ses_1459dbbd5ffewqrKSfu5f6t38q",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "deep",
|
||||||
|
"updated_at": "2026-06-12T05:57:16.412Z",
|
||||||
|
"started_at": "2026-06-12T05:56:53.263Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:57:16.412Z",
|
||||||
|
"elapsed_ms": 23149
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agent": "atlas"
|
"agent": "atlas"
|
||||||
|
|||||||
@@ -1009,7 +1009,7 @@ Max Concurrent: 6 (Wave 1b)
|
|||||||
|
|
||||||
**Commit**: YES (with Task 13)
|
**Commit**: YES (with Task 13)
|
||||||
|
|
||||||
- [ ] 13. Commit queue + WAL writer main loop
|
- [x] 13. Commit queue + WAL writer main loop
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- 创建 `wal/commit_queue.go`:`CommitQueue` — 写请求队列
|
- 创建 `wal/commit_queue.go`:`CommitQueue` — 写请求队列
|
||||||
@@ -1233,7 +1233,7 @@ Max Concurrent: 6 (Wave 1b)
|
|||||||
|
|
||||||
**Commit**: YES (with Tasks 16, 17)
|
**Commit**: YES (with Tasks 16, 17)
|
||||||
|
|
||||||
- [ ] 16. WAL recovery: fragment collector + batch replay
|
- [x] 16. WAL recovery: fragment collector + batch replay
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- 创建 `wal/recovery.go`:恢复核心逻辑
|
- 创建 `wal/recovery.go`:恢复核心逻辑
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
// WriteResult is sent to the caller when the write completes or fails.
|
||||||
|
type WriteResult struct {
|
||||||
|
Sequence uint64
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitRequest represents a single write request submitted by a caller.
|
||||||
|
type CommitRequest struct {
|
||||||
|
Entries []*WalEntry
|
||||||
|
Result chan WriteResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitQueue is a bounded buffered channel for write requests.
|
||||||
|
type CommitQueue struct {
|
||||||
|
ch chan *CommitRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommitQueue creates a bounded queue for commit requests.
|
||||||
|
func NewCommitQueue(capacity int) *CommitQueue {
|
||||||
|
if capacity < 1 {
|
||||||
|
capacity = 1
|
||||||
|
}
|
||||||
|
return &CommitQueue{ch: make(chan *CommitRequest, capacity)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit creates a commit request, submits it to the queue, and returns it.
|
||||||
|
// The send blocks when the queue is full, preserving backpressure.
|
||||||
|
func (cq *CommitQueue) Submit(entries []*WalEntry) *CommitRequest {
|
||||||
|
req := &CommitRequest{
|
||||||
|
Entries: entries,
|
||||||
|
Result: make(chan WriteResult, 1),
|
||||||
|
}
|
||||||
|
cq.ch <- req
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect drains all currently pending requests from the queue.
|
||||||
|
func (cq *CommitQueue) Collect() []*CommitRequest {
|
||||||
|
var requests []*CommitRequest
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case req, ok := <-cq.ch:
|
||||||
|
if !ok {
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
requests = append(requests, req)
|
||||||
|
default:
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the queue. Callers must stop submitting before Close.
|
||||||
|
func (cq *CommitQueue) Close() {
|
||||||
|
close(cq.ch)
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BatchReplayer is the interface for replaying recovered WAL entries.
|
||||||
|
// The recovery process calls the appropriate method for each entry.
|
||||||
|
type BatchReplayer interface {
|
||||||
|
ReplayPut(key, value []byte, sequence uint64)
|
||||||
|
ReplayDelete(key []byte, sequence uint64)
|
||||||
|
}
|
||||||
|
|
||||||
|
type replayAction struct {
|
||||||
|
opType uint8
|
||||||
|
key []byte
|
||||||
|
value []byte
|
||||||
|
sequence uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplayBatch validates and replays a decoded WAL batch, returning the next
|
||||||
|
// expected sequence after the batch.
|
||||||
|
func ReplayBatch(batch *WalBatch, expectedSequence uint64, replayer BatchReplayer) (nextSequence uint64, err error) {
|
||||||
|
if batch == nil {
|
||||||
|
return expectedSequence, errors.New("wal: batch is nil")
|
||||||
|
}
|
||||||
|
if replayer == nil {
|
||||||
|
return expectedSequence, errors.New("wal: batch replayer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if batch.Flags != 0 {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: invalid batch flags %d", batch.Flags)
|
||||||
|
}
|
||||||
|
if batch.EntryCount == 0 {
|
||||||
|
return expectedSequence, errors.New("wal: batch entry count is zero")
|
||||||
|
}
|
||||||
|
if batch.EntryCount > MaxWalBatchEntryCount {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch entry count %d exceeds maximum %d", batch.EntryCount, MaxWalBatchEntryCount)
|
||||||
|
}
|
||||||
|
if batch.EntriesSize != uint32(len(batch.Entries)) {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch entries size mismatch: header says %d bytes, have %d bytes", batch.EntriesSize, len(batch.Entries))
|
||||||
|
}
|
||||||
|
if batch.EntriesSize == 0 {
|
||||||
|
return expectedSequence, errors.New("wal: batch entries size is zero")
|
||||||
|
}
|
||||||
|
if batch.EntriesSize > MaxWalBatchEntriesSize {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch entries size %d exceeds maximum %d", batch.EntriesSize, MaxWalBatchEntriesSize)
|
||||||
|
}
|
||||||
|
if batch.BaseSequence != expectedSequence {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch base sequence %d does not match expected sequence %d", batch.BaseSequence, expectedSequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
entryCount := uint64(batch.EntryCount)
|
||||||
|
if batch.BaseSequence > math.MaxUint64-(entryCount-1) {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch sequence range overflows uint64: base sequence %d entry count %d", batch.BaseSequence, batch.EntryCount)
|
||||||
|
}
|
||||||
|
if expectedSequence > math.MaxUint64-entryCount {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: next sequence overflows uint64: expected sequence %d entry count %d", expectedSequence, batch.EntryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
actions := make([]replayAction, 0, batch.EntryCount)
|
||||||
|
offset := 0
|
||||||
|
for i := range entryCount {
|
||||||
|
entry, consumed, err := DecodeEntry(batch.Entries[offset:])
|
||||||
|
if err != nil {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: decode batch entry: %w", err)
|
||||||
|
}
|
||||||
|
if consumed <= 0 {
|
||||||
|
return expectedSequence, errors.New("wal: decoded batch entry consumed no bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence := batch.BaseSequence + i
|
||||||
|
actions = append(actions, replayAction{
|
||||||
|
opType: entry.OpType,
|
||||||
|
key: entry.Key,
|
||||||
|
value: entry.Value,
|
||||||
|
sequence: sequence,
|
||||||
|
})
|
||||||
|
offset += consumed
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset != len(batch.Entries) {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch entries contain trailing bytes: parsed %d of %d", offset, len(batch.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, action := range actions {
|
||||||
|
switch action.opType {
|
||||||
|
case OpPut:
|
||||||
|
replayer.ReplayPut(action.key, action.value, action.sequence)
|
||||||
|
case OpDelete:
|
||||||
|
replayer.ReplayDelete(action.key, action.sequence)
|
||||||
|
default:
|
||||||
|
return expectedSequence, fmt.Errorf("wal: invalid op type %d", action.opType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expectedSequence + entryCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaySegmentFile replays all complete WAL batches from one segment file.
|
||||||
|
func ReplaySegmentFile(filePath string, startSequence uint64, replayer BatchReplayer) (nextSequence uint64, err error) {
|
||||||
|
nextSequence = startSequence
|
||||||
|
records, parseErr := ParseRecordsFromFile(filePath)
|
||||||
|
if parseErr != nil && !IsTailCorruption(parseErr) {
|
||||||
|
return nextSequence, fmt.Errorf("wal: parse segment records: %w", parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
collector := NewFragmentCollector()
|
||||||
|
for _, record := range records {
|
||||||
|
if err := collector.Append(record.Type, record.Payload); err != nil {
|
||||||
|
return nextSequence, fmt.Errorf("wal: collect segment fragments: %w", err)
|
||||||
|
}
|
||||||
|
if !collector.IsComplete() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
batch, err := DecodeWalBatch(collector.BatchData())
|
||||||
|
if err != nil {
|
||||||
|
return nextSequence, fmt.Errorf("wal: decode recovered batch: %w", err)
|
||||||
|
}
|
||||||
|
nextSequence, err = ReplayBatch(batch, nextSequence, replayer)
|
||||||
|
if err != nil {
|
||||||
|
return nextSequence, fmt.Errorf("wal: replay recovered batch: %w", err)
|
||||||
|
}
|
||||||
|
collector.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
if parseErr != nil {
|
||||||
|
return nextSequence, parseErr
|
||||||
|
}
|
||||||
|
if collector.State() == FragmentCollecting {
|
||||||
|
return nextSequence, &TailCorruptionError{
|
||||||
|
Offset: 0,
|
||||||
|
Err: errors.New("incomplete fragmented batch at segment tail"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSequence, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoverFromSegments scans and replays WAL segments from recoverySegmentID.
|
||||||
|
func RecoverFromSegments(dir string, recoverySegmentID uint64, replayer BatchReplayer) (nextSequence uint64, err error) {
|
||||||
|
segments, err := ScanSegments(dir, recoverySegmentID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("wal: scan recovery segments: %w", err)
|
||||||
|
}
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSequence = segments[0].StartSequence
|
||||||
|
for _, segment := range segments {
|
||||||
|
if segment.StartSequence != nextSequence {
|
||||||
|
return nextSequence, fmt.Errorf("wal: segment start sequence %d does not match expected sequence %d", segment.StartSequence, nextSequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSequence, err = ReplaySegmentFile(segment.FilePath, nextSequence, replayer)
|
||||||
|
if err != nil {
|
||||||
|
if IsTailCorruption(err) {
|
||||||
|
return nextSequence, err
|
||||||
|
}
|
||||||
|
return nextSequence, fmt.Errorf("wal: replay segment: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSequence, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockReplayer struct {
|
||||||
|
puts []replayPut
|
||||||
|
deletes []replayDelete
|
||||||
|
}
|
||||||
|
|
||||||
|
type replayPut struct {
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
seq uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type replayDelete struct {
|
||||||
|
key string
|
||||||
|
seq uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockReplayer) ReplayPut(key, value []byte, sequence uint64) {
|
||||||
|
m.puts = append(m.puts, replayPut{key: string(key), value: string(value), seq: sequence})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockReplayer) ReplayDelete(key []byte, sequence uint64) {
|
||||||
|
m.deletes = append(m.deletes, replayDelete{key: string(key), seq: sequence})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayBatchValid(t *testing.T) {
|
||||||
|
encoded, err := EncodeWalBatch(100, []*WalEntry{
|
||||||
|
makePutEntry("alpha", "one"),
|
||||||
|
makeDeleteEntry("beta"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
batch, err := DecodeWalBatch(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
next, err := ReplayBatch(batch, 100, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReplayBatch: %v", err)
|
||||||
|
}
|
||||||
|
if next != 102 {
|
||||||
|
t.Fatalf("nextSequence = %d, want 102", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPuts := []replayPut{{key: "alpha", value: "one", seq: 100}}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Fatalf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
wantDeletes := []replayDelete{{key: "beta", seq: 101}}
|
||||||
|
if !reflect.DeepEqual(replayer.deletes, wantDeletes) {
|
||||||
|
t.Fatalf("deletes = %#v, want %#v", replayer.deletes, wantDeletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayBatchSequenceMismatch(t *testing.T) {
|
||||||
|
batch := mustDecodeTestBatch(t, 10, []*WalEntry{makePutEntry("k", "v")})
|
||||||
|
|
||||||
|
_, err := ReplayBatch(batch, 11, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ReplayBatch succeeded, want sequence mismatch error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "base sequence") {
|
||||||
|
t.Fatalf("error = %v, want base sequence context", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayBatchZeroEntries(t *testing.T) {
|
||||||
|
batch := &WalBatch{
|
||||||
|
Flags: 0,
|
||||||
|
BaseSequence: 10,
|
||||||
|
EntryCount: 0,
|
||||||
|
EntriesSize: 0,
|
||||||
|
Entries: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ReplayBatch(batch, 10, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ReplayBatch succeeded, want zero entries error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayBatchOverflowCheck(t *testing.T) {
|
||||||
|
entryBytes, err := EncodeEntry(makePutEntry("k", "v"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeEntry: %v", err)
|
||||||
|
}
|
||||||
|
batch := &WalBatch{
|
||||||
|
Flags: 0,
|
||||||
|
BaseSequence: math.MaxUint64 - 1,
|
||||||
|
EntryCount: 3,
|
||||||
|
EntriesSize: uint32(len(entryBytes) * 3),
|
||||||
|
Entries: append(append(append([]byte{}, entryBytes...), entryBytes...), entryBytes...),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ReplayBatch(batch, math.MaxUint64-1, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ReplayBatch succeeded, want overflow error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaySegmentFileFull(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := writeTestSegment(t, dir, 0, 50, [][]*WalEntry{
|
||||||
|
{makePutEntry("a", "1"), makeDeleteEntry("b")},
|
||||||
|
{makePutEntry("c", "3")},
|
||||||
|
})
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
next, err := ReplaySegmentFile(filePath, 50, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReplaySegmentFile: %v", err)
|
||||||
|
}
|
||||||
|
if next != 53 {
|
||||||
|
t.Fatalf("nextSequence = %d, want 53", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPuts := []replayPut{{key: "a", value: "1", seq: 50}, {key: "c", value: "3", seq: 52}}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Fatalf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
wantDeletes := []replayDelete{{key: "b", seq: 51}}
|
||||||
|
if !reflect.DeepEqual(replayer.deletes, wantDeletes) {
|
||||||
|
t.Fatalf("deletes = %#v, want %#v", replayer.deletes, wantDeletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaySegmentFileTailCorruption(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := writeTestSegment(t, dir, 0, 70, [][]*WalEntry{
|
||||||
|
{makePutEntry("ok", "before-corruption")},
|
||||||
|
})
|
||||||
|
appendFileBytes(t, filePath, []byte{0x01, 0x02, 0x03})
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
next, err := ReplaySegmentFile(filePath, 70, replayer)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ReplaySegmentFile succeeded, want tail corruption error")
|
||||||
|
}
|
||||||
|
if !IsTailCorruption(err) {
|
||||||
|
t.Fatalf("error = %v, want tail corruption", err)
|
||||||
|
}
|
||||||
|
if next != 71 {
|
||||||
|
t.Fatalf("nextSequence = %d, want 71", next)
|
||||||
|
}
|
||||||
|
wantPuts := []replayPut{{key: "ok", value: "before-corruption", seq: 70}}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Fatalf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverFromSegmentsMultiple(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeTestSegment(t, dir, 0, 10, [][]*WalEntry{
|
||||||
|
{makePutEntry("s0-a", "a"), makeDeleteEntry("s0-b")},
|
||||||
|
})
|
||||||
|
writeTestSegment(t, dir, 1, 12, [][]*WalEntry{
|
||||||
|
{makePutEntry("s1-c", "c")},
|
||||||
|
{makeDeleteEntry("s1-d")},
|
||||||
|
})
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
next, err := RecoverFromSegments(dir, 0, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecoverFromSegments: %v", err)
|
||||||
|
}
|
||||||
|
if next != 14 {
|
||||||
|
t.Fatalf("nextSequence = %d, want 14", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPuts := []replayPut{{key: "s0-a", value: "a", seq: 10}, {key: "s1-c", value: "c", seq: 12}}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Fatalf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
wantDeletes := []replayDelete{{key: "s0-b", seq: 11}, {key: "s1-d", seq: 13}}
|
||||||
|
if !reflect.DeepEqual(replayer.deletes, wantDeletes) {
|
||||||
|
t.Fatalf("deletes = %#v, want %#v", replayer.deletes, wantDeletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustDecodeTestBatch(t *testing.T, baseSequence uint64, entries []*WalEntry) *WalBatch {
|
||||||
|
t.Helper()
|
||||||
|
encoded, err := EncodeWalBatch(baseSequence, entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
batch, err := DecodeWalBatch(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestSegment(t *testing.T, dir string, segmentID, startSequence uint64, batches [][]*WalEntry) string {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
sw, err := NewSegmentWriter(dir, segmentID, startSequence, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentWriter: %v", err)
|
||||||
|
}
|
||||||
|
closed := false
|
||||||
|
defer func() {
|
||||||
|
if !closed {
|
||||||
|
if closeErr := sw.Close(); closeErr != nil && !errors.Is(closeErr, os.ErrClosed) {
|
||||||
|
t.Fatalf("SegmentWriter.Close cleanup: %v", closeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
nextSequence := startSequence
|
||||||
|
for i, entries := range batches {
|
||||||
|
encoded, err := EncodeWalBatch(nextSequence, 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)
|
||||||
|
}
|
||||||
|
nextSequence += uint64(len(entries))
|
||||||
|
}
|
||||||
|
if err := sw.Close(); err != nil {
|
||||||
|
t.Fatalf("SegmentWriter.Close: %v", err)
|
||||||
|
}
|
||||||
|
closed = true
|
||||||
|
return sw.SegmentPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendFileBytes(t *testing.T, filePath string, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFile append: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
t.Fatalf("Write corruption bytes: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
go_kv "github.com/dailz/go-kv"
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
"github.com/dailz/go-kv/memtable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemTableList tracks the active memtable plus frozen memtables retained for reads.
|
||||||
|
type MemTableList struct {
|
||||||
|
active *memtable.MemTable
|
||||||
|
immutable []*memtable.MemTable
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActive returns the current writable memtable.
|
||||||
|
func (mtl *MemTableList) GetActive() *memtable.MemTable {
|
||||||
|
mtl.mu.RLock()
|
||||||
|
defer mtl.mu.RUnlock()
|
||||||
|
return mtl.active
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns memtables from newest to oldest for read lookup.
|
||||||
|
func (mtl *MemTableList) GetAll() []*memtable.MemTable {
|
||||||
|
mtl.mu.RLock()
|
||||||
|
defer mtl.mu.RUnlock()
|
||||||
|
|
||||||
|
out := make([]*memtable.MemTable, 0, 1+len(mtl.immutable))
|
||||||
|
if mtl.active != nil {
|
||||||
|
out = append(out, mtl.active)
|
||||||
|
}
|
||||||
|
for i := len(mtl.immutable) - 1; i >= 0; i-- {
|
||||||
|
out = append(out, mtl.immutable[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mtl *MemTableList) rotateActive(newActive *memtable.MemTable) {
|
||||||
|
mtl.mu.Lock()
|
||||||
|
defer mtl.mu.Unlock()
|
||||||
|
if mtl.active != nil {
|
||||||
|
mtl.immutable = append(mtl.immutable, mtl.active)
|
||||||
|
}
|
||||||
|
mtl.active = newActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalWriter owns the single WAL append goroutine and group-commit pipeline.
|
||||||
|
type WalWriter struct {
|
||||||
|
cfg *config.WalConfig
|
||||||
|
dir string
|
||||||
|
queue *CommitQueue
|
||||||
|
segManager *SegmentManager
|
||||||
|
seqManager *SequenceManager
|
||||||
|
memTables *MemTableList
|
||||||
|
writeStopped atomic.Bool
|
||||||
|
done chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
closeOnce sync.Once
|
||||||
|
closeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWalWriter creates a WAL writer and starts its single background loop.
|
||||||
|
func NewWalWriter(cfg *config.WalConfig, dir string, startSegmentID uint64, startSequence uint64) (*WalWriter, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
defaults := config.Defaults()
|
||||||
|
cfg = &defaults
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
segManager, err := NewSegmentManager(dir, startSegmentID, startSequence, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
queueCapacity := max(1, int(cfg.MaxBatchEntries))
|
||||||
|
|
||||||
|
ww := &WalWriter{
|
||||||
|
cfg: cfg,
|
||||||
|
dir: dir,
|
||||||
|
queue: NewCommitQueue(queueCapacity),
|
||||||
|
segManager: segManager,
|
||||||
|
seqManager: NewSequenceManager(startSequence),
|
||||||
|
memTables: &MemTableList{
|
||||||
|
active: memtable.NewMemTable(cfg.MemTableSize),
|
||||||
|
},
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
ww.wg.Add(1)
|
||||||
|
go ww.runLoop()
|
||||||
|
|
||||||
|
return ww, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) runLoop() {
|
||||||
|
defer ww.wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ww.done:
|
||||||
|
ww.processRemaining()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := ww.queue.Collect()
|
||||||
|
if len(requests) == 0 {
|
||||||
|
time.Sleep(100 * time.Microsecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ww.processBatch(requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) processRemaining() {
|
||||||
|
for {
|
||||||
|
requests := ww.queue.Collect()
|
||||||
|
if len(requests) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ww.processBatch(requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) processBatch(requests []*CommitRequest) {
|
||||||
|
if ww.writeStopped.Load() {
|
||||||
|
ww.sendError(requests, go_kv.ErrWriteStopped)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, requestOffsets := flattenRequests(requests)
|
||||||
|
if err := ValidateBatchLimits(entries, ww.cfg); err != nil {
|
||||||
|
ww.sendError(requests, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ww.reserveMemTable(entries); err != nil {
|
||||||
|
ww.sendError(requests, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSequence, err := ww.seqManager.AllocateBatch(uint32(len(entries)))
|
||||||
|
if err != nil {
|
||||||
|
ww.sendError(requests, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastSequence := baseSequence + uint64(len(entries)) - 1
|
||||||
|
|
||||||
|
encoded, err := EncodeWalBatch(baseSequence, entries)
|
||||||
|
if err != nil {
|
||||||
|
ww.stopWithError(requests, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ww.segManager.AppendBatch(encoded); err != nil {
|
||||||
|
ww.stopWithError(requests, go_kv.ErrCommitUnknown)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
active := ww.memTables.GetActive()
|
||||||
|
if err := writePending(active, entries, baseSequence); err != nil {
|
||||||
|
abortRange(active, baseSequence, lastSequence)
|
||||||
|
ww.stopWithError(requests, go_kv.ErrCommitUnknown)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ww.segManager.Sync(); err != nil {
|
||||||
|
abortRange(active, baseSequence, lastSequence)
|
||||||
|
ww.stopWithError(requests, go_kv.ErrCommitUnknown)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ww.seqManager.MarkDurable(lastSequence)
|
||||||
|
active.Publish(lastSequence)
|
||||||
|
ww.seqManager.Publish(lastSequence)
|
||||||
|
ww.sendSuccess(requests, baseSequence, requestOffsets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenRequests(requests []*CommitRequest) ([]*WalEntry, []uint64) {
|
||||||
|
var entries []*WalEntry
|
||||||
|
requestOffsets := make([]uint64, len(requests))
|
||||||
|
for i, req := range requests {
|
||||||
|
requestOffsets[i] = uint64(len(entries))
|
||||||
|
entries = append(entries, req.Entries...)
|
||||||
|
}
|
||||||
|
return entries, requestOffsets
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) reserveMemTable(entries []*WalEntry) error {
|
||||||
|
reserveEntries := make([]memtable.ReserveEntry, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
reserveEntries = append(reserveEntries, memtable.ReserveEntry{
|
||||||
|
Key: entry.Key,
|
||||||
|
Value: entry.Value,
|
||||||
|
IsDelete: entry.OpType == OpDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
active := ww.memTables.GetActive()
|
||||||
|
if _, err := active.Reserve(reserveEntries); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !errors.Is(err, memtable.ErrMemTableFull) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ww.memTables.rotateActive(memtable.NewMemTable(ww.cfg.MemTableSize))
|
||||||
|
active = ww.memTables.GetActive()
|
||||||
|
_, err := active.Reserve(reserveEntries)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePending(mt *memtable.MemTable, entries []*WalEntry, baseSequence uint64) error {
|
||||||
|
for i, entry := range entries {
|
||||||
|
sequence := baseSequence + uint64(i)
|
||||||
|
switch entry.OpType {
|
||||||
|
case OpPut:
|
||||||
|
if err := mt.PutPending(entry.Key, entry.Value, sequence); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case OpDelete:
|
||||||
|
if err := mt.DeletePending(entry.Key, sequence); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New("wal: unsupported operation in committed batch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func abortRange(mt *memtable.MemTable, firstSequence uint64, lastSequence uint64) {
|
||||||
|
for seq := firstSequence; seq <= lastSequence; seq++ {
|
||||||
|
mt.Abort(seq)
|
||||||
|
if seq == ^uint64(0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) stopWithError(requests []*CommitRequest, err error) {
|
||||||
|
ww.writeStopped.Store(true)
|
||||||
|
ww.sendError(requests, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) sendError(requests []*CommitRequest, err error) {
|
||||||
|
for _, req := range requests {
|
||||||
|
req.Result <- WriteResult{Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) sendSuccess(requests []*CommitRequest, baseSequence uint64, requestOffsets []uint64) {
|
||||||
|
for i, req := range requests {
|
||||||
|
req.Result <- WriteResult{Sequence: baseSequence + requestOffsets[i]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put stores key with value.
|
||||||
|
func (ww *WalWriter) Put(key, value []byte) error {
|
||||||
|
if ww.writeStopped.Load() {
|
||||||
|
return go_kv.ErrWriteStopped
|
||||||
|
}
|
||||||
|
|
||||||
|
req := ww.queue.Submit([]*WalEntry{{
|
||||||
|
OpType: OpPut,
|
||||||
|
ValueKind: VKInline,
|
||||||
|
Key: cloneBytes(key),
|
||||||
|
Value: cloneBytes(value),
|
||||||
|
}})
|
||||||
|
result := <-req.Result
|
||||||
|
return result.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes key.
|
||||||
|
func (ww *WalWriter) Delete(key []byte) error {
|
||||||
|
if ww.writeStopped.Load() {
|
||||||
|
return go_kv.ErrWriteStopped
|
||||||
|
}
|
||||||
|
|
||||||
|
req := ww.queue.Submit([]*WalEntry{{
|
||||||
|
OpType: OpDelete,
|
||||||
|
ValueKind: VKNone,
|
||||||
|
Key: cloneBytes(key),
|
||||||
|
}})
|
||||||
|
result := <-req.Result
|
||||||
|
return result.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the newest visible value across active and immutable memtables.
|
||||||
|
func (ww *WalWriter) Get(key []byte) *memtable.GetResult {
|
||||||
|
for _, mt := range ww.memTables.GetAll() {
|
||||||
|
result := mt.Get(key)
|
||||||
|
if !result.Found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.Value == nil {
|
||||||
|
return &memtable.GetResult{Found: false, Sequence: result.Sequence}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return &memtable.GetResult{Found: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDurableSequence returns the highest sequence durably fsynced to WAL.
|
||||||
|
func (ww *WalWriter) GetDurableSequence() uint64 {
|
||||||
|
return ww.seqManager.Durable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWriteStopped reports whether the writer is rejecting new writes.
|
||||||
|
func (ww *WalWriter) IsWriteStopped() bool {
|
||||||
|
return ww.writeStopped.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close drains queued writes, stops the writer goroutine, syncs, and closes the segment.
|
||||||
|
func (ww *WalWriter) Close() error {
|
||||||
|
ww.closeOnce.Do(func() {
|
||||||
|
ww.writeStopped.Store(true)
|
||||||
|
ww.queue.Close()
|
||||||
|
close(ww.done)
|
||||||
|
ww.wg.Wait()
|
||||||
|
if err := ww.segManager.Sync(); err != nil {
|
||||||
|
ww.closeErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ww.closeErr = ww.segManager.Close()
|
||||||
|
})
|
||||||
|
return ww.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneBytes(src []byte) []byte {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dst := make([]byte, len(src))
|
||||||
|
copy(dst, src)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestWalWriter(t *testing.T) *WalWriter {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
ww, err := NewWalWriter(&cfg, t.TempDir(), 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWalWriter: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := ww.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return ww
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalWriterSinglePut(t *testing.T) {
|
||||||
|
ww := newTestWalWriter(t)
|
||||||
|
|
||||||
|
if err := ww.Put([]byte("k1"), []byte("v1")); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ww.Get([]byte("k1"))
|
||||||
|
if !got.Found {
|
||||||
|
t.Fatal("Get(k1) not found")
|
||||||
|
}
|
||||||
|
if string(got.Value) != "v1" {
|
||||||
|
t.Fatalf("Get(k1) value = %q, want %q", got.Value, "v1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalWriterMultiplePuts(t *testing.T) {
|
||||||
|
ww := newTestWalWriter(t)
|
||||||
|
|
||||||
|
for i := range 10 {
|
||||||
|
key := fmt.Appendf(nil, "k%d", i)
|
||||||
|
value := fmt.Appendf(nil, "v%d", i)
|
||||||
|
if err := ww.Put(key, value); err != nil {
|
||||||
|
t.Fatalf("Put(%q): %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range 10 {
|
||||||
|
key := fmt.Appendf(nil, "k%d", i)
|
||||||
|
want := fmt.Sprintf("v%d", i)
|
||||||
|
got := ww.Get(key)
|
||||||
|
if !got.Found {
|
||||||
|
t.Fatalf("Get(%q) not found", key)
|
||||||
|
}
|
||||||
|
if string(got.Value) != want {
|
||||||
|
t.Fatalf("Get(%q) value = %q, want %q", key, got.Value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalWriterGroupCommit(t *testing.T) {
|
||||||
|
ww := newTestWalWriter(t)
|
||||||
|
|
||||||
|
const writers = 5
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errCh := make(chan error, writers)
|
||||||
|
|
||||||
|
for i := range writers {
|
||||||
|
wg.Go(func() {
|
||||||
|
key := fmt.Appendf(nil, "group-k%d", i)
|
||||||
|
value := fmt.Appendf(nil, "group-v%d", i)
|
||||||
|
if err := ww.Put(key, value); err != nil {
|
||||||
|
errCh <- fmt.Errorf("put %d: %w", i, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
for err := range errCh {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range writers {
|
||||||
|
key := fmt.Appendf(nil, "group-k%d", i)
|
||||||
|
want := fmt.Sprintf("group-v%d", i)
|
||||||
|
got := ww.Get(key)
|
||||||
|
if !got.Found {
|
||||||
|
t.Fatalf("Get(%q) not found", key)
|
||||||
|
}
|
||||||
|
if string(got.Value) != want {
|
||||||
|
t.Fatalf("Get(%q) value = %q, want %q", key, got.Value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalWriterDelete(t *testing.T) {
|
||||||
|
ww := newTestWalWriter(t)
|
||||||
|
|
||||||
|
if err := ww.Put([]byte("k"), []byte("v")); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
if err := ww.Delete([]byte("k")); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ww.Get([]byte("k"))
|
||||||
|
if got.Found {
|
||||||
|
t.Fatalf("Get(k) found deleted key with value %q", got.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user