805 lines
20 KiB
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
|
|
}
|