Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus/durable_outbox_wal.go

805 lines
20 KiB
Go

package eventbus
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"hash/crc32"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
outboxWALMagic = uint32(0x4c4e5731) // LNW1
outboxWALHeaderSize = 12
outboxWALMaxRecordBytes = 16 << 20
defaultWALSegmentBytes = 16 << 20
defaultWALAppendQueue = 100_000
defaultWALCommitBatch = 256
defaultWALCommitInterval = time.Millisecond
defaultWALSegmentAge = 5 * time.Second
)
var ErrDurableOutboxWALClosed = errors.New("durable outbox wal is closed")
type durableOutboxWALConfig struct {
Directory string
SyncWrites bool
SegmentBytes int64
SegmentAge time.Duration
AppendQueue int
CommitBatch int
CommitInterval time.Duration
}
type outboxWALRecordRef struct {
segmentID uint64
index int
}
type storedOutboxRecord struct {
Ref outboxWALRecordRef
Record durableRecord
}
type outboxWALRecordStatus uint8
const (
outboxWALPending outboxWALRecordStatus = iota
outboxWALClaimed
outboxWALAcknowledged
)
type outboxWALRecordState struct {
payloadOffset int64
payloadLength uint32
status outboxWALRecordStatus
}
type outboxWALSegment struct {
id uint64
path string
createdAt time.Time
size int64
closed bool
deleting bool
acked int
records []*outboxWALRecordState
}
type outboxWALAppendRequest struct {
record durableRecord
payload []byte
frame []byte
result chan outboxWALAppendResult
}
type outboxWALAppendResult struct {
stored storedOutboxRecord
err error
}
type durableOutboxWAL struct {
dir string
syncWrites bool
segmentBytes int64
segmentAge time.Duration
commitBatch int
commitInterval time.Duration
mu sync.Mutex
segments []*outboxWALSegment
segmentByID map[uint64]*outboxWALSegment
current *outboxWALSegment
currentFile *os.File
backlog int
fatalErr error
appendMu sync.RWMutex
closed bool
queue chan outboxWALAppendRequest
writerWG sync.WaitGroup
closeOne sync.Once
}
func newDurableOutboxWAL(cfg durableOutboxWALConfig) (*durableOutboxWAL, error) {
dir := strings.TrimSpace(cfg.Directory)
if dir == "" {
return nil, errors.New("durable outbox wal directory is required")
}
if cfg.SegmentBytes <= 0 {
cfg.SegmentBytes = defaultWALSegmentBytes
}
if cfg.SegmentAge <= 0 {
cfg.SegmentAge = defaultWALSegmentAge
}
if cfg.AppendQueue <= 0 {
cfg.AppendQueue = defaultWALAppendQueue
}
if cfg.CommitBatch <= 0 {
cfg.CommitBatch = defaultWALCommitBatch
}
if cfg.CommitInterval <= 0 {
cfg.CommitInterval = defaultWALCommitInterval
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create durable outbox wal directory: %w", err)
}
w := &durableOutboxWAL{
dir: dir,
syncWrites: cfg.SyncWrites,
segmentBytes: cfg.SegmentBytes,
segmentAge: cfg.SegmentAge,
commitBatch: cfg.CommitBatch,
commitInterval: cfg.CommitInterval,
segmentByID: map[uint64]*outboxWALSegment{},
queue: make(chan outboxWALAppendRequest, cfg.AppendQueue),
}
if err := w.loadSegments(); err != nil {
return nil, err
}
if err := w.createCurrentSegment(); err != nil {
return nil, err
}
w.writerWG.Add(1)
go w.appendLoop()
return w, nil
}
func (w *durableOutboxWAL) Append(ctx context.Context, record durableRecord) (storedOutboxRecord, error) {
payload, err := json.Marshal(record)
if err != nil {
return storedOutboxRecord{}, fmt.Errorf("marshal durable outbox wal record: %w", err)
}
if len(payload) > outboxWALMaxRecordBytes {
return storedOutboxRecord{}, fmt.Errorf("durable outbox wal record is %d bytes, max %d", len(payload), outboxWALMaxRecordBytes)
}
request := outboxWALAppendRequest{
record: record,
payload: payload,
frame: encodeOutboxWALFrame(payload),
result: make(chan outboxWALAppendResult, 1),
}
if ctx == nil {
ctx = context.Background()
}
w.appendMu.RLock()
if w.closed {
w.appendMu.RUnlock()
return storedOutboxRecord{}, ErrDurableOutboxWALClosed
}
select {
case w.queue <- request:
w.appendMu.RUnlock()
case <-ctx.Done():
w.appendMu.RUnlock()
return storedOutboxRecord{}, ctx.Err()
}
// Once admitted to the WAL queue, wait for the durability result even if
// the caller context is cancelled. Otherwise a committed record could be
// left claimed with no publisher responsible for it.
result := <-request.result
return result.stored, result.err
}
func (w *durableOutboxWAL) ClaimPending(limit int) ([]storedOutboxRecord, error) {
if limit <= 0 {
limit = defaultWALCommitBatch
}
w.mu.Lock()
refs := make([]outboxWALRecordRef, 0, limit)
for _, segment := range w.segments {
for index, state := range segment.records {
if state.status != outboxWALPending {
continue
}
state.status = outboxWALClaimed
refs = append(refs, outboxWALRecordRef{segmentID: segment.id, index: index})
if len(refs) >= limit {
break
}
}
if len(refs) >= limit {
break
}
}
w.mu.Unlock()
if len(refs) == 0 {
return nil, nil
}
records, err := w.readClaimedRecords(refs)
if err != nil {
for _, ref := range refs {
w.Release(ref)
}
return nil, err
}
return records, nil
}
func (w *durableOutboxWAL) Release(ref outboxWALRecordRef) {
w.mu.Lock()
defer w.mu.Unlock()
state := w.recordStateLocked(ref)
if state != nil && state.status == outboxWALClaimed {
state.status = outboxWALPending
}
}
func (w *durableOutboxWAL) Ack(ref outboxWALRecordRef) error {
w.mu.Lock()
segment := w.segmentByID[ref.segmentID]
if segment == nil || ref.index < 0 || ref.index >= len(segment.records) {
w.mu.Unlock()
return nil
}
state := segment.records[ref.index]
if state.status == outboxWALAcknowledged {
w.mu.Unlock()
return nil
}
state.status = outboxWALAcknowledged
segment.acked++
if w.backlog > 0 {
w.backlog--
}
shouldDelete := segment.closed && segment.acked == len(segment.records) && !segment.deleting
if shouldDelete {
segment.deleting = true
}
w.mu.Unlock()
if !shouldDelete {
return nil
}
return w.deleteAcknowledgedSegment(segment)
}
func (w *durableOutboxWAL) Stats() (backlog int, oldest time.Time) {
w.mu.Lock()
defer w.mu.Unlock()
backlog = w.backlog
if backlog == 0 {
return backlog, time.Time{}
}
for _, segment := range w.segments {
if segment.acked < len(segment.records) {
return backlog, segment.createdAt
}
}
return backlog, time.Time{}
}
func (w *durableOutboxWAL) Close() error {
w.closeOne.Do(func() {
w.appendMu.Lock()
w.closed = true
close(w.queue)
w.appendMu.Unlock()
w.writerWG.Wait()
})
w.mu.Lock()
err := w.fatalErr
w.mu.Unlock()
return err
}
func (w *durableOutboxWAL) appendLoop() {
defer w.writerWG.Done()
maintenanceInterval := minDuration(w.segmentAge/2, time.Second)
if maintenanceInterval < 10*time.Millisecond {
maintenanceInterval = 10 * time.Millisecond
}
maintenance := time.NewTicker(maintenanceInterval)
defer maintenance.Stop()
for {
select {
case request, ok := <-w.queue:
if !ok {
w.finishWriter()
return
}
batch := w.collectAppendBatch(request)
w.commitAppendBatch(batch)
case <-maintenance.C:
if err := w.rotateIfAged(); err != nil {
w.setFatal(err)
}
}
}
}
func (w *durableOutboxWAL) collectAppendBatch(first outboxWALAppendRequest) []outboxWALAppendRequest {
batch := make([]outboxWALAppendRequest, 0, w.commitBatch)
batch = append(batch, first)
timer := time.NewTimer(w.commitInterval)
defer timer.Stop()
for len(batch) < w.commitBatch {
select {
case request, ok := <-w.queue:
if !ok {
return batch
}
batch = append(batch, request)
case <-timer.C:
return batch
}
}
return batch
}
func (w *durableOutboxWAL) commitAppendBatch(batch []outboxWALAppendRequest) {
if len(batch) == 0 {
return
}
w.mu.Lock()
fatalErr := w.fatalErr
w.mu.Unlock()
if fatalErr != nil {
w.completeAppendErrors(batch, fatalErr)
return
}
for len(batch) > 0 {
if err := w.rotateBeforeAppend(len(batch[0].frame)); err != nil {
w.setFatal(err)
w.completeAppendErrors(batch, err)
return
}
count := w.batchCountForCurrentSegment(batch)
if count <= 0 {
count = 1
}
group := batch[:count]
if err := w.commitAppendGroup(group); err != nil {
w.setFatal(err)
w.completeAppendErrors(batch, err)
return
}
batch = batch[count:]
}
}
func (w *durableOutboxWAL) commitAppendGroup(group []outboxWALAppendRequest) error {
start := w.current.size
totalBytes := 0
for _, request := range group {
totalBytes += len(request.frame)
}
buffer := make([]byte, 0, totalBytes)
for _, request := range group {
buffer = append(buffer, request.frame...)
}
written, err := w.currentFile.Write(buffer)
if err != nil || written != len(buffer) {
if err == nil {
err = io.ErrShortWrite
}
w.rollbackAppend(start)
return fmt.Errorf("append durable outbox wal: %w", err)
}
if w.syncWrites {
if err := w.currentFile.Sync(); err != nil {
w.rollbackAppend(start)
return fmt.Errorf("sync durable outbox wal: %w", err)
}
}
w.mu.Lock()
segment := w.current
offset := start
results := make([]outboxWALAppendResult, 0, len(group))
for _, request := range group {
state := &outboxWALRecordState{
payloadOffset: offset + outboxWALHeaderSize,
payloadLength: uint32(len(request.payload)),
status: outboxWALClaimed,
}
index := len(segment.records)
segment.records = append(segment.records, state)
w.backlog++
results = append(results, outboxWALAppendResult{stored: storedOutboxRecord{
Ref: outboxWALRecordRef{segmentID: segment.id, index: index},
Record: request.record,
}})
offset += int64(len(request.frame))
}
segment.size += int64(len(buffer))
w.mu.Unlock()
for index, request := range group {
request.result <- results[index]
}
return nil
}
func (w *durableOutboxWAL) rollbackAppend(size int64) {
if w.currentFile == nil {
return
}
_ = w.currentFile.Truncate(size)
if w.syncWrites {
_ = w.currentFile.Sync()
}
}
func (w *durableOutboxWAL) rotateBeforeAppend(frameBytes int) error {
if w.current == nil {
return w.createCurrentSegment()
}
if len(w.current.records) == 0 {
return nil
}
tooLarge := w.current.size+int64(frameBytes) > w.segmentBytes
tooOld := time.Since(w.current.createdAt) >= w.segmentAge
if !tooLarge && !tooOld {
return nil
}
return w.rotateCurrentSegment()
}
func (w *durableOutboxWAL) rotateIfAged() error {
w.mu.Lock()
current := w.current
shouldRotate := current != nil && len(current.records) > 0 && time.Since(current.createdAt) >= w.segmentAge
w.mu.Unlock()
if !shouldRotate {
return nil
}
return w.rotateCurrentSegment()
}
func (w *durableOutboxWAL) rotateCurrentSegment() error {
if w.currentFile == nil || w.current == nil {
return w.createCurrentSegment()
}
if w.syncWrites {
if err := w.currentFile.Sync(); err != nil {
return fmt.Errorf("sync closing durable outbox wal segment: %w", err)
}
}
if err := w.currentFile.Close(); err != nil {
return fmt.Errorf("close durable outbox wal segment: %w", err)
}
w.mu.Lock()
old := w.current
old.closed = true
w.current = nil
w.currentFile = nil
deleteOld := old.acked == len(old.records) && !old.deleting
if deleteOld {
old.deleting = true
}
w.mu.Unlock()
if deleteOld {
if err := w.deleteAcknowledgedSegment(old); err != nil {
return err
}
}
return w.createCurrentSegment()
}
func (w *durableOutboxWAL) createCurrentSegment() error {
w.mu.Lock()
lastID := uint64(0)
if len(w.segments) > 0 {
lastID = w.segments[len(w.segments)-1].id
}
w.mu.Unlock()
id := uint64(time.Now().UnixNano())
if id <= lastID {
id = lastID + 1
}
path := filepath.Join(w.dir, outboxWALFileName(id))
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR|os.O_APPEND, 0o640)
if err != nil {
return fmt.Errorf("create durable outbox wal segment: %w", err)
}
if w.syncWrites {
if err := syncDirectory(w.dir); err != nil {
_ = file.Close()
return fmt.Errorf("sync durable outbox wal directory after create: %w", err)
}
}
segment := &outboxWALSegment{id: id, path: path, createdAt: time.Now()}
w.mu.Lock()
w.segments = append(w.segments, segment)
w.segmentByID[id] = segment
w.current = segment
w.currentFile = file
w.mu.Unlock()
return nil
}
func (w *durableOutboxWAL) finishWriter() {
if w.currentFile == nil || w.current == nil {
return
}
var finishErr error
if w.syncWrites {
finishErr = w.currentFile.Sync()
}
if closeErr := w.currentFile.Close(); finishErr == nil {
finishErr = closeErr
}
w.mu.Lock()
current := w.current
current.closed = true
w.current = nil
w.currentFile = nil
deleteCurrent := current.acked == len(current.records) && !current.deleting
if deleteCurrent {
current.deleting = true
}
w.mu.Unlock()
if deleteCurrent {
if err := w.deleteAcknowledgedSegment(current); finishErr == nil {
finishErr = err
}
}
if finishErr != nil {
w.setFatal(finishErr)
}
}
func (w *durableOutboxWAL) batchCountForCurrentSegment(batch []outboxWALAppendRequest) int {
remaining := w.segmentBytes - w.current.size
count := 0
for _, request := range batch {
if count > 0 && int64(len(request.frame)) > remaining {
break
}
remaining -= int64(len(request.frame))
count++
if remaining <= 0 {
break
}
}
return count
}
func (w *durableOutboxWAL) completeAppendErrors(batch []outboxWALAppendRequest, err error) {
for _, request := range batch {
request.result <- outboxWALAppendResult{err: err}
}
}
func (w *durableOutboxWAL) readClaimedRecords(refs []outboxWALRecordRef) ([]storedOutboxRecord, error) {
files := map[uint64]*os.File{}
defer func() {
for _, file := range files {
_ = file.Close()
}
}()
records := make([]storedOutboxRecord, 0, len(refs))
for _, ref := range refs {
w.mu.Lock()
segment := w.segmentByID[ref.segmentID]
state := w.recordStateLocked(ref)
w.mu.Unlock()
if segment == nil || state == nil {
return nil, fmt.Errorf("durable outbox wal record reference not found: segment=%d index=%d", ref.segmentID, ref.index)
}
file := files[segment.id]
if file == nil {
opened, err := os.Open(segment.path)
if err != nil {
return nil, fmt.Errorf("open durable outbox wal segment for replay: %w", err)
}
files[segment.id] = opened
file = opened
}
payload := make([]byte, state.payloadLength)
if _, err := file.ReadAt(payload, state.payloadOffset); err != nil {
return nil, fmt.Errorf("read durable outbox wal record: %w", err)
}
var record durableRecord
if err := json.Unmarshal(payload, &record); err != nil {
return nil, fmt.Errorf("decode durable outbox wal record: %w", err)
}
records = append(records, storedOutboxRecord{Ref: ref, Record: record})
}
return records, nil
}
func (w *durableOutboxWAL) loadSegments() error {
paths, err := filepath.Glob(filepath.Join(w.dir, "outbox-*.wal"))
if err != nil {
return fmt.Errorf("list durable outbox wal segments: %w", err)
}
sort.Strings(paths)
for _, path := range paths {
segment, err := loadOutboxWALSegment(path, w.syncWrites)
if err != nil {
return err
}
if len(segment.records) == 0 {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove empty durable outbox wal segment: %w", err)
}
if w.syncWrites {
if err := syncDirectory(w.dir); err != nil {
return fmt.Errorf("sync durable outbox wal directory after removing empty segment: %w", err)
}
}
continue
}
w.segments = append(w.segments, segment)
w.segmentByID[segment.id] = segment
w.backlog += len(segment.records)
}
return nil
}
func loadOutboxWALSegment(path string, syncWrites bool) (*outboxWALSegment, error) {
id, err := parseOutboxWALFileName(filepath.Base(path))
if err != nil {
return nil, err
}
file, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return nil, fmt.Errorf("open durable outbox wal segment: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("stat durable outbox wal segment: %w", err)
}
segment := &outboxWALSegment{
id: id,
path: path,
createdAt: info.ModTime(),
closed: true,
}
offset := int64(0)
header := make([]byte, outboxWALHeaderSize)
for offset < info.Size() {
n, readErr := file.ReadAt(header, offset)
if readErr != nil {
if readErr == io.EOF && n < outboxWALHeaderSize {
if err := truncateOutboxWALTail(file, offset, syncWrites); err != nil {
return nil, err
}
break
}
return nil, fmt.Errorf("read durable outbox wal header at %d: %w", offset, readErr)
}
if binary.BigEndian.Uint32(header[0:4]) != outboxWALMagic {
return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: invalid magic", path, offset)
}
length := binary.BigEndian.Uint32(header[4:8])
checksum := binary.BigEndian.Uint32(header[8:12])
if length == 0 || length > outboxWALMaxRecordBytes {
return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: invalid length %d", path, offset, length)
}
frameEnd := offset + outboxWALHeaderSize + int64(length)
if frameEnd > info.Size() {
if err := truncateOutboxWALTail(file, offset, syncWrites); err != nil {
return nil, err
}
break
}
payload := make([]byte, length)
if _, err := file.ReadAt(payload, offset+outboxWALHeaderSize); err != nil {
return nil, fmt.Errorf("read durable outbox wal payload at %d: %w", offset, err)
}
if crc32.ChecksumIEEE(payload) != checksum {
return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: checksum mismatch", path, offset)
}
segment.records = append(segment.records, &outboxWALRecordState{
payloadOffset: offset + outboxWALHeaderSize,
payloadLength: length,
status: outboxWALPending,
})
offset = frameEnd
}
segment.size = offset
return segment, nil
}
func truncateOutboxWALTail(file *os.File, size int64, syncWrites bool) error {
if err := file.Truncate(size); err != nil {
return fmt.Errorf("truncate incomplete durable outbox wal tail: %w", err)
}
if syncWrites {
if err := file.Sync(); err != nil {
return fmt.Errorf("sync truncated durable outbox wal tail: %w", err)
}
}
return nil
}
func (w *durableOutboxWAL) deleteAcknowledgedSegment(segment *outboxWALSegment) error {
err := os.Remove(segment.path)
if os.IsNotExist(err) {
err = nil
}
if err == nil && w.syncWrites {
err = syncDirectory(w.dir)
}
w.mu.Lock()
defer w.mu.Unlock()
if err != nil {
segment.deleting = false
return fmt.Errorf("delete acknowledged durable outbox wal segment: %w", err)
}
delete(w.segmentByID, segment.id)
for index, candidate := range w.segments {
if candidate != segment {
continue
}
w.segments = append(w.segments[:index], w.segments[index+1:]...)
break
}
return nil
}
func (w *durableOutboxWAL) recordStateLocked(ref outboxWALRecordRef) *outboxWALRecordState {
segment := w.segmentByID[ref.segmentID]
if segment == nil || ref.index < 0 || ref.index >= len(segment.records) {
return nil
}
return segment.records[ref.index]
}
func (w *durableOutboxWAL) setFatal(err error) {
if err == nil {
return
}
w.mu.Lock()
if w.fatalErr == nil {
w.fatalErr = err
}
w.mu.Unlock()
}
func encodeOutboxWALFrame(payload []byte) []byte {
frame := make([]byte, outboxWALHeaderSize+len(payload))
binary.BigEndian.PutUint32(frame[0:4], outboxWALMagic)
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
binary.BigEndian.PutUint32(frame[8:12], crc32.ChecksumIEEE(payload))
copy(frame[outboxWALHeaderSize:], payload)
return frame
}
func outboxWALFileName(id uint64) string {
return fmt.Sprintf("outbox-%020d.wal", id)
}
func parseOutboxWALFileName(name string) (uint64, error) {
if !strings.HasPrefix(name, "outbox-") || !strings.HasSuffix(name, ".wal") {
return 0, fmt.Errorf("invalid durable outbox wal file name %q", name)
}
value := strings.TrimSuffix(strings.TrimPrefix(name, "outbox-"), ".wal")
id, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse durable outbox wal file name %q: %w", name, err)
}
return id, nil
}
func syncDirectory(dir string) error {
handle, err := os.Open(dir)
if err != nil {
return err
}
syncErr := handle.Sync()
closeErr := handle.Close()
return errors.Join(syncErr, closeErr)
}
func minDuration(left, right time.Duration) time.Duration {
if left <= 0 {
return right
}
if right <= 0 || left < right {
return left
}
return right
}