499 lines
19 KiB
Go
499 lines
19 KiB
Go
package openplatform
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// GB32960 live freshness is an API policy, not an assertion about the device's
|
|
// negotiated reporting interval.
|
|
const realtimeHydrogenStaleSeconds int64 = 300
|
|
|
|
type RealtimeHydrogenData struct {
|
|
HydrogenPressureTemperatureSource *string `json:"hydrogenPressureTemperatureSource"`
|
|
RemainingHydrogenPercentSource *string `json:"remainingHydrogenPercentSource"`
|
|
HydrogenFullCapacityKg *float64 `json:"hydrogenFullCapacityKg"`
|
|
HydrogenTankCapacityL *float64 `json:"hydrogenTankCapacityL"`
|
|
HydrogenFullPressureMPa *float64 `json:"hydrogenFullPressureMPa"`
|
|
HydrogenReferenceTemperatureC *float64 `json:"hydrogenReferenceTemperatureC"`
|
|
HydrogenEstimatePressureMPa *float64 `json:"hydrogenEstimatePressureMPa"`
|
|
HydrogenEstimateTemperatureC *float64 `json:"hydrogenEstimateTemperatureC"`
|
|
HydrogenCalculationVersion *string `json:"hydrogenCalculationVersion"`
|
|
HydrogenCapacitySource *string `json:"hydrogenCapacitySource"`
|
|
HydrogenPercentReason *string `json:"hydrogenPercentReason"`
|
|
RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"`
|
|
RemainingHydrogenPercent *float64 `json:"remainingHydrogenPercent"`
|
|
HydrogenRecordTime *string `json:"hydrogenRecordTime"`
|
|
HydrogenDataStatus string `json:"hydrogenDataStatus"`
|
|
RemainingHydrogenKgStatus string `json:"remainingHydrogenKgStatus"`
|
|
RemainingHydrogenPercentStatus string `json:"remainingHydrogenPercentStatus"`
|
|
HydrogenValueSource *string `json:"hydrogenValueSource"`
|
|
HydrogenSourceProtocol *string `json:"hydrogenSourceProtocol"`
|
|
HydrogenStaleAfterSeconds *int64 `json:"hydrogenStaleAfterSeconds"`
|
|
HydrogenExpectedIntervalSeconds *int64 `json:"hydrogenExpectedIntervalSeconds"`
|
|
}
|
|
|
|
func missingRealtimeHydrogen(protocol string) RealtimeHydrogenData {
|
|
status := "UNSUPPORTED"
|
|
if protocol == "" || protocol == "GB32960" {
|
|
status = "MISSING"
|
|
}
|
|
result := RealtimeHydrogenData{HydrogenDataStatus: status, RemainingHydrogenKgStatus: status, RemainingHydrogenPercentStatus: status}
|
|
if protocol == "GB32960" {
|
|
threshold := realtimeHydrogenStaleSeconds
|
|
result.HydrogenStaleAfterSeconds = &threshold
|
|
result.HydrogenSourceProtocol = &protocol
|
|
}
|
|
return result
|
|
}
|
|
|
|
func applyRealtimeLiveData(item *RealtimeVehicleResult, point RealtimeVehiclePoint, location *time.Location) {
|
|
item.RealtimeHydrogenData = point.LiveHydrogen
|
|
if item.HydrogenDataStatus == "" {
|
|
item.RealtimeHydrogenData = missingRealtimeHydrogen(point.Protocol)
|
|
}
|
|
if point.GPSFixStatus != "" {
|
|
item.GPSFixStatus = point.GPSFixStatus
|
|
}
|
|
if point.CoordinateSystem != "" {
|
|
item.CoordinateSystem = point.CoordinateSystem
|
|
}
|
|
if !point.LocationObservedAt.IsZero() {
|
|
at := point.LocationObservedAt.In(location).Format(time.RFC3339Nano)
|
|
item.LocationRecordTime = &at
|
|
}
|
|
}
|
|
|
|
func realtimeHydrogenFromFrame(parsed string, observedAt, now time.Time) RealtimeHydrogenData {
|
|
result := missingRealtimeHydrogen("GB32960")
|
|
var fields map[string]any
|
|
if json.Unmarshal([]byte(parsed), &fields) != nil {
|
|
return result
|
|
}
|
|
for _, key := range hydrogenMassFields {
|
|
value, exists := fields[key]
|
|
if !exists {
|
|
continue
|
|
}
|
|
// A malformed or explicit protocol invalid value must never fall through to
|
|
// an alias retaining a different reading.
|
|
mass, valid := numericValue(value)
|
|
if !observedAt.IsZero() {
|
|
at := observedAt.In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format(time.RFC3339Nano)
|
|
result.HydrogenRecordTime = &at
|
|
}
|
|
if !valid || math.IsNaN(mass) || math.IsInf(mass, 0) || mass < 0 || mass > 200 || observedAt.IsZero() || observedAt.After(now.Add(time.Minute)) {
|
|
result.HydrogenDataStatus, result.RemainingHydrogenKgStatus = "INVALID", "INVALID"
|
|
return result
|
|
}
|
|
mass = round3(mass)
|
|
result.RemainingHydrogenKg = &mass
|
|
source := "REPORTED"
|
|
result.HydrogenValueSource = &source
|
|
result.HydrogenDataStatus, result.RemainingHydrogenKgStatus = "PARTIAL", "NORMAL"
|
|
if now.Sub(observedAt) > time.Duration(realtimeHydrogenStaleSeconds)*time.Second {
|
|
result.HydrogenDataStatus, result.RemainingHydrogenKgStatus = "STALE", "STALE"
|
|
}
|
|
return result
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GPS flags are read only from the exact raw frame which produced the chosen
|
|
// location. A snapshot's JSON is merge-patched and cannot prove field freshness.
|
|
func realtimeGPSFromFrame(protocol, parsed string) (string, string) {
|
|
fix, coordinate := "UNKNOWN", "UNKNOWN"
|
|
var fields map[string]any
|
|
if json.Unmarshal([]byte(parsed), &fields) != nil {
|
|
return fix, coordinate
|
|
}
|
|
switch protocol {
|
|
case "GB32960":
|
|
if flag, ok := numericValue(fields["gb32960.position.position_status"]); ok && flag >= 0 && flag <= 7 && math.Trunc(flag) == flag {
|
|
fix = "FIXED"
|
|
if int(flag)&1 != 0 {
|
|
fix = "NO_FIX"
|
|
}
|
|
}
|
|
if code, exists := fields["gb32960.position.coordinate_system"]; exists {
|
|
if value, ok := numericValue(code); ok {
|
|
switch value {
|
|
case 1:
|
|
coordinate = "WGS84"
|
|
case 2:
|
|
coordinate = "GCJ02"
|
|
}
|
|
}
|
|
}
|
|
case "JT808":
|
|
if flag, ok := numericValue(fields["jt808.location.status_flag"]); ok && flag >= 0 && flag <= math.MaxUint32 && math.Trunc(flag) == flag {
|
|
fix = "NO_FIX"
|
|
if uint32(flag)&2 != 0 {
|
|
fix = "FIXED"
|
|
}
|
|
}
|
|
}
|
|
return fix, coordinate
|
|
}
|
|
|
|
type realtimeFrameReference struct {
|
|
VIN, Protocol, EventID string
|
|
ReceivedAt time.Time
|
|
Hydrogen, Location bool
|
|
}
|
|
|
|
func realtimeReferenceKey(vin, protocol, eventID string) string {
|
|
return vin + "\x00" + protocol + "\x00" + eventID
|
|
}
|
|
|
|
// An optional history outage must not make previously available realtime fields
|
|
// disappear. A shared deadline bounds enrichment even for the 2,000 VIN batch.
|
|
func (r *MySQLRepository) enrichRealtimeLiveData(ctx context.Context, vins []string, points map[string]RealtimeVehiclePoint, now time.Time) error {
|
|
for vin, point := range points {
|
|
point.LiveHydrogen = missingRealtimeHydrogen("")
|
|
points[vin] = point
|
|
}
|
|
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
if err := r.loadRealtimeLiveData(bounded, vins, points, now); err != nil {
|
|
log.Printf("openplatform realtime enrichment unavailable: type=%T reason=%s", err, realtimeEnrichmentErrorReason(err))
|
|
// Keep successfully proven raw-frame values, and mark every unresolved
|
|
// field missing/unknown rather than classifying a storage error unsupported.
|
|
for vin, point := range points {
|
|
if point.LiveHydrogen.HydrogenRecordTime == nil {
|
|
point.LiveHydrogen = missingRealtimeHydrogen("")
|
|
}
|
|
points[vin] = point
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *MySQLRepository) loadRealtimeLiveData(ctx context.Context, vins []string, points map[string]RealtimeVehiclePoint, now time.Time) error {
|
|
for vin, point := range points {
|
|
point.LiveHydrogen = missingRealtimeHydrogen(point.Protocol)
|
|
points[vin] = point
|
|
}
|
|
// Repositories used without history retain compatible old fields and explicit
|
|
// missing statuses; merged MySQL JSON is never used as a fallback.
|
|
if r.tdengine == nil || r.tdDatabase == "" {
|
|
return fmt.Errorf("realtime history is not configured")
|
|
}
|
|
refs := map[string]realtimeFrameReference{}
|
|
for vin, point := range points {
|
|
if point.LocationEventID != "" && !point.LocationReceivedAt.IsZero() {
|
|
ref := realtimeFrameReference{VIN: vin, Protocol: point.Protocol, EventID: point.LocationEventID, ReceivedAt: point.LocationReceivedAt, Location: true}
|
|
refs[realtimeReferenceKey(vin, ref.Protocol, ref.EventID)] = ref
|
|
}
|
|
}
|
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
|
args := make([]any, len(vins))
|
|
for i, vin := range vins {
|
|
args[i] = vin
|
|
}
|
|
rows, err := r.db.QueryContext(ctx, `SELECT vin,protocol,event_id,received_at FROM vehicle_realtime_snapshot WHERE BINARY vin IN (`+placeholders+`) AND protocol='GB32960'`, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for rows.Next() {
|
|
var ref realtimeFrameReference
|
|
var received sql.NullTime
|
|
var eventID sql.NullString
|
|
if err := rows.Scan(&ref.VIN, &ref.Protocol, &eventID, &received); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
point, exists := points[ref.VIN]
|
|
if !exists {
|
|
continue
|
|
} // preserve existing realtime row selection contract
|
|
point.LiveHydrogen = missingRealtimeHydrogen("GB32960")
|
|
points[ref.VIN] = point
|
|
if !received.Valid || !eventID.Valid || eventID.String == "" {
|
|
continue
|
|
}
|
|
ref.EventID, ref.ReceivedAt, ref.Hydrogen = eventID.String, received.Time, true
|
|
key := realtimeReferenceKey(ref.VIN, ref.Protocol, ref.EventID)
|
|
if prior, ok := refs[key]; ok {
|
|
ref.Location = prior.Location
|
|
}
|
|
refs[key] = ref
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
rows.Close()
|
|
ordered := make([]realtimeFrameReference, 0, len(refs))
|
|
for _, ref := range refs {
|
|
ordered = append(ordered, ref)
|
|
}
|
|
|
|
// At most two references per requested VIN. Batches bound SQL size; primary
|
|
// timestamp and VIN tag filters prevent unbounded history scans.
|
|
frames, loadErr := r.loadRealtimeRawFrameBatches(ctx, ordered)
|
|
capacities, capacityErr := r.loadRealtimeHydrogenCapacities(ctx, vins)
|
|
if capacityErr != nil {
|
|
log.Printf("openplatform realtime hydrogen capacity unavailable: %s", realtimeEnrichmentErrorReason(capacityErr))
|
|
}
|
|
for _, frame := range frames {
|
|
ref, exists := refs[realtimeReferenceKey(frame.VIN, frame.Protocol, frame.EventID)]
|
|
if !exists {
|
|
continue
|
|
}
|
|
point, exists := points[frame.VIN]
|
|
if !exists {
|
|
continue
|
|
}
|
|
if ref.Hydrogen {
|
|
var at time.Time
|
|
if frame.EventMS.Valid && frame.EventMS.Int64 > 0 {
|
|
at = time.UnixMilli(frame.EventMS.Int64)
|
|
}
|
|
point.LiveHydrogen = realtimeHydrogenWithCapacity(frame.Parsed.String, at, now, capacities[frame.VIN])
|
|
}
|
|
if ref.Location {
|
|
point.GPSFixStatus, point.CoordinateSystem = realtimeGPSFromFrame(frame.Protocol, frame.Parsed.String)
|
|
}
|
|
points[frame.VIN] = point
|
|
}
|
|
if loadErr == nil && ctx.Err() == nil {
|
|
var missing []realtimeFrameReference
|
|
for _, ref := range refs {
|
|
if ref.Hydrogen && points[ref.VIN].LiveHydrogen.HydrogenDataStatus == "MISSING" {
|
|
missing = append(missing, ref)
|
|
}
|
|
}
|
|
fallback, err := r.loadRealtimeRawFrameBatchesWithQuery(ctx, missing, realtimeHydrogenFallbackQuery, 1)
|
|
applyRealtimeHydrogenFallback(points, missing, fallback, now, capacities)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return loadErr
|
|
}
|
|
|
|
// Only explicitly missing hydrogen can be filled. In particular, the newest
|
|
// invalid measurement must never be hidden by an older valid reading.
|
|
func applyRealtimeHydrogenFallback(points map[string]RealtimeVehiclePoint, refs []realtimeFrameReference, frames []realtimeRawFrame, now time.Time, capacityMaps ...map[string]float64) {
|
|
allowed := make(map[string]bool, len(refs))
|
|
for _, ref := range refs {
|
|
allowed[ref.VIN] = true
|
|
}
|
|
// SQL orders by event_time, not arrival time: delayed retransmissions must not
|
|
// displace newer measurements. Keep that rule explicit when processing rows.
|
|
sort.SliceStable(frames, func(i, j int) bool { return frames[i].EventMS.Int64 > frames[j].EventMS.Int64 })
|
|
for _, frame := range frames {
|
|
if !allowed[frame.VIN] || frame.Protocol != "GB32960" {
|
|
continue
|
|
}
|
|
point, exists := points[frame.VIN]
|
|
if !exists || point.LiveHydrogen.HydrogenDataStatus != "MISSING" {
|
|
continue
|
|
}
|
|
var at time.Time
|
|
if frame.EventMS.Valid && frame.EventMS.Int64 > 0 {
|
|
at = time.UnixMilli(frame.EventMS.Int64)
|
|
}
|
|
data := realtimeHydrogenFromFrame(frame.Parsed.String, at, now)
|
|
if len(capacityMaps) > 0 {
|
|
data = realtimeHydrogenWithCapacity(frame.Parsed.String, at, now, capacityMaps[0][frame.VIN])
|
|
}
|
|
if data.HydrogenDataStatus == "MISSING" {
|
|
continue
|
|
} // LIKE is a prefilter, never a JSON parser.
|
|
point.LiveHydrogen = data
|
|
points[frame.VIN] = point
|
|
}
|
|
}
|
|
|
|
type realtimeRawFrame struct {
|
|
VIN, Protocol, EventID string
|
|
EventMS sql.NullInt64
|
|
Parsed sql.NullString
|
|
}
|
|
|
|
type realtimeRawBatchResult struct {
|
|
frames []realtimeRawFrame
|
|
err error
|
|
}
|
|
|
|
// Four workers cap pressure on history storage while avoiding serial latency
|
|
// across a large authorized fleet. Only the caller writes the result map.
|
|
func (r *MySQLRepository) loadRealtimeRawFrameBatches(ctx context.Context, refs []realtimeFrameReference) ([]realtimeRawFrame, error) {
|
|
return r.loadRealtimeRawFrameBatchesWithQuery(ctx, refs, realtimeRawFrameQuery, 100)
|
|
}
|
|
|
|
func (r *MySQLRepository) loadRealtimeRawFrameBatchesWithQuery(ctx context.Context, refs []realtimeFrameReference, buildQuery func(string, []realtimeFrameReference) (string, error), maxBatchSize int) ([]realtimeRawFrame, error) {
|
|
batches := realtimeRawReferenceBatches(refs)
|
|
if maxBatchSize == 1 {
|
|
var singleVINBatches [][]realtimeFrameReference
|
|
for _, batch := range batches {
|
|
for i := range batch {
|
|
singleVINBatches = append(singleVINBatches, batch[i:i+1])
|
|
}
|
|
}
|
|
batches = singleVINBatches
|
|
}
|
|
batchCount := len(batches)
|
|
if batchCount == 0 {
|
|
return nil, nil
|
|
}
|
|
jobs := make(chan string, batchCount)
|
|
results := make(chan realtimeRawBatchResult, batchCount)
|
|
for _, batch := range batches {
|
|
query, err := buildQuery(r.tdDatabase, batch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
jobs <- query
|
|
}
|
|
close(jobs)
|
|
workers := 4
|
|
if batchCount < workers {
|
|
workers = batchCount
|
|
}
|
|
for worker := 0; worker < workers; worker++ {
|
|
go func() {
|
|
for query := range jobs {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
frames, err := r.loadRealtimeRawFrameBatch(ctx, query)
|
|
results <- realtimeRawBatchResult{frames: frames, err: err}
|
|
}
|
|
}()
|
|
}
|
|
var frames []realtimeRawFrame
|
|
var firstErr error
|
|
for batch := 0; batch < batchCount; batch++ {
|
|
var result realtimeRawBatchResult
|
|
select {
|
|
case result = <-results:
|
|
case <-ctx.Done():
|
|
return frames, ctx.Err()
|
|
}
|
|
if firstErr == nil && result.err != nil {
|
|
firstErr = result.err
|
|
}
|
|
frames = append(frames, result.frames...)
|
|
}
|
|
return frames, firstErr
|
|
}
|
|
|
|
func (r *MySQLRepository) loadRealtimeRawFrameBatch(ctx context.Context, query string) ([]realtimeRawFrame, error) {
|
|
rows, err := r.tdengine.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var frames []realtimeRawFrame
|
|
for rows.Next() {
|
|
var frame realtimeRawFrame
|
|
if err := rows.Scan(&frame.VIN, &frame.Protocol, &frame.EventID, &frame.EventMS, &frame.Parsed); err != nil {
|
|
return frames, err
|
|
}
|
|
frames = append(frames, frame)
|
|
}
|
|
return frames, rows.Err()
|
|
}
|
|
|
|
// Keep historical outliers away from current data: one query never spans more
|
|
// than five minutes of arrival time, even when a fleet includes months-old rows.
|
|
// Newest batches run first so the shared deadline favors currently active cars.
|
|
func realtimeRawReferenceBatches(refs []realtimeFrameReference) [][]realtimeFrameReference {
|
|
ordered := append([]realtimeFrameReference(nil), refs...)
|
|
sort.Slice(ordered, func(i, j int) bool { return ordered[i].ReceivedAt.After(ordered[j].ReceivedAt) })
|
|
var batches [][]realtimeFrameReference
|
|
for start := 0; start < len(ordered); {
|
|
end := start + 1
|
|
for end < len(ordered) && end-start < 100 && ordered[start].ReceivedAt.Sub(ordered[end].ReceivedAt) <= 5*time.Minute {
|
|
end++
|
|
}
|
|
batches = append(batches, ordered[start:end])
|
|
start = end
|
|
}
|
|
return batches
|
|
}
|
|
|
|
func realtimeRawFrameQuery(database string, refs []realtimeFrameReference) (string, error) {
|
|
if !validTDIdentifier(database) || len(refs) == 0 || len(refs) > 100 {
|
|
return "", fmt.Errorf("invalid realtime raw-frame query")
|
|
}
|
|
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
|
clauses, vins, timestamps := make([]string, 0, len(refs)), make([]string, 0, len(refs)), make([]string, 0, len(refs))
|
|
min, max := refs[0].ReceivedAt.UnixMilli(), refs[0].ReceivedAt.UnixMilli()
|
|
for _, ref := range refs {
|
|
at := ref.ReceivedAt.UnixMilli()
|
|
if at < min {
|
|
min = at
|
|
}
|
|
if at > max {
|
|
max = at
|
|
}
|
|
vins = append(vins, quote(ref.VIN))
|
|
timestamps = append(timestamps, strconv.FormatInt(at, 10))
|
|
clauses = append(clauses, "(ts="+strconv.FormatInt(at, 10)+" AND vin="+quote(ref.VIN)+" AND protocol="+quote(ref.Protocol)+" AND event_id="+quote(ref.EventID)+")")
|
|
}
|
|
return `SELECT vin,protocol,event_id,CAST(event_time AS BIGINT),parsed_json FROM ` + database + `.raw_frames WHERE ts>=` + strconv.FormatInt(min, 10) + ` AND ts<=` + strconv.FormatInt(max, 10) + ` AND ts IN (` + strings.Join(timestamps, ",") + `) AND vin IN (` + strings.Join(vins, ",") + `) AND parse_status='OK' AND (` + strings.Join(clauses, " OR ") + `) LIMIT ` + strconv.Itoa(len(refs)*2), nil
|
|
}
|
|
|
|
// Search only five minutes before one snapshot arrival. TDengine applies LIMIT
|
|
// globally even with PARTITION BY, so each fallback query must contain one VIN.
|
|
// The shared worker pool still bounds concurrency to four and uses one deadline.
|
|
func realtimeHydrogenFallbackQuery(database string, refs []realtimeFrameReference) (string, error) {
|
|
if !validTDIdentifier(database) || len(refs) != 1 {
|
|
return "", fmt.Errorf("invalid realtime hydrogen fallback query")
|
|
}
|
|
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
|
min, max := refs[0].ReceivedAt.Add(-5*time.Minute).UnixMilli(), refs[0].ReceivedAt.UnixMilli()
|
|
var clauses, vins, fields []string
|
|
for _, ref := range refs {
|
|
start, end := ref.ReceivedAt.Add(-5*time.Minute).UnixMilli(), ref.ReceivedAt.UnixMilli()
|
|
if start < min {
|
|
min = start
|
|
}
|
|
if end > max {
|
|
max = end
|
|
}
|
|
vins = append(vins, quote(ref.VIN))
|
|
clauses = append(clauses, "(vin="+quote(ref.VIN)+" AND ts>="+strconv.FormatInt(start, 10)+" AND ts<="+strconv.FormatInt(end, 10)+")")
|
|
}
|
|
for _, key := range append(append([]string{}, hydrogenMassFields...), realtimeHydrogenPressureField, realtimeHydrogenTemperatureField) {
|
|
fields = append(fields, "parsed_json LIKE "+quote("%\""+key+"\":%"))
|
|
}
|
|
return `SELECT vin,protocol,event_id,CAST(event_time AS BIGINT),parsed_json FROM ` + database + `.raw_frames WHERE protocol='GB32960' AND ts>=` + strconv.FormatInt(min, 10) + ` AND ts<=` + strconv.FormatInt(max, 10) + ` AND vin IN (` + strings.Join(vins, ",") + `) AND parse_status='OK' AND (` + strings.Join(clauses, " OR ") + `) AND (` + strings.Join(fields, " OR ") + `) ORDER BY event_time DESC,ts DESC LIMIT 5`, nil
|
|
}
|
|
|
|
var realtimeErrorQuotedText = regexp.MustCompile(`'[^']*'|"[^"]*"`)
|
|
var realtimeErrorVIN = regexp.MustCompile(`\b[A-Z0-9]{12,32}\b`)
|
|
|
|
// Driver errors sometimes append the whole query. Keep the failure reason for
|
|
// operations without writing fleet identifiers or SQL literals to the log.
|
|
func realtimeEnrichmentErrorReason(err error) string {
|
|
reason := err.Error()
|
|
upper := strings.ToUpper(reason)
|
|
for _, keyword := range []string{"SELECT ", "INSERT ", "UPDATE ", "DELETE "} {
|
|
if index := strings.Index(upper, keyword); index >= 0 {
|
|
reason = reason[:index] + "[SQL omitted]"
|
|
break
|
|
}
|
|
}
|
|
reason = realtimeErrorQuotedText.ReplaceAllString(reason, "[quoted value omitted]")
|
|
reason = realtimeErrorVIN.ReplaceAllString(reason, "[VIN omitted]")
|
|
reason = strings.Join(strings.Fields(reason), " ")
|
|
if len(reason) > 300 {
|
|
reason = reason[:300] + "..."
|
|
}
|
|
return reason
|
|
}
|