487 lines
12 KiB
Go
487 lines
12 KiB
Go
package eventbus
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
|
)
|
|
|
|
type DurableConfig struct {
|
|
Directory string
|
|
ReplayBatchSize int
|
|
Metrics *metrics.Registry
|
|
Name string
|
|
}
|
|
|
|
type DurableSink struct {
|
|
delegate Sink
|
|
dir string
|
|
metrics *metrics.Registry
|
|
name string
|
|
|
|
mu sync.Mutex
|
|
seq uint64
|
|
rawPending map[string]struct{}
|
|
replayBatchSize int
|
|
}
|
|
|
|
type durableRecord struct {
|
|
Kind string `json:"kind"`
|
|
Envelope envelope.FrameEnvelope `json:"envelope"`
|
|
}
|
|
|
|
type durableRecordFile struct {
|
|
path string
|
|
record durableRecord
|
|
}
|
|
|
|
type recordPublishingSink interface {
|
|
PublishRecords(context.Context, []durableRecord) error
|
|
}
|
|
|
|
const durableReplayOperationTimeout = 30 * time.Second
|
|
|
|
func NewDurableSink(delegate Sink, cfg DurableConfig) *DurableSink {
|
|
if delegate == nil {
|
|
panic("durable delegate sink must not be nil")
|
|
}
|
|
s := &DurableSink{
|
|
delegate: delegate,
|
|
dir: strings.TrimSpace(cfg.Directory),
|
|
metrics: cfg.Metrics,
|
|
name: durableMetricName(cfg.Name),
|
|
rawPending: map[string]struct{}{},
|
|
replayBatchSize: cfg.ReplayBatchSize,
|
|
}
|
|
s.recordBacklogAfterReplay()
|
|
return s
|
|
}
|
|
|
|
func (s *DurableSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
if err := s.delegate.PublishRaw(ctx, env); err == nil {
|
|
return nil
|
|
}
|
|
if err := s.spool("raw", env); err != nil {
|
|
return err
|
|
}
|
|
s.markRawPending(env)
|
|
return nil
|
|
}
|
|
|
|
func (s *DurableSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
if s.isRawPending(env) {
|
|
return s.spool("unified", env)
|
|
}
|
|
if err := s.delegate.PublishUnified(ctx, env); err == nil {
|
|
return nil
|
|
}
|
|
return s.spool("unified", env)
|
|
}
|
|
|
|
func (s *DurableSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
if s.isRawPending(env) {
|
|
return s.spool("fields", env)
|
|
}
|
|
if err := s.delegate.PublishFields(ctx, env); err == nil {
|
|
return nil
|
|
}
|
|
return s.spool("fields", env)
|
|
}
|
|
|
|
func (s *DurableSink) ReplayOnce(ctx context.Context) error {
|
|
return s.replay(ctx, 0)
|
|
}
|
|
|
|
func (s *DurableSink) replay(ctx context.Context, limit int) error {
|
|
files, err := durableFiles(s.dir, limit)
|
|
if err != nil {
|
|
s.recordReplay("list_error", 0)
|
|
return err
|
|
}
|
|
s.recordBacklogAfterReplay()
|
|
records := make([]durableRecordFile, 0, len(files))
|
|
for _, file := range files {
|
|
record, err := readDurableRecord(file)
|
|
if err != nil {
|
|
s.recordReplay("read_error", 1)
|
|
if quarantineErr := quarantineDurableFile(file); quarantineErr != nil {
|
|
s.recordReplay("quarantine_error", 1)
|
|
return fmt.Errorf("quarantine unreadable durable record %s: read error: %w; quarantine error: %v", file, err, quarantineErr)
|
|
}
|
|
s.recordReplay("quarantined", 1)
|
|
continue
|
|
}
|
|
records = append(records, durableRecordFile{path: file, record: record})
|
|
}
|
|
if len(records) == 0 {
|
|
s.recordReplay("empty", 0)
|
|
s.recordBacklogAfterReplay()
|
|
return nil
|
|
}
|
|
sortDurableRecords(records)
|
|
if publisher, ok := s.delegate.(recordPublishingSink); ok {
|
|
durableRecords := make([]durableRecord, 0, len(records))
|
|
for _, item := range records {
|
|
durableRecords = append(durableRecords, item.record)
|
|
}
|
|
if err := publisher.PublishRecords(ctx, durableRecords); err != nil {
|
|
s.recordReplay("publish_error", len(records))
|
|
s.recordReplayRecords(records, "publish_error")
|
|
return err
|
|
}
|
|
for _, item := range records {
|
|
if err := os.Remove(item.path); err != nil {
|
|
s.recordReplay("delete_error", len(records))
|
|
return err
|
|
}
|
|
if item.record.Kind == "raw" {
|
|
s.clearRawPending(item.record.Envelope)
|
|
}
|
|
}
|
|
s.recordReplay("ok", len(records))
|
|
s.recordReplayRecords(records, "ok")
|
|
s.recordBacklogAfterReplay()
|
|
return nil
|
|
}
|
|
for _, item := range records {
|
|
if err := s.publishRecord(ctx, item.record); err != nil {
|
|
s.recordReplay("publish_error", len(records))
|
|
s.recordReplayRecord(item.record, "publish_error")
|
|
return err
|
|
}
|
|
if err := os.Remove(item.path); err != nil {
|
|
s.recordReplay("delete_error", len(records))
|
|
return err
|
|
}
|
|
if item.record.Kind == "raw" {
|
|
s.clearRawPending(item.record.Envelope)
|
|
}
|
|
s.recordReplayRecord(item.record, "ok")
|
|
}
|
|
s.recordReplay("ok", len(records))
|
|
s.recordBacklogAfterReplay()
|
|
return nil
|
|
}
|
|
|
|
func sortDurableRecords(records []durableRecordFile) {
|
|
sort.SliceStable(records, func(i, j int) bool {
|
|
leftEvent := records[i].record.Envelope.StableEventID()
|
|
rightEvent := records[j].record.Envelope.StableEventID()
|
|
if leftEvent == rightEvent {
|
|
return durableKindOrder(records[i].record.Kind) < durableKindOrder(records[j].record.Kind)
|
|
}
|
|
return records[i].path < records[j].path
|
|
})
|
|
}
|
|
|
|
func durableKindOrder(kind string) int {
|
|
if kind == "raw" {
|
|
return 0
|
|
}
|
|
if kind == "unified" {
|
|
return 1
|
|
}
|
|
if kind == "fields" {
|
|
return 2
|
|
}
|
|
return 3
|
|
}
|
|
|
|
func (s *DurableSink) ReplayLoop(ctx context.Context, interval time.Duration, onError func(error)) {
|
|
if interval <= 0 {
|
|
interval = time.Second
|
|
}
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := s.replayOnceFromLoop(ctx); err != nil && onError != nil {
|
|
onError(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *DurableSink) replayOnceFromLoop(ctx context.Context) error {
|
|
replayCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), durableReplayOperationTimeout)
|
|
defer cancel()
|
|
return s.replay(replayCtx, s.replayBatchSize)
|
|
}
|
|
|
|
func (s *DurableSink) Close() error {
|
|
return s.delegate.Close()
|
|
}
|
|
|
|
func (s *DurableSink) publishRecord(ctx context.Context, record durableRecord) error {
|
|
switch record.Kind {
|
|
case "raw":
|
|
return s.delegate.PublishRaw(ctx, record.Envelope)
|
|
case "unified":
|
|
return s.delegate.PublishUnified(ctx, record.Envelope)
|
|
case "fields":
|
|
return s.delegate.PublishFields(ctx, record.Envelope)
|
|
default:
|
|
return errUnknownRecordKind(record.Kind)
|
|
}
|
|
}
|
|
|
|
func errUnknownRecordKind(kind string) error {
|
|
return fmt.Errorf("unknown durable record kind %q", kind)
|
|
}
|
|
|
|
func (s *DurableSink) spool(kind string, env envelope.FrameEnvelope) error {
|
|
if s.dir == "" {
|
|
return s.spoolError(kind, fmt.Errorf("durable spool directory is empty"))
|
|
}
|
|
if env.EventID == "" {
|
|
env.EventID = env.StableEventID()
|
|
}
|
|
if env.ParseStatus == "" {
|
|
env.ParseStatus = envelope.ParseOK
|
|
}
|
|
if err := os.MkdirAll(s.dir, 0o750); err != nil {
|
|
return s.spoolError(kind, err)
|
|
}
|
|
payload, err := json.Marshal(durableRecord{Kind: kind, Envelope: env})
|
|
if err != nil {
|
|
return s.spoolError(kind, err)
|
|
}
|
|
name := s.nextFileName(env, kind)
|
|
path := filepath.Join(s.dir, name)
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, payload, 0o640); err != nil {
|
|
return s.spoolError(kind, err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return s.spoolError(kind, err)
|
|
}
|
|
s.recordSpool(kind, "ok")
|
|
s.recordBacklogAfterReplay()
|
|
return nil
|
|
}
|
|
|
|
func (s *DurableSink) nextFileName(env envelope.FrameEnvelope, kind string) string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.seq++
|
|
eventID := env.StableEventID()
|
|
if len(eventID) > 12 {
|
|
eventID = eventID[:12]
|
|
}
|
|
return fmt.Sprintf("%020d-%06d-%s-%s.json", time.Now().UnixNano(), s.seq, kind, eventID)
|
|
}
|
|
|
|
func (s *DurableSink) markRawPending(env envelope.FrameEnvelope) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.rawPending[env.StableEventID()] = struct{}{}
|
|
}
|
|
|
|
func (s *DurableSink) clearRawPending(env envelope.FrameEnvelope) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.rawPending, env.StableEventID())
|
|
}
|
|
|
|
func (s *DurableSink) isRawPending(env envelope.FrameEnvelope) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
_, ok := s.rawPending[env.StableEventID()]
|
|
return ok
|
|
}
|
|
|
|
func readDurableRecord(path string) (durableRecord, error) {
|
|
var record durableRecord
|
|
payload, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return record, err
|
|
}
|
|
return record, json.Unmarshal(payload, &record)
|
|
}
|
|
|
|
func quarantineDurableFile(path string) error {
|
|
target := path + ".bad"
|
|
if _, err := os.Stat(target); err == nil {
|
|
target = fmt.Sprintf("%s.%d.bad", path, time.Now().UnixNano())
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return os.Rename(path, target)
|
|
}
|
|
|
|
func durableFiles(dir string, limit int) ([]string, error) {
|
|
if limit > 0 {
|
|
handle, err := os.Open(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
defer handle.Close()
|
|
return durableFilesFromReader(dir, limit, handle)
|
|
}
|
|
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Strings(files)
|
|
return files, nil
|
|
}
|
|
|
|
type durableNameReader interface {
|
|
Readdirnames(int) ([]string, error)
|
|
}
|
|
|
|
func durableFilesFromReader(dir string, limit int, reader durableNameReader) ([]string, error) {
|
|
if limit <= 0 {
|
|
return nil, nil
|
|
}
|
|
files := make([]string, 0, limit)
|
|
for len(files) < limit {
|
|
names, err := reader.Readdirnames(limit - len(files))
|
|
for _, name := range names {
|
|
if !strings.HasSuffix(name, ".json") {
|
|
continue
|
|
}
|
|
files = append(files, filepath.Join(dir, name))
|
|
if len(files) >= limit {
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
if errorsIsEOF(err) {
|
|
break
|
|
}
|
|
return nil, err
|
|
}
|
|
if len(names) == 0 {
|
|
break
|
|
}
|
|
}
|
|
sort.Strings(files)
|
|
return files, nil
|
|
}
|
|
|
|
func errorsIsEOF(err error) bool {
|
|
return err == io.EOF
|
|
}
|
|
|
|
func durableMetricName(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "default"
|
|
}
|
|
return name
|
|
}
|
|
|
|
func (s *DurableSink) spoolError(kind string, err error) error {
|
|
s.recordSpool(kind, "error")
|
|
return err
|
|
}
|
|
|
|
func (s *DurableSink) recordSpool(kind string, status string) {
|
|
if s == nil || s.metrics == nil {
|
|
return
|
|
}
|
|
s.metrics.IncCounter("vehicle_durable_spool_records_total", metrics.Labels{
|
|
"name": s.name,
|
|
"kind": kind,
|
|
"status": status,
|
|
})
|
|
}
|
|
|
|
func (s *DurableSink) recordReplay(status string, records int) {
|
|
if s == nil || s.metrics == nil {
|
|
return
|
|
}
|
|
labels := metrics.Labels{"name": s.name, "status": status}
|
|
s.metrics.IncCounter("vehicle_durable_spool_replay_total", labels)
|
|
if records > 0 {
|
|
s.metrics.AddCounter("vehicle_durable_spool_replay_records_total", metrics.Labels{
|
|
"name": s.name,
|
|
"kind": "all",
|
|
"status": status,
|
|
}, float64(records))
|
|
}
|
|
}
|
|
|
|
func (s *DurableSink) recordReplayRecords(records []durableRecordFile, status string) {
|
|
for _, item := range records {
|
|
s.recordReplayRecord(item.record, status)
|
|
}
|
|
}
|
|
|
|
func (s *DurableSink) recordReplayRecord(record durableRecord, status string) {
|
|
if s == nil || s.metrics == nil {
|
|
return
|
|
}
|
|
s.metrics.IncCounter("vehicle_durable_spool_replay_records_total", metrics.Labels{
|
|
"name": s.name,
|
|
"kind": record.Kind,
|
|
"status": status,
|
|
})
|
|
}
|
|
|
|
func (s *DurableSink) recordBacklogAfterReplay() {
|
|
if s == nil || s.metrics == nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(s.dir) == "" {
|
|
s.recordBacklog(0, 0)
|
|
return
|
|
}
|
|
files, oldestAge, err := durableBacklogStats(s.dir, time.Now())
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.recordBacklog(files, oldestAge)
|
|
}
|
|
|
|
func (s *DurableSink) recordBacklog(files int, oldestAge time.Duration) {
|
|
if s == nil || s.metrics == nil {
|
|
return
|
|
}
|
|
s.metrics.SetGauge("vehicle_durable_spool_backlog_files", metrics.Labels{"name": s.name}, float64(files))
|
|
ageSeconds := 0.0
|
|
if files > 0 && oldestAge > 0 {
|
|
ageSeconds = oldestAge.Seconds()
|
|
}
|
|
s.metrics.SetGauge("vehicle_durable_spool_oldest_age_seconds", metrics.Labels{"name": s.name}, ageSeconds)
|
|
}
|
|
|
|
func durableBacklogStats(dir string, now time.Time) (count int, oldestAge time.Duration, err error) {
|
|
files, err := durableFiles(dir, 0)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
for _, file := range files {
|
|
info, statErr := os.Stat(file)
|
|
if statErr != nil {
|
|
return 0, 0, statErr
|
|
}
|
|
age := now.Sub(info.ModTime())
|
|
if age < 0 {
|
|
age = 0
|
|
}
|
|
if count == 0 || age > oldestAge {
|
|
oldestAge = age
|
|
}
|
|
count++
|
|
}
|
|
return count, oldestAge, nil
|
|
}
|