package companion import ( "context" "fmt" "strings" "sync" "time" ) type TrajectorySource interface { ReadPositionRows(context.Context, int) ([]map[string]any, error) ReadVehicleRows(context.Context, int) ([]map[string]any, error) } type TrajectoryStore interface { EnsureSchema(context.Context) error StorePositionRows(context.Context, string, []map[string]any, time.Time) (int, error) StoreVehicleRows(context.Context, string, []map[string]any, time.Time) (int, error) } type TrajectoryCollectionReport struct { CollectedAt time.Time PositionRows int VehicleRows int StoredSamples int Status string Reason string } type TrajectoryCollector struct { Source TrajectorySource Store TrajectoryStore ServerInstanceID string Interval time.Duration MaxRows int Now func() time.Time schemaOnce sync.Once schemaErr error } func NewTrajectoryCollector(config Config, source TrajectorySource, store TrajectoryStore) *TrajectoryCollector { return &TrajectoryCollector{ Source: source, Store: store, ServerInstanceID: config.Component.ServerInstanceID, Interval: time.Duration(config.Trajectory.IntervalSeconds) * time.Second, MaxRows: config.Trajectory.MaxRows, } } func (collector *TrajectoryCollector) CollectOnce(ctx context.Context) (TrajectoryCollectionReport, error) { if collector == nil || collector.Source == nil || collector.Store == nil || strings.TrimSpace(collector.ServerInstanceID) == "" { return TrajectoryCollectionReport{}, fmt.Errorf("SCUM trajectory collector is not configured") } collector.schemaOnce.Do(func() { collector.schemaErr = collector.Store.EnsureSchema(ctx) }) if collector.schemaErr != nil { return TrajectoryCollectionReport{}, collector.schemaErr } sampledAt := collector.clock()().UTC() report := TrajectoryCollectionReport{CollectedAt: sampledAt, Status: "healthy"} positions, err := collector.Source.ReadPositionRows(ctx, collector.MaxRows) if err != nil { report.Status, report.Reason = "degraded", "position collection failed" return report, err } report.PositionRows = len(positions) written, err := collector.Store.StorePositionRows(ctx, collector.ServerInstanceID, positions, sampledAt) if err != nil { report.Status, report.Reason = "degraded", "position storage failed" return report, err } report.StoredSamples += written vehicles, err := collector.Source.ReadVehicleRows(ctx, collector.MaxRows) if err != nil { report.Status, report.Reason = "degraded", "vehicle collection failed" return report, err } report.VehicleRows = len(vehicles) written, err = collector.Store.StoreVehicleRows(ctx, collector.ServerInstanceID, vehicles, sampledAt) if err != nil { report.Status, report.Reason = "degraded", "vehicle storage failed" return report, err } report.StoredSamples += written report.Reason = "raw world coordinates stored" return report, nil } func (collector *TrajectoryCollector) Run(ctx context.Context, status *TrajectoryCollectionStatus) error { interval := collector.Interval if interval < time.Second { interval = time.Duration(DefaultTrajectoryCollectionIntervalSecs) * time.Second } if report, err := collector.CollectOnce(ctx); status != nil { status.Record(report, err) } else if err != nil { return err } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: report, err := collector.CollectOnce(ctx) if status != nil { status.Record(report, err) continue } if err != nil { return err } } } } func (collector *TrajectoryCollector) clock() func() time.Time { if collector.Now != nil { return collector.Now } return time.Now } type TrajectoryCollectionStatus struct { mu sync.Mutex latest TrajectoryCollectionReport err error } func (status *TrajectoryCollectionStatus) Record(report TrajectoryCollectionReport, err error) { if status == nil { return } status.mu.Lock() defer status.mu.Unlock() status.latest = report status.err = err } func (status *TrajectoryCollectionStatus) HealthReport() HealthReport { if status == nil { return HealthReport{Status: "healthy", Reason: "typed companion dispatcher ready"} } status.mu.Lock() defer status.mu.Unlock() if status.latest.Status == "healthy" && status.err == nil { return HealthReport{Status: "healthy", Reason: safeHealthReason(status.latest.Reason, "typed companion dispatcher ready")} } if !status.latest.CollectedAt.IsZero() && status.err == nil { return HealthReport{Status: "healthy", Reason: "raw world coordinate collection ready"} } if status.err != nil { return HealthReport{Status: "degraded", Reason: safeHealthReason(status.latest.Reason, "trajectory collection waiting for source data")} } return HealthReport{Status: "degraded", Reason: "trajectory collection waiting for first sample"} } func safeHealthReason(value string, fallback string) string { value = strings.TrimSpace(value) if value == "" { return fallback } return value }