470 lines
13 KiB
Go
470 lines
13 KiB
Go
package feichibridge
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
|
)
|
|
|
|
type Source interface {
|
|
Vehicles(context.Context) ([]Vehicle, error)
|
|
Snapshot(context.Context, string) (Snapshot, error)
|
|
History(context.Context, string, time.Time, time.Time) ([]Record, error)
|
|
}
|
|
|
|
type FrameTarget interface {
|
|
Connect(context.Context) error
|
|
Send(context.Context, []byte) error
|
|
Close() error
|
|
LastACK() time.Time
|
|
}
|
|
|
|
type ServiceConfig struct {
|
|
PollInterval time.Duration
|
|
DiscoveryInterval time.Duration
|
|
BackfillInterval time.Duration
|
|
BackfillLookback time.Duration
|
|
BackfillWindow time.Duration
|
|
BackfillSafetyLag time.Duration
|
|
SourceStaleAfter time.Duration
|
|
FetchConcurrency int
|
|
BackfillEnabled bool
|
|
StaleReissueEnabled bool
|
|
}
|
|
|
|
type Service struct {
|
|
config ServiceConfig
|
|
source Source
|
|
target FrameTarget
|
|
state *StateStore
|
|
encoder Encoder
|
|
logger *slog.Logger
|
|
metrics *metrics.Registry
|
|
|
|
mu sync.RWMutex
|
|
vehicles []Vehicle
|
|
lastSourceSuccess time.Time
|
|
lastError error
|
|
}
|
|
|
|
func NewService(config ServiceConfig, source Source, target FrameTarget, state *StateStore, logger *slog.Logger, registry *metrics.Registry) (*Service, error) {
|
|
if source == nil || target == nil || state == nil {
|
|
return nil, errors.New("source, target, and state are required")
|
|
}
|
|
if config.PollInterval <= 0 {
|
|
config.PollInterval = 10 * time.Second
|
|
}
|
|
if config.DiscoveryInterval <= 0 {
|
|
config.DiscoveryInterval = 5 * time.Minute
|
|
}
|
|
if config.BackfillInterval <= 0 {
|
|
config.BackfillInterval = time.Hour
|
|
}
|
|
if config.BackfillLookback <= 0 {
|
|
config.BackfillLookback = time.Hour
|
|
}
|
|
if config.BackfillWindow <= 0 {
|
|
config.BackfillWindow = 20 * time.Minute
|
|
}
|
|
if config.BackfillSafetyLag <= 0 {
|
|
config.BackfillSafetyLag = 30 * time.Second
|
|
}
|
|
if config.SourceStaleAfter <= 0 {
|
|
config.SourceStaleAfter = 2 * time.Minute
|
|
}
|
|
if config.FetchConcurrency <= 0 {
|
|
config.FetchConcurrency = 4
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Service{
|
|
config: config, source: source, target: target, state: state,
|
|
logger: logger, metrics: registry,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Run(ctx context.Context) error {
|
|
if err := s.target.Connect(ctx); err != nil {
|
|
s.setError(err)
|
|
return fmt.Errorf("connect GB/T 32960 target: %w", err)
|
|
}
|
|
if err := s.discover(ctx); err != nil {
|
|
s.setError(err)
|
|
return fmt.Errorf("initial vehicle discovery: %w", err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
s.discoveryLoop(ctx)
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
s.realtimeLoop(ctx)
|
|
}()
|
|
if s.config.BackfillEnabled {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
s.backfillLoop(ctx)
|
|
}()
|
|
}
|
|
<-ctx.Done()
|
|
_ = s.target.Close()
|
|
wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) Ready(context.Context) error {
|
|
s.mu.RLock()
|
|
lastSourceSuccess := s.lastSourceSuccess
|
|
lastErr := s.lastError
|
|
s.mu.RUnlock()
|
|
maxSourceAge := max(3*s.config.PollInterval, time.Minute)
|
|
if lastSourceSuccess.IsZero() || time.Since(lastSourceSuccess) > maxSourceAge {
|
|
if lastErr != nil {
|
|
return fmt.Errorf("source unavailable: %w", lastErr)
|
|
}
|
|
return errors.New("source has not completed a successful request")
|
|
}
|
|
lastACK := s.target.LastACK()
|
|
if lastACK.IsZero() || time.Since(lastACK) > max(3*s.config.DiscoveryInterval, 10*time.Minute) {
|
|
return errors.New("GB/T 32960 target has no recent ACK")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) discoveryLoop(ctx context.Context) {
|
|
ticker := time.NewTicker(s.config.DiscoveryInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := s.discover(ctx); err != nil {
|
|
s.recordFailure("discover", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) realtimeLoop(ctx context.Context) {
|
|
s.pollRealtime(ctx)
|
|
ticker := time.NewTicker(s.config.PollInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.pollRealtime(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) backfillLoop(ctx context.Context) {
|
|
s.runBackfill(ctx)
|
|
ticker := time.NewTicker(s.config.BackfillInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.runBackfill(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) discover(ctx context.Context) error {
|
|
start := time.Now()
|
|
vehicles, err := s.source.Vehicles(ctx)
|
|
s.observeAPI("vehicles", start, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
filtered := make([]Vehicle, 0, len(vehicles))
|
|
for _, vehicle := range vehicles {
|
|
vehicle.VIN = strings.TrimSpace(vehicle.VIN)
|
|
vehicle.VehicleID = strings.TrimSpace(vehicle.VehicleID)
|
|
if len(vehicle.VIN) != 17 || vehicle.VehicleID == "" {
|
|
continue
|
|
}
|
|
if vehicle.RuleTypeName != "" && !strings.Contains(strings.ToUpper(vehicle.RuleTypeName), "32960") {
|
|
continue
|
|
}
|
|
filtered = append(filtered, vehicle)
|
|
}
|
|
sort.Slice(filtered, func(i, j int) bool { return filtered[i].VIN < filtered[j].VIN })
|
|
s.mu.Lock()
|
|
s.vehicles = filtered
|
|
s.lastSourceSuccess = time.Now()
|
|
s.lastError = nil
|
|
s.mu.Unlock()
|
|
if s.metrics != nil {
|
|
s.metrics.SetGauge("vehicle_feichi_bridge_vehicles", nil, float64(len(filtered)))
|
|
}
|
|
s.logger.Info("feichi vehicles discovered", "count", len(filtered))
|
|
return nil
|
|
}
|
|
|
|
type fetchedSnapshot struct {
|
|
vehicle Vehicle
|
|
record Record
|
|
at time.Time
|
|
hash string
|
|
err error
|
|
duration time.Duration
|
|
}
|
|
|
|
func (s *Service) pollRealtime(ctx context.Context) {
|
|
vehicles := s.vehicleSnapshot()
|
|
jobs := make(chan Vehicle)
|
|
results := make(chan fetchedSnapshot, len(vehicles))
|
|
var wg sync.WaitGroup
|
|
for worker := 0; worker < min(s.config.FetchConcurrency, len(vehicles)); worker++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for vehicle := range jobs {
|
|
start := time.Now()
|
|
snapshot, err := s.source.Snapshot(ctx, vehicle.VehicleID)
|
|
result := fetchedSnapshot{vehicle: vehicle, duration: time.Since(start), err: err}
|
|
if err == nil {
|
|
result.record = Record(snapshot.DataItems)
|
|
result.at, result.err = recordTime(result.record)
|
|
result.hash = canonicalHash(result.record)
|
|
}
|
|
results <- result
|
|
}
|
|
}()
|
|
}
|
|
for _, vehicle := range vehicles {
|
|
jobs <- vehicle
|
|
}
|
|
close(jobs)
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
var snapshots []fetchedSnapshot
|
|
for result := range results {
|
|
s.observeAPIWithDuration("snapshot", result.duration, result.err)
|
|
if result.err != nil {
|
|
s.recordFailure("snapshot", fmt.Errorf("VIN %s: %w", result.vehicle.VIN, result.err))
|
|
continue
|
|
}
|
|
snapshots = append(snapshots, result)
|
|
s.markSourceSuccess()
|
|
}
|
|
sort.Slice(snapshots, func(i, j int) bool {
|
|
if snapshots[i].at.Equal(snapshots[j].at) {
|
|
return snapshots[i].vehicle.VIN < snapshots[j].vehicle.VIN
|
|
}
|
|
return snapshots[i].at.Before(snapshots[j].at)
|
|
})
|
|
for _, snapshot := range snapshots {
|
|
if time.Since(snapshot.at) > s.config.SourceStaleAfter {
|
|
if s.config.StaleReissueEnabled {
|
|
s.reissueStaleSnapshot(ctx, snapshot)
|
|
}
|
|
continue
|
|
}
|
|
current := s.state.Vehicle(snapshot.vehicle.VIN)
|
|
if snapshot.at.Before(current.LastRealtimeTime) ||
|
|
(snapshot.at.Equal(current.LastRealtimeTime) && snapshot.hash == current.LastRealtimeHash) {
|
|
continue
|
|
}
|
|
frame, err := s.encoder.DataFrame(CommandRealtime, snapshot.vehicle.VIN, snapshot.at, snapshot.record)
|
|
if err != nil {
|
|
s.recordFailure("encode", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
|
|
continue
|
|
}
|
|
if err := s.target.Send(ctx, frame); err != nil {
|
|
s.recordFrame(CommandRealtime, "error", snapshot.vehicle.VIN, snapshot.at)
|
|
s.recordFailure("send", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
|
|
continue
|
|
}
|
|
if err := s.state.CommitRealtime(snapshot.vehicle.VIN, snapshot.at, snapshot.hash); err != nil {
|
|
s.recordFailure("state", err)
|
|
continue
|
|
}
|
|
s.recordFrame(CommandRealtime, "acked", snapshot.vehicle.VIN, snapshot.at)
|
|
}
|
|
}
|
|
|
|
func (s *Service) reissueStaleSnapshot(ctx context.Context, snapshot fetchedSnapshot) {
|
|
current := s.state.Vehicle(snapshot.vehicle.VIN)
|
|
if snapshot.at.Before(current.LastSnapshotReissueTime) ||
|
|
(snapshot.at.Equal(current.LastSnapshotReissueTime) && snapshot.hash == current.LastSnapshotReissueHash) {
|
|
return
|
|
}
|
|
frame, err := s.encoder.DataFrame(CommandReissue, snapshot.vehicle.VIN, snapshot.at, snapshot.record)
|
|
if err != nil {
|
|
s.recordFailure("encode_stale_reissue", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
|
|
return
|
|
}
|
|
if err := s.target.Send(ctx, frame); err != nil {
|
|
s.recordFrame(CommandReissue, "error", snapshot.vehicle.VIN, snapshot.at)
|
|
s.recordFailure("send_stale_reissue", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
|
|
return
|
|
}
|
|
if err := s.state.CommitSnapshotReissue(snapshot.vehicle.VIN, snapshot.at, snapshot.hash); err != nil {
|
|
s.recordFailure("state", err)
|
|
return
|
|
}
|
|
s.recordFrame(CommandReissue, "acked", snapshot.vehicle.VIN, snapshot.at)
|
|
}
|
|
|
|
func (s *Service) runBackfill(ctx context.Context) {
|
|
for _, vehicle := range s.vehicleSnapshot() {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if err := s.backfillVehicle(ctx, vehicle); err != nil {
|
|
s.recordFailure("backfill", fmt.Errorf("VIN %s: %w", vehicle.VIN, err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) backfillVehicle(ctx context.Context, vehicle Vehicle) error {
|
|
end := time.Now().Add(-s.config.BackfillSafetyLag)
|
|
cursor := s.state.Vehicle(vehicle.VIN).BackfillCursor
|
|
if cursor.IsZero() {
|
|
cursor = end.Add(-s.config.BackfillLookback)
|
|
}
|
|
for cursor.Before(end) {
|
|
windowEnd := cursor.Add(s.config.BackfillWindow)
|
|
if windowEnd.After(end) {
|
|
windowEnd = end
|
|
}
|
|
start := time.Now()
|
|
records, err := s.source.History(ctx, vehicle.VIN, cursor, windowEnd)
|
|
s.observeAPI("history", start, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sort.Slice(records, func(i, j int) bool {
|
|
left, _ := recordTime(records[i])
|
|
right, _ := recordTime(records[j])
|
|
return left.Before(right)
|
|
})
|
|
committed := cursor
|
|
for _, record := range records {
|
|
at, err := recordTime(record)
|
|
if err != nil || !at.After(cursor) || at.After(windowEnd) {
|
|
continue
|
|
}
|
|
frame, err := s.encoder.DataFrame(CommandReissue, vehicle.VIN, at, record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.target.Send(ctx, frame); err != nil {
|
|
s.recordFrame(CommandReissue, "error", vehicle.VIN, at)
|
|
return err
|
|
}
|
|
if err := s.state.CommitBackfill(vehicle.VIN, at); err != nil {
|
|
return err
|
|
}
|
|
s.recordFrame(CommandReissue, "acked", vehicle.VIN, at)
|
|
committed = at
|
|
}
|
|
if !committed.After(cursor) || committed.Before(windowEnd) {
|
|
if err := s.state.AdvanceBackfillCursor(vehicle.VIN, windowEnd); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
cursor = windowEnd
|
|
s.markSourceSuccess()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) vehicleSnapshot() []Vehicle {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return append([]Vehicle(nil), s.vehicles...)
|
|
}
|
|
|
|
func (s *Service) markSourceSuccess() {
|
|
s.mu.Lock()
|
|
s.lastSourceSuccess = time.Now()
|
|
s.lastError = nil
|
|
s.mu.Unlock()
|
|
if s.metrics != nil {
|
|
metrics.RecordLastActivity(s.metrics, "vehicle_feichi_bridge_source_last_success_unix_seconds", nil)
|
|
}
|
|
}
|
|
|
|
func (s *Service) setError(err error) {
|
|
s.mu.Lock()
|
|
s.lastError = err
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *Service) recordFailure(operation string, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
s.setError(err)
|
|
if s.metrics != nil {
|
|
s.metrics.IncCounter("vehicle_feichi_bridge_errors_total", metrics.Labels{"operation": operation})
|
|
}
|
|
s.logger.Error("feichi bridge operation failed", "operation", operation, "error", err)
|
|
}
|
|
|
|
func (s *Service) observeAPI(operation string, start time.Time, err error) {
|
|
s.observeAPIWithDuration(operation, time.Since(start), err)
|
|
}
|
|
|
|
func (s *Service) observeAPIWithDuration(operation string, duration time.Duration, err error) {
|
|
if s.metrics == nil {
|
|
return
|
|
}
|
|
status := "ok"
|
|
if err != nil {
|
|
status = "error"
|
|
}
|
|
s.metrics.IncCounter("vehicle_feichi_bridge_api_requests_total", metrics.Labels{"operation": operation, "status": status})
|
|
s.metrics.ObserveHistogram(
|
|
"vehicle_feichi_bridge_api_request_duration_seconds",
|
|
metrics.Labels{"operation": operation},
|
|
[]float64{0.1, 0.25, 0.5, 1, 2, 5},
|
|
duration.Seconds(),
|
|
)
|
|
}
|
|
|
|
func (s *Service) recordFrame(command byte, status, vin string, sourceTime time.Time) {
|
|
if s.metrics != nil {
|
|
s.metrics.IncCounter("vehicle_feichi_bridge_frames_total", metrics.Labels{
|
|
"command": fmt.Sprintf("0x%02X", command),
|
|
"status": status,
|
|
})
|
|
}
|
|
if status == "acked" {
|
|
if s.metrics != nil {
|
|
metrics.RecordLastActivity(s.metrics, "vehicle_feichi_bridge_target_last_ack_unix_seconds", nil)
|
|
s.metrics.SetGauge(
|
|
"vehicle_feichi_bridge_vehicle_last_ack_unix_seconds",
|
|
metrics.Labels{"vin": vin, "command": fmt.Sprintf("0x%02X", command)},
|
|
float64(time.Now().Unix()),
|
|
)
|
|
}
|
|
s.logger.Info(
|
|
"GB/T 32960 vehicle frame acknowledged",
|
|
"vin", vin,
|
|
"command", fmt.Sprintf("0x%02X", command),
|
|
"source_time", sourceTime,
|
|
)
|
|
}
|
|
}
|