package main import ( "context" "database/sql" "errors" "fmt" "log/slog" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) var errNoUsableSourceBoundary = errors.New("source has no usable mileage boundary") // FIRST/LAST are ordered by TDengine's storage timestamp, which represents // receipt order. Keep the fast aggregate path, but independently verify both // ends against extrema of the vehicle event timestamp and fetch the matching // complete row whenever late delivery has moved either end. type sourceBoundaryEvidence struct { vin, phone, deviceID, endpoint string firstTS, lastTS time.Time firstParsed, firstRaw string lastParsed, lastRaw string count, minimumMS, maximumMS int64 } func correctSourceEventBoundaries(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, where string, includeFirst bool, e *sourceBoundaryEvidence) error { _, firstUsable := mileageFromEvidence(protocol, e.vin, e.firstParsed, e.firstRaw, e.firstTS, cfg.Location) _, lastUsable := mileageFromEvidence(protocol, e.vin, e.lastParsed, e.lastRaw, e.lastTS, cfg.Location) if e.lastTS.UnixMilli() != e.maximumMS || !lastUsable { stamp, parsed, raw, err := querySourceEventBoundary(ctx, db, cfg, protocol, where, *e, e.maximumMS, false) if err != nil { if errors.Is(err, sql.ErrNoRows) && !firstUsable && !lastUsable { return errNoUsableSourceBoundary } return err } slog.Info("corrected last source event boundary", "vin", e.vin, "endpoint", e.endpoint, "storage_last", e.lastTS, "event_last", stamp) e.lastTS, e.lastParsed, e.lastRaw = stamp, parsed, raw } if !includeFirst { e.firstTS, e.firstParsed, e.firstRaw = e.lastTS, e.lastParsed, e.lastRaw return nil } if e.firstTS.UnixMilli() != e.minimumMS || !firstUsable { stamp, parsed, raw, err := querySourceEventBoundary(ctx, db, cfg, protocol, where, *e, e.minimumMS, true) if err != nil { return err } slog.Info("corrected first source event boundary", "vin", e.vin, "endpoint", e.endpoint, "storage_first", e.firstTS, "event_first", stamp) e.firstTS, e.firstParsed, e.firstRaw = stamp, parsed, raw } return nil } func querySourceEventBoundary(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, where string, e sourceBoundaryEvidence, eventMS int64, first bool) (time.Time, string, string, error) { // An equal event timestamp is resolved consistently by newest storage ts. // Never obtain the timestamp and payload through independent aggregates. comparison, order := "<=", "DESC" if first { comparison, order = ">=", "ASC" } query := fmt.Sprintf(`SELECT event_time, parsed_json, raw_text FROM %s.raw_frames WHERE %s AND vin = '%s' AND phone = '%s' AND device_id = '%s' AND source_endpoint = '%s' AND CAST(event_time AS BIGINT) %s %d ORDER BY event_time %s, ts DESC`, ident(cfg.TDengineDatabase), where, quote(e.vin), quote(e.phone), quote(e.deviceID), quote(e.endpoint), comparison, eventMS, order) rows, err := db.QueryContext(ctx, query) if err != nil { return time.Time{}, "", "", fmt.Errorf("resolve event-time boundary %s/%s/%d: %w", e.vin, e.endpoint, eventMS, err) } defer rows.Close() for rows.Next() { var stamp time.Time var parsed, raw string if err := rows.Scan(&stamp, &parsed, &raw); err != nil { return time.Time{}, "", "", err } if _, ok := mileageFromEvidence(protocol, e.vin, parsed, raw, stamp, cfg.Location); ok { return stamp, parsed, raw, nil } } if err := rows.Err(); err != nil { return time.Time{}, "", "", err } return time.Time{}, "", "", sql.ErrNoRows }