349 lines
9.3 KiB
Go
349 lines
9.3 KiB
Go
package eventbus
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
|
)
|
|
|
|
type DurableOutboxConfig struct {
|
|
Directory string
|
|
ReplayBatchSize int
|
|
SyncWrites bool
|
|
CloseTimeout time.Duration
|
|
WALSegmentBytes int64
|
|
WALSegmentAge time.Duration
|
|
WALAppendQueue int
|
|
WALCommitBatch int
|
|
WALCommitWait time.Duration
|
|
Metrics *metrics.Registry
|
|
Name string
|
|
OnError func(error)
|
|
}
|
|
|
|
type asyncRecordPublishingSink interface {
|
|
Sink
|
|
ValidateRecord(durableRecord) error
|
|
PublishRecordAsync(durableRecord, func(error)) error
|
|
}
|
|
|
|
// DurableOutboxSink accepts a record only after its WAL commit is durable.
|
|
// Publishing is asynchronous; the WAL record remains replayable until the
|
|
// broker returns PubAck. Stable event IDs make crash-window replays idempotent.
|
|
type DurableOutboxSink struct {
|
|
delegate asyncRecordPublishingSink
|
|
store *durableOutboxWAL
|
|
replayBatchSize int
|
|
closeTimeout time.Duration
|
|
metrics *metrics.Registry
|
|
name string
|
|
onError func(error)
|
|
|
|
acceptMu sync.RWMutex
|
|
mu sync.Mutex
|
|
closed bool
|
|
inflight map[outboxWALRecordRef]struct{}
|
|
pending sync.WaitGroup
|
|
closeOne sync.Once
|
|
closeErr error
|
|
}
|
|
|
|
var ErrDurableOutboxClosed = errors.New("durable outbox is closed")
|
|
|
|
func NewDurableOutboxSink(delegate Sink, cfg DurableOutboxConfig) (*DurableOutboxSink, error) {
|
|
publisher, ok := delegate.(asyncRecordPublishingSink)
|
|
if !ok || publisher == nil {
|
|
return nil, errors.New("durable outbox delegate must support async record publishing")
|
|
}
|
|
dir := strings.TrimSpace(cfg.Directory)
|
|
if dir == "" {
|
|
return nil, errors.New("durable outbox directory is required")
|
|
}
|
|
if cfg.ReplayBatchSize <= 0 {
|
|
cfg.ReplayBatchSize = 1000
|
|
}
|
|
if cfg.CloseTimeout <= 0 {
|
|
cfg.CloseTimeout = 5 * time.Second
|
|
}
|
|
store, err := newDurableOutboxWAL(durableOutboxWALConfig{
|
|
Directory: dir,
|
|
SyncWrites: cfg.SyncWrites,
|
|
SegmentBytes: cfg.WALSegmentBytes,
|
|
SegmentAge: cfg.WALSegmentAge,
|
|
AppendQueue: cfg.WALAppendQueue,
|
|
CommitBatch: cfg.WALCommitBatch,
|
|
CommitInterval: cfg.WALCommitWait,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &DurableOutboxSink{
|
|
delegate: publisher,
|
|
store: store,
|
|
replayBatchSize: cfg.ReplayBatchSize,
|
|
closeTimeout: cfg.CloseTimeout,
|
|
metrics: cfg.Metrics,
|
|
name: durableMetricName(cfg.Name),
|
|
onError: cfg.OnError,
|
|
inflight: map[outboxWALRecordRef]struct{}{},
|
|
}
|
|
s.recordBacklog()
|
|
return s, nil
|
|
}
|
|
|
|
func (s *DurableOutboxSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
return s.persistAndSubmit(ctx, "raw", env)
|
|
}
|
|
|
|
func (s *DurableOutboxSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
return s.persistAndSubmit(ctx, "unified", env)
|
|
}
|
|
|
|
func (s *DurableOutboxSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
return s.persistAndSubmit(ctx, "fields", env)
|
|
}
|
|
|
|
func (s *DurableOutboxSink) Close() error {
|
|
s.closeOne.Do(func() {
|
|
s.acceptMu.Lock()
|
|
s.mu.Lock()
|
|
s.closed = true
|
|
s.mu.Unlock()
|
|
s.acceptMu.Unlock()
|
|
|
|
walErr := s.store.Close()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
s.pending.Wait()
|
|
close(done)
|
|
}()
|
|
var waitErr error
|
|
select {
|
|
case <-done:
|
|
case <-time.After(s.closeTimeout):
|
|
waitErr = fmt.Errorf("durable outbox close timed out after %s", s.closeTimeout)
|
|
s.recordPublish("all", "close_timeout")
|
|
}
|
|
s.closeErr = errors.Join(walErr, waitErr, s.delegate.Close())
|
|
})
|
|
return s.closeErr
|
|
}
|
|
|
|
// ReplayOnce claims at most one configured batch. A bounded claim prevents a
|
|
// large recovered backlog from creating an unbounded number of PubAck futures.
|
|
func (s *DurableOutboxSink) ReplayOnce(ctx context.Context) error {
|
|
s.acceptMu.RLock()
|
|
defer s.acceptMu.RUnlock()
|
|
if s.isClosed() {
|
|
return ErrDurableOutboxClosed
|
|
}
|
|
return s.replay(ctx, s.replayBatchSize)
|
|
}
|
|
|
|
func (s *DurableOutboxSink) ReplayLoop(ctx context.Context, interval time.Duration) {
|
|
if interval <= 0 {
|
|
interval = time.Second
|
|
}
|
|
if err := s.ReplayOnce(ctx); err != nil && !errors.Is(err, ErrDurableOutboxClosed) {
|
|
s.reportError(err)
|
|
}
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := s.ReplayOnce(ctx); err != nil && !errors.Is(err, ErrDurableOutboxClosed) {
|
|
s.reportError(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *DurableOutboxSink) persistAndSubmit(ctx context.Context, kind string, env envelope.FrameEnvelope) error {
|
|
s.acceptMu.RLock()
|
|
defer s.acceptMu.RUnlock()
|
|
if s.isClosed() {
|
|
return ErrDurableOutboxClosed
|
|
}
|
|
record := durableRecord{Kind: kind, Envelope: normalizeDurableEnvelope(env)}
|
|
if err := s.delegate.ValidateRecord(record); err != nil {
|
|
s.recordPublish(kind, "validation_error")
|
|
return err
|
|
}
|
|
stored, err := s.store.Append(ctx, record)
|
|
if err != nil {
|
|
s.recordSpool(kind, "error")
|
|
return err
|
|
}
|
|
s.recordSpool(kind, "ok")
|
|
s.recordBacklog()
|
|
if err := s.submit(stored); err != nil {
|
|
// The durable commit is the device-facing acceptance boundary. Broker
|
|
// submission errors are surfaced operationally and recovered by replay.
|
|
s.reportError(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeDurableEnvelope(env envelope.FrameEnvelope) envelope.FrameEnvelope {
|
|
if env.EventID == "" {
|
|
env.EventID = env.StableEventID()
|
|
}
|
|
if env.ParseStatus == "" {
|
|
env.ParseStatus = envelope.ParseOK
|
|
}
|
|
return env
|
|
}
|
|
|
|
func (s *DurableOutboxSink) submit(stored storedOutboxRecord) error {
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
s.store.Release(stored.Ref)
|
|
return ErrDurableOutboxClosed
|
|
}
|
|
if _, exists := s.inflight[stored.Ref]; exists {
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
s.inflight[stored.Ref] = struct{}{}
|
|
s.pending.Add(1)
|
|
inflight := len(s.inflight)
|
|
s.mu.Unlock()
|
|
s.recordInflight(inflight)
|
|
|
|
err := s.delegate.PublishRecordAsync(stored.Record, func(publishErr error) {
|
|
s.complete(stored, publishErr)
|
|
})
|
|
if err == nil {
|
|
s.recordPublish(stored.Record.Kind, "submitted")
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
delete(s.inflight, stored.Ref)
|
|
inflight = len(s.inflight)
|
|
s.mu.Unlock()
|
|
s.pending.Done()
|
|
s.store.Release(stored.Ref)
|
|
s.recordInflight(inflight)
|
|
s.recordPublish(stored.Record.Kind, "submit_error")
|
|
return fmt.Errorf("submit durable outbox record: %w", err)
|
|
}
|
|
|
|
func (s *DurableOutboxSink) complete(stored storedOutboxRecord, publishErr error) {
|
|
defer s.pending.Done()
|
|
status := "acked"
|
|
if publishErr != nil {
|
|
status = "ack_error"
|
|
s.store.Release(stored.Ref)
|
|
} else if err := s.store.Ack(stored.Ref); err != nil {
|
|
publishErr = err
|
|
status = "remove_error"
|
|
}
|
|
s.mu.Lock()
|
|
delete(s.inflight, stored.Ref)
|
|
inflight := len(s.inflight)
|
|
s.mu.Unlock()
|
|
s.recordInflight(inflight)
|
|
s.recordBacklog()
|
|
s.recordPublish(stored.Record.Kind, status)
|
|
if publishErr != nil {
|
|
s.reportError(publishErr)
|
|
}
|
|
}
|
|
|
|
func (s *DurableOutboxSink) replay(ctx context.Context, limit int) error {
|
|
if ctx != nil && ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
records, err := s.store.ClaimPending(limit)
|
|
if err != nil {
|
|
return fmt.Errorf("claim durable outbox records: %w", err)
|
|
}
|
|
for index, stored := range records {
|
|
if ctx != nil && ctx.Err() != nil {
|
|
for _, remaining := range records[index:] {
|
|
s.store.Release(remaining.Ref)
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
if err := s.delegate.ValidateRecord(stored.Record); err != nil {
|
|
s.store.Release(stored.Ref)
|
|
for _, remaining := range records[index+1:] {
|
|
s.store.Release(remaining.Ref)
|
|
}
|
|
s.recordPublish(stored.Record.Kind, "validation_error")
|
|
return fmt.Errorf("validate durable outbox replay record: %w", err)
|
|
}
|
|
if err := s.submit(stored); err != nil && !errors.Is(err, ErrDurableOutboxClosed) {
|
|
s.reportError(err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *DurableOutboxSink) isClosed() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.closed
|
|
}
|
|
|
|
func (s *DurableOutboxSink) reportError(err error) {
|
|
if err != nil && s.onError != nil {
|
|
s.onError(err)
|
|
}
|
|
}
|
|
|
|
func (s *DurableOutboxSink) recordSpool(kind string, status string) {
|
|
if s.metrics == nil {
|
|
return
|
|
}
|
|
s.metrics.IncCounter("vehicle_durable_spool_records_total", metrics.Labels{
|
|
"name": s.name, "kind": kind, "status": status,
|
|
})
|
|
}
|
|
|
|
func (s *DurableOutboxSink) recordPublish(kind string, status string) {
|
|
if s.metrics == nil {
|
|
return
|
|
}
|
|
s.metrics.IncCounter("vehicle_durable_outbox_publish_total", metrics.Labels{
|
|
"name": s.name, "kind": kind, "status": status,
|
|
})
|
|
}
|
|
|
|
func (s *DurableOutboxSink) recordInflight(value int) {
|
|
if s.metrics == nil {
|
|
return
|
|
}
|
|
s.metrics.SetGauge("vehicle_durable_outbox_inflight", metrics.Labels{"name": s.name}, float64(value))
|
|
}
|
|
|
|
func (s *DurableOutboxSink) recordBacklog() {
|
|
if s.metrics == nil {
|
|
return
|
|
}
|
|
count, oldest := s.store.Stats()
|
|
labels := metrics.Labels{"name": s.name}
|
|
// Keep the legacy gauge during migration because capacity gates already
|
|
// consume it; its value now represents durable WAL records, not files.
|
|
s.metrics.SetGauge("vehicle_durable_spool_backlog_files", labels, float64(count))
|
|
s.metrics.SetGauge("vehicle_durable_outbox_backlog_records", labels, float64(count))
|
|
ageSeconds := 0.0
|
|
if count > 0 && !oldest.IsZero() {
|
|
ageSeconds = time.Since(oldest).Seconds()
|
|
if ageSeconds < 0 {
|
|
ageSeconds = 0
|
|
}
|
|
}
|
|
s.metrics.SetGauge("vehicle_durable_spool_oldest_age_seconds", labels, ageSeconds)
|
|
}
|