Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/history/writer.go

665 lines
18 KiB
Go

package history
import (
"context"
"crypto/sha1"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
type Execer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
type Writer struct {
exec Execer
database string
cache tableCache
}
type AppendResult struct {
RawRows int
LocationRows int
LocationError error
}
const (
LocationStatusOK = "ok"
LocationStatusSkippedNonRealtime = "skipped_non_realtime"
LocationStatusSkippedMissingVIN = "skipped_missing_vin"
LocationStatusSkippedMissingCoordinates = "skipped_missing_coordinates"
)
const (
rawFramePayloadInlineLimit = 12_000
rawFramePayloadChunkSize = 16_000
tdengineInsertSoftLimit = 6 * 1024 * 1024
)
type payloadChunk struct {
Kind string
Index int
Count int
Text string
}
type tableCache struct {
mu sync.Mutex
seen map[string]struct{}
inflight map[string]*tableEnsure
}
type tableEnsure struct {
done chan struct{}
err error
}
func (c *tableCache) doOnce(key string, fn func() error) error {
c.mu.Lock()
if _, ok := c.seen[key]; ok {
c.mu.Unlock()
return nil
}
if c.inflight == nil {
c.inflight = map[string]*tableEnsure{}
}
if entry, ok := c.inflight[key]; ok {
c.mu.Unlock()
<-entry.done
return entry.err
}
entry := &tableEnsure{done: make(chan struct{})}
c.inflight[key] = entry
c.mu.Unlock()
err := fn()
c.mu.Lock()
if err == nil {
c.seen[key] = struct{}{}
}
entry.err = err
delete(c.inflight, key)
close(entry.done)
c.mu.Unlock()
return err
}
func NewWriter(exec Execer) *Writer {
if exec == nil {
panic("history execer must not be nil")
}
return &Writer{exec: exec, cache: tableCache{seen: map[string]struct{}{}}}
}
func NewWriterWithDatabase(exec Execer, database string) *Writer {
writer := NewWriter(exec)
writer.database = normalizeIdentifier(database)
return writer
}
func (w *Writer) EnsureSchema(ctx context.Context, database string) error {
for _, statement := range SchemaStatements(database) {
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
return err
}
}
for _, statement := range SchemaMigrationStatements(database) {
if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isDuplicateTDengineColumnError(err) {
return err
}
}
return nil
}
func isDuplicateTDengineColumnError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "duplicate column") || strings.Contains(message, "duplicated column") || strings.Contains(message, "column already exists")
}
func (w *Writer) qualify(table string) string {
table = normalizeIdentifier(table)
if w.database == "" {
return table
}
return w.database + "." + table
}
func (w *Writer) AppendAll(ctx context.Context, env envelope.FrameEnvelope) error {
result, err := w.AppendAllWithResult(ctx, env)
if err != nil {
return err
}
return result.LocationError
}
func (w *Writer) AppendAllWithResult(ctx context.Context, env envelope.FrameEnvelope) (AppendResult, error) {
var result AppendResult
if err := w.AppendRawFrame(ctx, env); err != nil {
return result, err
}
result.RawRows = 1
rows, err := w.appendLocationWithCount(ctx, env)
if err != nil {
result.LocationError = err
return result, nil
}
result.LocationRows = rows
return result, nil
}
func (w *Writer) AppendAllBatch(ctx context.Context, envelopes []envelope.FrameEnvelope) error {
result, err := w.AppendAllBatchWithResult(ctx, envelopes)
if err != nil {
return err
}
return result.LocationError
}
func (w *Writer) AppendAllBatchWithResult(ctx context.Context, envelopes []envelope.FrameEnvelope) (AppendResult, error) {
var result AppendResult
if len(envelopes) == 0 {
return result, nil
}
if err := w.AppendRawFrameBatch(ctx, envelopes); err != nil {
return result, err
}
result.RawRows = len(envelopes)
rows, err := w.appendLocationBatchWithCount(ctx, envelopes)
if err != nil {
result.LocationError = err
return result, nil
}
result.LocationRows = rows
return result, nil
}
func (w *Writer) AppendRawFrame(ctx context.Context, env envelope.FrameEnvelope) error {
table := tableName("raw", env)
if err := w.ensureRawChild(ctx, table, "raw_frames", env); err != nil {
return err
}
rawHex, rawHexChunks := chunkPayload(env, "raw_hex", env.RawHex)
rawText, rawTextChunks := chunkPayload(env, "raw_text", env.RawText)
parsedFields, parsedChunks := chunkPayload(env, "parsed_fields", parsedFieldsJSONString(env))
_, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, frame_id, event_id, message_id, event_time, received_at, raw_size_bytes,
raw_hex, raw_text, parsed_json, parse_status, parse_error, source_endpoint)
VALUES (%s)`, w.qualify(table), joinLiterals(rawValues(env, rawHex, rawText, parsedFields))))
if err != nil {
return err
}
chunks := append(rawHexChunks, rawTextChunks...)
chunks = append(chunks, parsedChunks...)
if len(chunks) == 0 {
return nil
}
chunkTable := tableName("chunk", env)
if err := w.ensureRawChild(ctx, chunkTable, "raw_frame_payload_chunks", env); err != nil {
return err
}
for _, chunk := range chunks {
if _, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, event_id, frame_id, received_at, payload_kind, chunk_index, chunk_count, chunk_text)
VALUES (%s)`, w.qualify(chunkTable), joinLiterals(chunkValues(env, chunk)))); err != nil {
return err
}
}
return nil
}
func (w *Writer) AppendRawFrameBatch(ctx context.Context, envelopes []envelope.FrameEnvelope) error {
rowsByTable := map[string][]string{}
chunkRowsByTable := map[string][]string{}
for _, env := range envelopes {
table := tableName("raw", env)
if err := w.ensureRawChild(ctx, table, "raw_frames", env); err != nil {
return err
}
rawHex, rawHexChunks := chunkPayload(env, "raw_hex", env.RawHex)
rawText, rawTextChunks := chunkPayload(env, "raw_text", env.RawText)
parsedFields, parsedChunks := chunkPayload(env, "parsed_fields", parsedFieldsJSONString(env))
rowsByTable[table] = append(rowsByTable[table], "("+joinLiterals(rawValues(env, rawHex, rawText, parsedFields))+")")
chunks := append(rawHexChunks, rawTextChunks...)
chunks = append(chunks, parsedChunks...)
if len(chunks) == 0 {
continue
}
chunkTable := tableName("chunk", env)
if err := w.ensureRawChild(ctx, chunkTable, "raw_frame_payload_chunks", env); err != nil {
return err
}
for _, chunk := range chunks {
chunkRowsByTable[chunkTable] = append(chunkRowsByTable[chunkTable], "("+joinLiterals(chunkValues(env, chunk))+")")
}
}
if err := w.execMultiTableInsert(ctx, rowsByTable, `ts, frame_id, event_id, message_id, event_time, received_at, raw_size_bytes,
raw_hex, raw_text, parsed_json, parse_status, parse_error, source_endpoint`); err != nil {
return err
}
if err := w.execMultiTableInsert(ctx, chunkRowsByTable, `ts, event_id, frame_id, received_at, payload_kind, chunk_index, chunk_count, chunk_text`); err != nil {
return err
}
return nil
}
func (w *Writer) AppendLocation(ctx context.Context, env envelope.FrameEnvelope) error {
_, err := w.appendLocationWithCount(ctx, env)
return err
}
func (w *Writer) appendLocationWithCount(ctx context.Context, env envelope.FrameEnvelope) (int, error) {
longitude, latitude, status := locationCandidate(env)
if status != LocationStatusOK {
return 0, nil
}
table := locationTableName(env)
if err := w.ensureLocationChild(ctx, table, env); err != nil {
return 0, err
}
_, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh,
soc_percent, direction_deg, alarm_flag, status_flag, total_mileage_km)
VALUES (%s)`, w.qualify(table), joinLiterals(locationValues(env, longitude, latitude))))
if err != nil {
return 0, err
}
return 1, nil
}
func (w *Writer) AppendLocationBatch(ctx context.Context, envelopes []envelope.FrameEnvelope) error {
_, err := w.appendLocationBatchWithCount(ctx, envelopes)
return err
}
func (w *Writer) appendLocationBatchWithCount(ctx context.Context, envelopes []envelope.FrameEnvelope) (int, error) {
rowsByTable := map[string][]string{}
rowCount := 0
for _, env := range envelopes {
longitude, latitude, status := locationCandidate(env)
if status != LocationStatusOK {
continue
}
table := locationTableName(env)
if err := w.ensureLocationChild(ctx, table, env); err != nil {
return rowCount, err
}
rowsByTable[table] = append(rowsByTable[table], "("+joinLiterals(locationValues(env, longitude, latitude))+")")
rowCount++
}
if err := w.execMultiTableInsert(ctx, rowsByTable, `ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh,
soc_percent, direction_deg, alarm_flag, status_flag, total_mileage_km`); err != nil {
return rowCount, err
}
return rowCount, nil
}
func (w *Writer) execMultiTableInsert(ctx context.Context, rowsByTable map[string][]string, columns string) error {
statements := buildMultiTableInsertStatements(rowsByTable, w.qualify, columns, tdengineInsertSoftLimit)
for _, statement := range statements {
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
func buildMultiTableInsertStatements(rowsByTable map[string][]string, qualify func(string) string, columns string, softLimit int) []string {
if len(rowsByTable) == 0 {
return nil
}
keys := make([]string, 0, len(rowsByTable))
for table, rows := range rowsByTable {
if len(rows) > 0 {
keys = append(keys, table)
}
}
sort.Strings(keys)
if len(keys) == 0 {
return nil
}
if qualify == nil {
qualify = func(table string) string { return table }
}
var statements []string
current := "INSERT INTO "
parts := 0
for _, table := range keys {
part := fmt.Sprintf(`%s
(%s)
VALUES %s`, qualify(table), columns, strings.Join(rowsByTable[table], ","))
if parts > 0 && softLimit > 0 && len(current)+1+len(part) > softLimit {
statements = append(statements, current)
current = "INSERT INTO "
parts = 0
}
if parts > 0 {
current += " "
}
current += part
parts++
}
if parts > 0 {
statements = append(statements, current)
}
return statements
}
func LocationStatus(env envelope.FrameEnvelope) string {
_, _, status := locationCandidate(env)
return status
}
func locationCandidate(env envelope.FrameEnvelope) (float64, float64, string) {
if !envelope.IsRealtimeTelemetryFrame(env) {
return 0, 0, LocationStatusSkippedNonRealtime
}
if strings.TrimSpace(env.VIN) == "" {
return 0, 0, LocationStatusSkippedMissingVIN
}
location, ok := telemetry.LocationProjectionForProtocol(env.Protocol, env.ParsedFields)
if !ok {
return 0, 0, LocationStatusSkippedMissingCoordinates
}
return location.Longitude, location.Latitude, LocationStatusOK
}
func (w *Writer) ensureRawChild(ctx context.Context, table string, stable string, env envelope.FrameEnvelope) error {
key := stable + "." + table
return w.cache.doOnce(key, func() error {
statement := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s USING %s TAGS ('%s', '%s', '%s', '%s', '%s')",
w.qualify(table), w.qualify(stable),
quote(string(env.Protocol)),
quote(env.VehicleKey()),
quote(env.VIN),
quote(normalizePhoneTag(env.Phone)),
quote(env.DeviceID))
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
return err
}
if phone := normalizePhoneTag(env.Phone); phone != "" {
if _, err := w.exec.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s SET TAG phone = '%s'", w.qualify(table), quote(phone))); err != nil {
return err
}
}
return nil
})
}
func (w *Writer) ensureLocationChild(ctx context.Context, table string, env envelope.FrameEnvelope) error {
key := "vehicle_locations." + table
return w.cache.doOnce(key, func() error {
statement := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s USING %s TAGS ('%s', '%s')",
w.qualify(table),
w.qualify("vehicle_locations"),
quote(string(env.Protocol)),
quote(strings.TrimSpace(env.VIN)))
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
return err
}
return nil
})
}
func rawValues(env envelope.FrameEnvelope, rawHex string, rawText string, parsedFields string) []any {
received := millis(env.ReceivedAtMS)
eventTime := millis(env.EventTimeMS)
return []any{
received,
frameID(env),
env.StableEventID(),
messageIDInt(env.MessageID),
eventTime,
received,
rawSizeBytes(env),
rawHex,
rawText,
parsedFields,
string(env.ParseStatus),
env.ParseError,
env.SourceEndpoint,
}
}
func chunkValues(env envelope.FrameEnvelope, chunk payloadChunk) []any {
received := millis(env.ReceivedAtMS)
return []any{
received.Add(time.Duration(chunk.Index) * time.Millisecond),
env.StableEventID(),
frameID(env),
received,
chunk.Kind,
chunk.Index,
chunk.Count,
chunk.Text,
}
}
func chunkPayload(env envelope.FrameEnvelope, kind string, value string) (string, []payloadChunk) {
if len(value) <= rawFramePayloadInlineLimit {
return value, nil
}
chunks := make([]payloadChunk, 0, (len(value)+rawFramePayloadChunkSize-1)/rawFramePayloadChunkSize)
for index, start := 0, 0; start < len(value); index++ {
end := safeChunkEnd(value, start, rawFramePayloadChunkSize)
chunks = append(chunks, payloadChunk{
Kind: kind,
Index: index,
Text: value[start:end],
})
start = end
}
count := len(chunks)
for index := range chunks {
chunks[index].Count = count
}
manifest := map[string]any{
"chunked": true,
"payload_kind": kind,
"event_id": env.StableEventID(),
"chunk_count": count,
}
return jsonString(manifest), chunks
}
func safeChunkEnd(value string, start int, maxBytes int) int {
end := start + maxBytes
if end >= len(value) {
return len(value)
}
for end > start && !utf8.RuneStart(value[end]) {
end--
}
if end == start {
return start + maxBytes
}
return end
}
func locationValues(env envelope.FrameEnvelope, longitude float64, latitude float64) []any {
received := millis(env.ReceivedAtMS)
location, _ := telemetry.LocationProjectionForProtocol(env.Protocol, env.ParsedFields)
totalMileageKM, hasTotalMileage := telemetry.TotalMileageKM(env.Protocol, env.ParsedFields)
return []any{
eventTimeOrReceived(env),
env.StableEventID(),
received,
longitude,
latitude,
optionalFloat(location.AltitudeM),
optionalFloat(location.SpeedKMH),
optionalFloat(location.SOCPercent),
optionalInt(location.DirectionDeg),
optionalInt64(location.AlarmFlag),
optionalInt64(location.StatusFlag),
optionalPositiveFloat(totalMileageKM, hasTotalMileage),
}
}
func frameID(env envelope.FrameEnvelope) string {
return "go_" + env.StableEventID()
}
func tableName(prefix string, env envelope.FrameEnvelope) string {
return prefix + "_" + strings.ToLower(string(env.Protocol)) + "_" + hash16(env.VehicleKey())
}
func locationTableName(env envelope.FrameEnvelope) string {
return "loc_" + strings.ToLower(string(env.Protocol)) + "_" + hash16(strings.TrimSpace(env.VIN))
}
func normalizeIdentifier(value string) string {
value = strings.TrimSpace(value)
value = strings.Trim(value, "`")
return value
}
func normalizePhoneTag(phone string) string {
trimmed := strings.TrimLeft(strings.TrimSpace(phone), "0")
if trimmed == "" {
return strings.TrimSpace(phone)
}
return trimmed
}
func hash16(value string) string {
sum := sha1.Sum([]byte(value))
return hex.EncodeToString(sum[:8])
}
func messageIDInt(value string) int64 {
value = strings.TrimSpace(strings.ToLower(value))
value = strings.TrimPrefix(value, "0x")
parsed, err := strconv.ParseInt(value, 16, 32)
if err == nil {
return parsed
}
parsed, _ = strconv.ParseInt(value, 10, 32)
return parsed
}
func rawSizeBytes(env envelope.FrameEnvelope) int {
rawHex := strings.TrimSpace(env.RawHex)
if len(rawHex)%2 != 0 {
return len(rawHex) / 2
}
if rawHex != "" {
return len(rawHex) / 2
}
return len([]byte(env.RawText))
}
func jsonString(value any) string {
data, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(data)
}
func parsedFieldsJSONString(env envelope.FrameEnvelope) string {
fields, _, ok := realtime.ParsedFieldsForEnvelope(env)
if !ok || len(fields) == 0 {
return ""
}
return jsonString(fields)
}
func optionalFloat(value *float64) any {
if value == nil {
return nil
}
return *value
}
func optionalInt(value *float64) any {
if value == nil {
return nil
}
return int64(*value)
}
func optionalInt64(value *int64) any {
if value == nil {
return nil
}
return *value
}
func optionalPositiveFloat(value float64, ok bool) any {
if !ok || value <= 0 {
return nil
}
return value
}
func eventTimeOrReceived(env envelope.FrameEnvelope) time.Time {
eventMS, _ := envelope.NormalizedEventTimeMS(env)
return millis(eventMS)
}
func millis(value int64) time.Time {
if value <= 0 {
return time.UnixMilli(time.Now().UnixMilli()).UTC()
}
return time.UnixMilli(value).UTC()
}
func quote(value string) string {
return strings.NewReplacer(
`\`, `\\`,
`'`, `''`,
).Replace(value)
}
func joinLiterals(values []any) string {
out := make([]string, 0, len(values))
for _, value := range values {
out = append(out, literal(value))
}
return strings.Join(out, ", ")
}
func literal(value any) string {
if value == nil {
return "NULL"
}
switch typed := value.(type) {
case time.Time:
return strconv.FormatInt(typed.UnixMilli(), 10)
case string:
return "'" + quote(typed) + "'"
case envelope.ParseStatus:
return "'" + quote(string(typed)) + "'"
case int:
return strconv.FormatInt(int64(typed), 10)
case int64:
return strconv.FormatInt(typed, 10)
case uint16:
return strconv.FormatUint(uint64(typed), 10)
case uint32:
return strconv.FormatUint(uint64(typed), 10)
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(typed), 'f', -1, 64)
default:
return "'" + quote(fmt.Sprint(typed)) + "'"
}
}