1205 lines
33 KiB
Go
1205 lines
33 KiB
Go
package realtime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
)
|
|
|
|
type Repository struct {
|
|
client *redis.Client
|
|
cfg Config
|
|
}
|
|
|
|
type FastUpdateResult struct {
|
|
EnvelopesSeen int
|
|
EnvelopesUpdated int
|
|
EnvelopesSkippedNonRealtime int
|
|
EnvelopesSkippedMissingVIN int
|
|
EnvelopesSkippedMissingVehicleKey int
|
|
EnvelopesSkippedMissingFields int
|
|
FieldsSeen int
|
|
FieldsWritten int
|
|
FieldsSkippedStale int
|
|
}
|
|
|
|
func (r FastUpdateResult) Add(other FastUpdateResult) FastUpdateResult {
|
|
return FastUpdateResult{
|
|
EnvelopesSeen: r.EnvelopesSeen + other.EnvelopesSeen,
|
|
EnvelopesUpdated: r.EnvelopesUpdated + other.EnvelopesUpdated,
|
|
EnvelopesSkippedNonRealtime: r.EnvelopesSkippedNonRealtime + other.EnvelopesSkippedNonRealtime,
|
|
EnvelopesSkippedMissingVIN: r.EnvelopesSkippedMissingVIN + other.EnvelopesSkippedMissingVIN,
|
|
EnvelopesSkippedMissingVehicleKey: r.EnvelopesSkippedMissingVehicleKey + other.EnvelopesSkippedMissingVehicleKey,
|
|
EnvelopesSkippedMissingFields: r.EnvelopesSkippedMissingFields + other.EnvelopesSkippedMissingFields,
|
|
FieldsSeen: r.FieldsSeen + other.FieldsSeen,
|
|
FieldsWritten: r.FieldsWritten + other.FieldsWritten,
|
|
FieldsSkippedStale: r.FieldsSkippedStale + other.FieldsSkippedStale,
|
|
}
|
|
}
|
|
|
|
func NewRepository(client *redis.Client, cfg Config) *Repository {
|
|
if client == nil {
|
|
panic("redis client must not be nil")
|
|
}
|
|
return &Repository{client: client, cfg: cfg}
|
|
}
|
|
|
|
func (r *Repository) FastUpdate(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
_, err := r.FastUpdateWithResult(ctx, env)
|
|
return err
|
|
}
|
|
|
|
func (r *Repository) FastUpdateWithResult(ctx context.Context, env envelope.FrameEnvelope) (FastUpdateResult, error) {
|
|
result := FastUpdateResult{EnvelopesSeen: 1}
|
|
if !envelope.IsRealtimeTelemetryFrame(env) {
|
|
result.EnvelopesSkippedNonRealtime = 1
|
|
return result, nil
|
|
}
|
|
vin := strings.TrimSpace(env.VIN)
|
|
if vin == "" {
|
|
result.EnvelopesSkippedMissingVIN = 1
|
|
return result, nil
|
|
}
|
|
vehicleKey := strings.TrimSpace(env.VehicleKey())
|
|
if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") {
|
|
result.EnvelopesSkippedMissingVehicleKey = 1
|
|
return result, nil
|
|
}
|
|
if len(env.ParsedFields) == 0 {
|
|
result.EnvelopesSkippedMissingFields = 1
|
|
return result, nil
|
|
}
|
|
return r.setFastProjection(ctx, vehicleKey, vin, env)
|
|
}
|
|
|
|
func (r *Repository) FastUpdateBatch(ctx context.Context, envs []envelope.FrameEnvelope) error {
|
|
_, err := r.FastUpdateBatchWithResult(ctx, envs)
|
|
return err
|
|
}
|
|
|
|
func (r *Repository) FastUpdateBatchWithResult(ctx context.Context, envs []envelope.FrameEnvelope) (FastUpdateResult, error) {
|
|
if len(envs) == 0 {
|
|
return FastUpdateResult{}, nil
|
|
}
|
|
pipe := r.client.Pipeline()
|
|
var result FastUpdateResult
|
|
queued := make([]queuedFastProjection, 0, len(envs))
|
|
for _, env := range envs {
|
|
result.EnvelopesSeen++
|
|
if !envelope.IsRealtimeTelemetryFrame(env) {
|
|
result.EnvelopesSkippedNonRealtime++
|
|
continue
|
|
}
|
|
vin := strings.TrimSpace(env.VIN)
|
|
if vin == "" {
|
|
result.EnvelopesSkippedMissingVIN++
|
|
continue
|
|
}
|
|
vehicleKey := strings.TrimSpace(env.VehicleKey())
|
|
if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") {
|
|
result.EnvelopesSkippedMissingVehicleKey++
|
|
continue
|
|
}
|
|
if len(env.ParsedFields) == 0 {
|
|
result.EnvelopesSkippedMissingFields++
|
|
continue
|
|
}
|
|
queuedProjection, err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env)
|
|
if err != nil {
|
|
return FastUpdateResult{}, err
|
|
}
|
|
queued = append(queued, queuedProjection)
|
|
}
|
|
if len(queued) == 0 {
|
|
return result, nil
|
|
}
|
|
_, err := pipe.Exec(ctx)
|
|
if err != nil {
|
|
return FastUpdateResult{}, err
|
|
}
|
|
for _, item := range queued {
|
|
result = result.Add(item.result())
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (r *Repository) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
if !envelope.IsRealtimeTelemetryFrame(env) {
|
|
return nil
|
|
}
|
|
vin := strings.TrimSpace(env.VIN)
|
|
if vin == "" {
|
|
return nil
|
|
}
|
|
vehicleKey := strings.TrimSpace(env.VehicleKey())
|
|
if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") {
|
|
return nil
|
|
}
|
|
nowMS := time.Now().UnixMilli()
|
|
eventMS, _ := envelope.NormalizedEventTimeMS(env)
|
|
protocolSnapshot := Snapshot{
|
|
VehicleKey: vehicleKey,
|
|
VIN: vin,
|
|
Protocol: env.Protocol,
|
|
EventID: env.StableEventID(),
|
|
EventTimeMS: eventMS,
|
|
ReceivedAtMS: env.ReceivedAtMS,
|
|
Fields: cloneFields(env.Fields),
|
|
FieldTimesMS: fieldTimes(env.Fields, eventMS),
|
|
Parsed: cloneMap(env.Parsed),
|
|
UpdatedAtMS: nowMS,
|
|
}
|
|
existingProtocol, err := r.GetProtocol(ctx, vehicleKey, env.Protocol)
|
|
if err != nil && !errors.Is(err, redis.Nil) {
|
|
return err
|
|
}
|
|
existingParsed, err := r.GetRealtimeRaw(ctx, vehicleKey, env.Protocol)
|
|
if err != nil && !errors.Is(err, redis.Nil) {
|
|
return err
|
|
}
|
|
if existingProtocol.VehicleKey != "" {
|
|
protocolSnapshot = existingProtocol
|
|
if protocolSnapshot.VIN == "" && vin != "" {
|
|
protocolSnapshot.VIN = vin
|
|
}
|
|
mergeFields(&protocolSnapshot, env.Fields, eventMS)
|
|
if eventMS >= protocolSnapshot.EventTimeMS {
|
|
protocolSnapshot.EventTimeMS = eventMS
|
|
protocolSnapshot.EventID = env.StableEventID()
|
|
protocolSnapshot.ReceivedAtMS = env.ReceivedAtMS
|
|
}
|
|
protocolSnapshot.UpdatedAtMS = nowMS
|
|
}
|
|
protocolSnapshot.Parsed = mergeParsedForProtocol(env.Protocol, existingParsed, env.Parsed)
|
|
if err := r.setJSON(ctx, protocolKey(vehicleKey, env.Protocol), protocolSnapshot.Lightweight(), r.cfg.ttl()); err != nil {
|
|
return err
|
|
}
|
|
if err := r.setJSON(ctx, realtimeRawKey(vehicleKey, env.Protocol), protocolSnapshot.Parsed, r.cfg.ttl()); err != nil {
|
|
return err
|
|
}
|
|
if err := r.setKV(ctx, vin, env); err != nil {
|
|
return err
|
|
}
|
|
|
|
protocols, err := r.addProtocol(ctx, vehicleKey, env.Protocol)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
merged, err := r.GetMerged(ctx, vehicleKey)
|
|
if err != nil && !errors.Is(err, redis.Nil) {
|
|
return err
|
|
}
|
|
if merged.VehicleKey == "" {
|
|
merged = Snapshot{
|
|
VehicleKey: vehicleKey,
|
|
VIN: vin,
|
|
Fields: map[string]any{},
|
|
FieldTimesMS: map[string]int64{},
|
|
}
|
|
} else if merged.VIN == "" && vin != "" {
|
|
merged.VIN = vin
|
|
}
|
|
mergeFields(&merged, env.Fields, eventMS)
|
|
if eventMS >= merged.EventTimeMS {
|
|
merged.EventTimeMS = eventMS
|
|
merged.EventID = env.StableEventID()
|
|
merged.ReceivedAtMS = env.ReceivedAtMS
|
|
}
|
|
merged.Protocols = protocols
|
|
merged.UpdatedAtMS = nowMS
|
|
if err := r.setJSON(ctx, mergedKey(vehicleKey), merged, r.cfg.ttl()); err != nil {
|
|
return err
|
|
}
|
|
|
|
online := OnlineStatus{
|
|
VehicleKey: vehicleKey,
|
|
VIN: vin,
|
|
Protocol: env.Protocol,
|
|
Online: true,
|
|
LastSeenMS: env.ReceivedAtMS,
|
|
Protocols: protocols,
|
|
TTLSeconds: int64(r.cfg.ttl().Seconds()),
|
|
}
|
|
if err := r.setOnlineStatus(ctx, online); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) GetMerged(ctx context.Context, vehicleKey string) (Snapshot, error) {
|
|
return r.getSnapshot(ctx, mergedKey(vehicleKey))
|
|
}
|
|
|
|
func (r *Repository) GetProtocol(ctx context.Context, vehicleKey string, protocol envelope.Protocol) (Snapshot, error) {
|
|
return r.getSnapshot(ctx, protocolKey(vehicleKey, protocol))
|
|
}
|
|
|
|
func (r *Repository) GetRealtimeRaw(ctx context.Context, vehicleKey string, protocol envelope.Protocol) (map[string]any, error) {
|
|
payload, err := r.client.Get(ctx, realtimeRawKey(vehicleKey, protocol)).Bytes()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
return out, json.Unmarshal(payload, &out)
|
|
}
|
|
|
|
func (r *Repository) IsOnline(ctx context.Context, vehicleKey string) (OnlineStatus, error) {
|
|
vehicleKey = strings.TrimSpace(vehicleKey)
|
|
best := OnlineStatus{VehicleKey: vehicleKey, VIN: vehicleKey, Online: false}
|
|
for _, protocol := range knownRealtimeProtocols() {
|
|
key := onlineKey(protocol, vehicleKey)
|
|
payload, err := r.client.Get(ctx, key).Bytes()
|
|
if err != nil {
|
|
if errors.Is(err, redis.Nil) {
|
|
continue
|
|
}
|
|
return OnlineStatus{}, err
|
|
}
|
|
var status OnlineStatus
|
|
if err := json.Unmarshal(payload, &status); err != nil {
|
|
return OnlineStatus{}, err
|
|
}
|
|
ttl := r.client.TTL(ctx, key).Val()
|
|
status.Online = ttl > 0
|
|
status.TTLSeconds = int64(ttl.Seconds())
|
|
if status.Protocol == "" {
|
|
status.Protocol = protocol
|
|
}
|
|
if status.LastSeenMS >= best.LastSeenMS {
|
|
best = status
|
|
}
|
|
}
|
|
return best, nil
|
|
}
|
|
|
|
func (r *Repository) ListOnline(ctx context.Context, query OnlineListQuery) ([]OnlineStatus, int64, error) {
|
|
query = normalizeOnlineListQuery(query)
|
|
total, err := r.onlineTotal(ctx, query.Protocol)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
items := make([]OnlineStatus, 0, query.Limit)
|
|
start := int64(query.Offset)
|
|
if query.Protocol != "" {
|
|
start = 0
|
|
}
|
|
skipped := 0
|
|
batchSize := int64(query.Limit * 5)
|
|
if batchSize < 100 {
|
|
batchSize = 100
|
|
}
|
|
for len(items) < query.Limit {
|
|
stop := start + batchSize - 1
|
|
members, err := r.client.ZRevRangeWithScores(ctx, "vehicle:last_seen", start, stop).Result()
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if len(members) == 0 {
|
|
break
|
|
}
|
|
for _, member := range members {
|
|
protocol, vin, ok := splitOnlineMember(fmt.Sprint(member.Member))
|
|
if !ok {
|
|
continue
|
|
}
|
|
if query.Protocol != "" && protocol != query.Protocol {
|
|
continue
|
|
}
|
|
if query.Protocol != "" && skipped < query.Offset {
|
|
skipped++
|
|
continue
|
|
}
|
|
status, err := r.onlineStatusFor(ctx, protocol, vin, int64(member.Score))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
items = append(items, status)
|
|
if len(items) >= query.Limit {
|
|
break
|
|
}
|
|
}
|
|
if int64(len(members)) < batchSize {
|
|
break
|
|
}
|
|
start += batchSize
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
func (r *Repository) PipelineSummary(ctx context.Context) (PipelineSummary, error) {
|
|
out := PipelineSummary{
|
|
Protocols: make([]ProtocolPipelineSummary, 0, len(knownRealtimeProtocols())),
|
|
UpdatedMS: time.Now().UnixMilli(),
|
|
}
|
|
for _, protocol := range knownRealtimeProtocols() {
|
|
indexed, err := r.client.SCard(ctx, realtimeIndexKey(protocol)).Result()
|
|
if err != nil {
|
|
return PipelineSummary{}, err
|
|
}
|
|
onlineCount, latestSeen, err := r.protocolOnlineStats(ctx, protocol)
|
|
if err != nil {
|
|
return PipelineSummary{}, err
|
|
}
|
|
out.Protocols = append(out.Protocols, ProtocolPipelineSummary{
|
|
Protocol: protocol,
|
|
IndexedCount: indexed,
|
|
OnlineCount: onlineCount,
|
|
LatestSeenMS: latestSeen,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repository) onlineTotal(ctx context.Context, protocol envelope.Protocol) (int64, error) {
|
|
if protocol != "" {
|
|
return r.client.SCard(ctx, realtimeIndexKey(protocol)).Result()
|
|
}
|
|
return r.client.ZCard(ctx, "vehicle:last_seen").Result()
|
|
}
|
|
|
|
func (r *Repository) protocolOnlineStats(ctx context.Context, protocol envelope.Protocol) (int64, int64, error) {
|
|
members, err := r.client.SMembers(ctx, realtimeIndexKey(protocol)).Result()
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
var onlineCount int64
|
|
var latestSeen int64
|
|
for _, vin := range members {
|
|
status, err := r.onlineStatusFor(ctx, protocol, vin, 0)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if status.Online {
|
|
onlineCount++
|
|
}
|
|
if status.LastSeenMS > latestSeen {
|
|
latestSeen = status.LastSeenMS
|
|
}
|
|
}
|
|
return onlineCount, latestSeen, nil
|
|
}
|
|
|
|
func (r *Repository) onlineStatusFor(ctx context.Context, protocol envelope.Protocol, vin string, scoreMS int64) (OnlineStatus, error) {
|
|
key := onlineKey(protocol, vin)
|
|
stateKey := onlineStateKey(protocol, vin)
|
|
state, err := r.client.HGetAll(ctx, stateKey).Result()
|
|
if err != nil {
|
|
return OnlineStatus{}, err
|
|
}
|
|
ttl := r.client.TTL(ctx, key).Val()
|
|
status := OnlineStatus{
|
|
VehicleKey: firstNonEmptyString(state["vehicle_key"], vin),
|
|
VIN: firstNonEmptyString(state["vin"], vin),
|
|
Protocol: protocol,
|
|
Online: ttl > 0,
|
|
LastSeenMS: firstPositiveInt64(parseInt64(state["last_seen_ms"]), scoreMS),
|
|
OfflineAfterMS: parseInt64(state["offline_after_ms"]),
|
|
SourceEndpoint: state["source_endpoint"],
|
|
Protocols: []envelope.Protocol{protocol},
|
|
TTLSeconds: int64(ttl.Seconds()),
|
|
}
|
|
if status.TTLSeconds < 0 {
|
|
status.TTLSeconds = 0
|
|
}
|
|
if status.OfflineAfterMS <= 0 && status.LastSeenMS > 0 {
|
|
status.OfflineAfterMS = status.LastSeenMS + r.cfg.ttl().Milliseconds()
|
|
}
|
|
return status, nil
|
|
}
|
|
|
|
func (r *Repository) getSnapshot(ctx context.Context, key string) (Snapshot, error) {
|
|
var snapshot Snapshot
|
|
payload, err := r.client.Get(ctx, key).Bytes()
|
|
if err != nil {
|
|
return snapshot, err
|
|
}
|
|
return snapshot, json.Unmarshal(payload, &snapshot)
|
|
}
|
|
|
|
func (r *Repository) setJSON(ctx context.Context, key string, value any, ttl time.Duration) error {
|
|
payload, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return r.client.Set(ctx, key, payload, ttl).Err()
|
|
}
|
|
|
|
func (r *Repository) setKV(ctx context.Context, vin string, env envelope.FrameEnvelope) error {
|
|
values, types := realtimeKVMapsForEnvelope(env)
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
meta := map[string]any{
|
|
"event_time_ms": strconv.FormatInt(eventTimeOrReceivedMS(env), 10),
|
|
"received_at_ms": strconv.FormatInt(env.ReceivedAtMS, 10),
|
|
"event_id": env.StableEventID(),
|
|
"protocol": string(env.Protocol),
|
|
"vin": vin,
|
|
"source_endpoint": env.SourceEndpoint,
|
|
"field_mapping": realtimeFieldMappingVersion,
|
|
}
|
|
return evalGuardedRealtimeKV(ctx, r.client, env.Protocol, vin, eventTimeOrReceivedMS(env), values, types, meta).Err()
|
|
}
|
|
|
|
func (r *Repository) setFastProjection(ctx context.Context, vehicleKey string, vin string, env envelope.FrameEnvelope) (FastUpdateResult, error) {
|
|
pipe := r.client.Pipeline()
|
|
queued, err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env)
|
|
if err != nil {
|
|
return FastUpdateResult{}, err
|
|
}
|
|
if _, err := pipe.Exec(ctx); err != nil {
|
|
return FastUpdateResult{}, err
|
|
}
|
|
result := queued.result()
|
|
result.EnvelopesSeen = 1
|
|
return result, nil
|
|
}
|
|
|
|
type queuedFastProjection struct {
|
|
fieldsSeen int
|
|
writeCmd *redis.Cmd
|
|
}
|
|
|
|
func (q queuedFastProjection) result() FastUpdateResult {
|
|
written := redisCmdInt(q.writeCmd)
|
|
skipped := q.fieldsSeen - written
|
|
if skipped < 0 {
|
|
skipped = 0
|
|
}
|
|
return FastUpdateResult{
|
|
EnvelopesUpdated: 1,
|
|
FieldsSeen: q.fieldsSeen,
|
|
FieldsWritten: written,
|
|
FieldsSkippedStale: skipped,
|
|
}
|
|
}
|
|
|
|
func (r *Repository) queueFastProjection(ctx context.Context, pipe redis.Pipeliner, vehicleKey string, vin string, env envelope.FrameEnvelope) (queuedFastProjection, error) {
|
|
values, types := realtimeKVMapsForEnvelope(env)
|
|
eventTimeMS := eventTimeOrReceivedMS(env)
|
|
offlineAfterMS := env.ReceivedAtMS + r.cfg.ttl().Milliseconds()
|
|
online := OnlineStatus{
|
|
VehicleKey: vehicleKey,
|
|
VIN: vin,
|
|
Protocol: env.Protocol,
|
|
Online: true,
|
|
LastSeenMS: env.ReceivedAtMS,
|
|
OfflineAfterMS: offlineAfterMS,
|
|
SourceEndpoint: env.SourceEndpoint,
|
|
Protocols: []envelope.Protocol{env.Protocol},
|
|
TTLSeconds: int64(r.cfg.ttl().Seconds()),
|
|
}
|
|
payload, err := json.Marshal(online)
|
|
if err != nil {
|
|
return queuedFastProjection{}, err
|
|
}
|
|
meta := map[string]any{
|
|
"event_time_ms": strconv.FormatInt(eventTimeMS, 10),
|
|
"received_at_ms": strconv.FormatInt(env.ReceivedAtMS, 10),
|
|
"event_id": env.StableEventID(),
|
|
"protocol": string(env.Protocol),
|
|
"vin": vin,
|
|
"source_endpoint": env.SourceEndpoint,
|
|
"field_mapping": realtimeFieldMappingVersion,
|
|
}
|
|
state := map[string]any{
|
|
"vehicle_key": vehicleKey,
|
|
"vin": vin,
|
|
"protocol": string(env.Protocol),
|
|
"online": strconv.FormatBool(true),
|
|
"last_seen_ms": strconv.FormatInt(env.ReceivedAtMS, 10),
|
|
"offline_after_ms": strconv.FormatInt(offlineAfterMS, 10),
|
|
"ttl_seconds": strconv.FormatInt(int64(r.cfg.ttl().Seconds()), 10),
|
|
"source_endpoint": env.SourceEndpoint,
|
|
}
|
|
queued := queuedFastProjection{fieldsSeen: len(values)}
|
|
if len(values) > 0 {
|
|
queued.writeCmd = evalGuardedRealtimeKV(ctx, pipe, env.Protocol, vin, eventTimeMS, values, types, meta)
|
|
}
|
|
if _, err := evalGuardedOnlineStatus(ctx, pipe, online, state, payload, r.cfg.ttl()); err != nil {
|
|
return queuedFastProjection{}, err
|
|
}
|
|
return queued, nil
|
|
}
|
|
|
|
const guardedRealtimeKVScript = `
|
|
local incoming = tonumber(ARGV[1]) or 0
|
|
local value_count = tonumber(ARGV[2]) or 0
|
|
local idx = 3
|
|
local written = 0
|
|
|
|
for i = 1, value_count do
|
|
local field = ARGV[idx]
|
|
local value = ARGV[idx + 1]
|
|
idx = idx + 2
|
|
local current = tonumber(redis.call('HGET', KEYS[3], field) or '') or 0
|
|
if current <= incoming then
|
|
redis.call('HSET', KEYS[1], field, value)
|
|
redis.call('HSET', KEYS[3], field, incoming)
|
|
written = written + 1
|
|
end
|
|
end
|
|
|
|
local type_count = tonumber(ARGV[idx]) or 0
|
|
idx = idx + 1
|
|
for i = 1, type_count do
|
|
local field = ARGV[idx]
|
|
local value_type = ARGV[idx + 1]
|
|
idx = idx + 2
|
|
local current = tonumber(redis.call('HGET', KEYS[3], field) or '') or 0
|
|
if current <= incoming then
|
|
redis.call('HSET', KEYS[2], field, value_type)
|
|
end
|
|
end
|
|
|
|
local meta_count = tonumber(ARGV[idx]) or 0
|
|
idx = idx + 1
|
|
local current_meta = tonumber(redis.call('HGET', KEYS[4], 'event_time_ms') or '') or 0
|
|
if current_meta <= incoming then
|
|
for i = 1, meta_count do
|
|
redis.call('HSET', KEYS[4], ARGV[idx], ARGV[idx + 1])
|
|
idx = idx + 2
|
|
end
|
|
end
|
|
|
|
return written
|
|
`
|
|
|
|
const guardedOnlineStatusScript = `
|
|
local incoming = tonumber(ARGV[1]) or 0
|
|
local ttl_ms = tonumber(ARGV[2]) or 60000
|
|
local payload = ARGV[3]
|
|
local member = ARGV[4]
|
|
local vin = ARGV[5]
|
|
local state_count = tonumber(ARGV[6]) or 0
|
|
local idx = 7
|
|
local current = tonumber(redis.call('HGET', KEYS[2], 'last_seen_ms') or '') or 0
|
|
|
|
redis.call('SADD', KEYS[4], vin)
|
|
if current <= incoming then
|
|
redis.call('SET', KEYS[1], payload, 'PX', ttl_ms)
|
|
for i = 1, state_count do
|
|
redis.call('HSET', KEYS[2], ARGV[idx], ARGV[idx + 1])
|
|
idx = idx + 2
|
|
end
|
|
redis.call('ZADD', KEYS[3], incoming, member)
|
|
return 1
|
|
end
|
|
return 0
|
|
`
|
|
|
|
type redisEvaler interface {
|
|
Eval(ctx context.Context, script string, keys []string, args ...any) *redis.Cmd
|
|
}
|
|
|
|
func redisCmdInt(cmd *redis.Cmd) int {
|
|
if cmd == nil {
|
|
return 0
|
|
}
|
|
value, err := cmd.Int64()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return int(value)
|
|
}
|
|
|
|
func evalGuardedRealtimeKV(ctx context.Context, evaler redisEvaler, protocol envelope.Protocol, vin string, eventTimeMS int64, values map[string]any, types map[string]any, meta map[string]any) *redis.Cmd {
|
|
keys := []string{
|
|
realtimeKVValuesKey(protocol, vin),
|
|
realtimeKVTypesKey(protocol, vin),
|
|
realtimeKVTimesKey(protocol, vin),
|
|
realtimeKVMetaKey(protocol, vin),
|
|
}
|
|
args := guardedRealtimeKVArgs(eventTimeMS, values, types, meta)
|
|
return evaler.Eval(ctx, guardedRealtimeKVScript, keys, args...)
|
|
}
|
|
|
|
func evalGuardedOnlineStatus(ctx context.Context, evaler redisEvaler, online OnlineStatus, state map[string]any, payload []byte, ttl time.Duration) (*redis.Cmd, error) {
|
|
protocol := online.Protocol
|
|
if protocol == "" && len(online.Protocols) > 0 {
|
|
protocol = online.Protocols[0]
|
|
}
|
|
vin := strings.TrimSpace(online.VIN)
|
|
if protocol == "" || vin == "" {
|
|
return nil, nil
|
|
}
|
|
if len(payload) == 0 {
|
|
var err error
|
|
payload, err = json.Marshal(online)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = time.Minute
|
|
}
|
|
keys := []string{
|
|
onlineKey(protocol, vin),
|
|
onlineStateKey(protocol, vin),
|
|
"vehicle:last_seen",
|
|
realtimeIndexKey(protocol),
|
|
}
|
|
args := guardedOnlineStatusArgs(online, protocol, state, payload, ttl)
|
|
return evaler.Eval(ctx, guardedOnlineStatusScript, keys, args...), nil
|
|
}
|
|
|
|
func guardedOnlineStatusArgs(online OnlineStatus, protocol envelope.Protocol, state map[string]any, payload []byte, ttl time.Duration) []any {
|
|
args := []any{
|
|
strconv.FormatInt(online.LastSeenMS, 10),
|
|
strconv.FormatInt(ttl.Milliseconds(), 10),
|
|
string(payload),
|
|
onlineMember(protocol, online.VIN),
|
|
strings.TrimSpace(online.VIN),
|
|
}
|
|
return appendMapPairs(args, state)
|
|
}
|
|
|
|
func guardedRealtimeKVArgs(eventTimeMS int64, values map[string]any, types map[string]any, meta map[string]any) []any {
|
|
args := []any{strconv.FormatInt(eventTimeMS, 10)}
|
|
args = appendMapPairs(args, values)
|
|
args = appendMapPairs(args, types)
|
|
args = appendMapPairs(args, meta)
|
|
return args
|
|
}
|
|
|
|
func appendMapPairs(args []any, values map[string]any) []any {
|
|
keys := make([]string, 0, len(values))
|
|
for key := range values {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
args = append(args, strconv.Itoa(len(keys)))
|
|
for _, key := range keys {
|
|
args = append(args, key, fmt.Sprint(values[key]))
|
|
}
|
|
return args
|
|
}
|
|
|
|
func realtimeKVMapsForEnvelope(env envelope.FrameEnvelope) (map[string]any, map[string]any) {
|
|
if fields, fieldTypes, ok := ParsedFieldsForEnvelope(env); ok {
|
|
values := make(map[string]any, len(fields))
|
|
types := make(map[string]any, len(fields))
|
|
for field, value := range fields {
|
|
if strings.TrimSpace(field) == "" {
|
|
continue
|
|
}
|
|
if isRealtimeTotalMileageField(field) && !positiveNumber(value) {
|
|
continue
|
|
}
|
|
stringValue, valueType, ok := stringifyKVValue(value)
|
|
if !ok {
|
|
continue
|
|
}
|
|
values[field] = stringValue
|
|
if typed := strings.TrimSpace(fieldTypes[field]); typed != "" {
|
|
types[field] = typed
|
|
} else {
|
|
types[field] = valueType
|
|
}
|
|
}
|
|
return values, types
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func realtimeKVMaps(rows []RealtimeKVField) (map[string]any, map[string]any) {
|
|
values := make(map[string]any, len(rows))
|
|
types := make(map[string]any, len(rows))
|
|
for _, row := range rows {
|
|
field := realtimeKVFieldPath(row.Domain, row.Field)
|
|
if field == "" {
|
|
continue
|
|
}
|
|
values[field] = row.Value
|
|
types[field] = row.ValueType
|
|
}
|
|
return values, types
|
|
}
|
|
|
|
func (r *Repository) setOnline(ctx context.Context, vehicleKey string, vin string, protocol envelope.Protocol, lastSeenMS int64, sourceEndpoint string) error {
|
|
if protocol == "" {
|
|
return nil
|
|
}
|
|
online := OnlineStatus{
|
|
VehicleKey: vehicleKey,
|
|
VIN: vin,
|
|
Protocol: protocol,
|
|
Online: true,
|
|
LastSeenMS: lastSeenMS,
|
|
OfflineAfterMS: lastSeenMS + r.cfg.ttl().Milliseconds(),
|
|
SourceEndpoint: sourceEndpoint,
|
|
Protocols: []envelope.Protocol{protocol},
|
|
TTLSeconds: int64(r.cfg.ttl().Seconds()),
|
|
}
|
|
return r.setOnlineStatus(ctx, online)
|
|
}
|
|
|
|
func (r *Repository) setOnlineStatus(ctx context.Context, online OnlineStatus) error {
|
|
protocol := online.Protocol
|
|
if protocol == "" && len(online.Protocols) > 0 {
|
|
protocol = online.Protocols[0]
|
|
}
|
|
if protocol == "" {
|
|
return nil
|
|
}
|
|
if online.OfflineAfterMS <= 0 {
|
|
online.OfflineAfterMS = online.LastSeenMS + r.cfg.ttl().Milliseconds()
|
|
}
|
|
state := map[string]any{
|
|
"vehicle_key": online.VehicleKey,
|
|
"vin": online.VIN,
|
|
"protocol": string(protocol),
|
|
"online": strconv.FormatBool(true),
|
|
"last_seen_ms": strconv.FormatInt(online.LastSeenMS, 10),
|
|
"offline_after_ms": strconv.FormatInt(online.OfflineAfterMS, 10),
|
|
"ttl_seconds": strconv.FormatInt(int64(r.cfg.ttl().Seconds()), 10),
|
|
"source_endpoint": online.SourceEndpoint,
|
|
}
|
|
pipe := r.client.Pipeline()
|
|
payload, err := json.Marshal(online)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := evalGuardedOnlineStatus(ctx, pipe, online, state, payload, r.cfg.ttl()); err != nil {
|
|
return err
|
|
}
|
|
_, err = pipe.Exec(ctx)
|
|
return err
|
|
}
|
|
|
|
func (r *Repository) addProtocol(ctx context.Context, vehicleKey string, protocol envelope.Protocol) ([]envelope.Protocol, error) {
|
|
key := protocolsKey(vehicleKey)
|
|
if err := r.client.SAdd(ctx, key, string(protocol)).Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = r.client.Expire(ctx, key, r.cfg.ttl()).Err()
|
|
values, err := r.client.SMembers(ctx, key).Result()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Strings(values)
|
|
out := make([]envelope.Protocol, 0, len(values))
|
|
for _, value := range values {
|
|
out = append(out, envelope.Protocol(value))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func mergeFields(snapshot *Snapshot, fields map[string]any, eventMS int64) {
|
|
if snapshot.Fields == nil {
|
|
snapshot.Fields = map[string]any{}
|
|
}
|
|
if snapshot.FieldTimesMS == nil {
|
|
snapshot.FieldTimesMS = map[string]int64{}
|
|
}
|
|
for key, value := range fields {
|
|
if key == envelope.FieldTotalMileageKM && !positiveNumber(value) {
|
|
continue
|
|
}
|
|
if eventMS >= snapshot.FieldTimesMS[key] {
|
|
snapshot.Fields[key] = value
|
|
snapshot.FieldTimesMS[key] = eventMS
|
|
}
|
|
}
|
|
}
|
|
|
|
func positiveNumber(value any) bool {
|
|
switch typed := value.(type) {
|
|
case float64:
|
|
return typed > 0
|
|
case float32:
|
|
return typed > 0
|
|
case int:
|
|
return typed > 0
|
|
case int64:
|
|
return typed > 0
|
|
case uint16:
|
|
return typed > 0
|
|
case uint32:
|
|
return typed > 0
|
|
case string:
|
|
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
|
return err == nil && parsed > 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func cloneFields(fields map[string]any) map[string]any {
|
|
if len(fields) == 0 {
|
|
return map[string]any{}
|
|
}
|
|
out := make(map[string]any, len(fields))
|
|
for key, value := range fields {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneMap(values map[string]any) map[string]any {
|
|
if len(values) == 0 {
|
|
return map[string]any{}
|
|
}
|
|
out := make(map[string]any, len(values))
|
|
for key, value := range values {
|
|
out[key] = cloneAny(value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneAny(value any) any {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
return cloneMap(typed)
|
|
case []any:
|
|
out := make([]any, len(typed))
|
|
for i, item := range typed {
|
|
out[i] = cloneAny(item)
|
|
}
|
|
return out
|
|
case []map[string]any:
|
|
out := make([]any, len(typed))
|
|
for i, item := range typed {
|
|
out[i] = cloneMap(item)
|
|
}
|
|
return out
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func mergeParsed(existing map[string]any, incoming map[string]any) map[string]any {
|
|
out := cloneMap(existing)
|
|
for key, value := range incoming {
|
|
out[key] = mergeAny(out[key], value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mergeParsedForProtocol(protocol envelope.Protocol, existing map[string]any, incoming map[string]any) map[string]any {
|
|
out := mergeParsed(existing, incoming)
|
|
for _, key := range replaceParsedKeys(protocol, incoming) {
|
|
out[key] = cloneAny(incoming[key])
|
|
}
|
|
if protocol == envelope.ProtocolGB32960 {
|
|
normalizeGB32960RealtimeParsed(out)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func replaceParsedKeys(protocol envelope.Protocol, incoming map[string]any) []string {
|
|
keys := make([]string, 0, 2)
|
|
if _, ok := incoming["header"]; ok {
|
|
keys = append(keys, "header")
|
|
}
|
|
if protocol == envelope.ProtocolJT808 {
|
|
if _, ok := incoming["location"]; ok {
|
|
keys = append(keys, "location")
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func mergeAny(existing any, incoming any) any {
|
|
switch incomingTyped := incoming.(type) {
|
|
case map[string]any:
|
|
existingMap, _ := existing.(map[string]any)
|
|
return mergeParsed(existingMap, incomingTyped)
|
|
case []any:
|
|
if merged, ok := mergeMapSlice(existing, incomingTyped); ok {
|
|
return merged
|
|
}
|
|
return cloneAny(incomingTyped)
|
|
case []map[string]any:
|
|
items := make([]any, len(incomingTyped))
|
|
for i, item := range incomingTyped {
|
|
items[i] = item
|
|
}
|
|
if merged, ok := mergeMapSlice(existing, items); ok {
|
|
return merged
|
|
}
|
|
return cloneAny(incomingTyped)
|
|
default:
|
|
return cloneAny(incoming)
|
|
}
|
|
}
|
|
|
|
func mergeMapSlice(existing any, incoming []any) ([]any, bool) {
|
|
existingItems, ok := asAnySlice(existing)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
merged := make([]any, 0, len(existingItems)+len(incoming))
|
|
index := map[string]int{}
|
|
add := func(item any) {
|
|
itemMap, ok := item.(map[string]any)
|
|
if !ok {
|
|
merged = append(merged, cloneAny(item))
|
|
return
|
|
}
|
|
key := mergeSliceKey(itemMap)
|
|
if key == "" {
|
|
merged = append(merged, cloneMap(itemMap))
|
|
return
|
|
}
|
|
if pos, exists := index[key]; exists {
|
|
merged[pos] = mergeAny(merged[pos], itemMap)
|
|
return
|
|
}
|
|
index[key] = len(merged)
|
|
merged = append(merged, cloneMap(itemMap))
|
|
}
|
|
for _, item := range existingItems {
|
|
add(item)
|
|
}
|
|
for _, item := range incoming {
|
|
add(item)
|
|
}
|
|
return merged, true
|
|
}
|
|
|
|
func asAnySlice(value any) ([]any, bool) {
|
|
switch typed := value.(type) {
|
|
case []any:
|
|
return typed, true
|
|
case []map[string]any:
|
|
out := make([]any, len(typed))
|
|
for i, item := range typed {
|
|
out[i] = item
|
|
}
|
|
return out, true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
func mergeSliceKey(item map[string]any) string {
|
|
for _, key := range []string{"name", "type", "id", "serial_no"} {
|
|
if value, ok := item[key]; ok {
|
|
text := strings.TrimSpace(strconvAny(value))
|
|
if text != "" {
|
|
return key + ":" + text
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func strconvAny(value any) string {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed
|
|
default:
|
|
return fmt.Sprint(typed)
|
|
}
|
|
}
|
|
|
|
func normalizeGB32960RealtimeParsed(parsed map[string]any) {
|
|
units, ok := asAnySlice(parsed["data_units"])
|
|
if !ok {
|
|
return
|
|
}
|
|
for _, unit := range units {
|
|
unitMap, ok := unit.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
value, ok := unitMap["value"].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(strconvAny(unitMap["name"])) {
|
|
case "gd_fc_stack":
|
|
collapseGDFCStackValue(value)
|
|
case "gd_fc_auxiliary":
|
|
collapseGDFCAuxiliaryValue(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func collapseGDFCStackValue(value map[string]any) {
|
|
summaries, ok := asAnySlice(value["summaries"])
|
|
if !ok || len(summaries) == 0 {
|
|
return
|
|
}
|
|
collapsed := map[string]any{}
|
|
for _, item := range summaries {
|
|
summary, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for key, fieldValue := range summary {
|
|
if isGDFCStackFragmentField(key) {
|
|
continue
|
|
}
|
|
collapsed[key] = cloneAny(fieldValue)
|
|
}
|
|
}
|
|
if len(collapsed) == 0 {
|
|
delete(value, "summaries")
|
|
return
|
|
}
|
|
value["stack_count"] = 1
|
|
value["summaries"] = []any{collapsed}
|
|
}
|
|
|
|
func isGDFCStackFragmentField(key string) bool {
|
|
switch key {
|
|
case "frame_cell_start", "frame_cell_count", "frame_max_cell_voltage_v", "frame_min_cell_voltage_v":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func collapseGDFCAuxiliaryValue(value map[string]any) {
|
|
subsystems, ok := asAnySlice(value["subsystems"])
|
|
if !ok || len(subsystems) == 0 {
|
|
return
|
|
}
|
|
latest := subsystems[len(subsystems)-1]
|
|
subsystem, ok := latest.(map[string]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
value["subsystem_count"] = 1
|
|
value["subsystems"] = []any{cloneMap(subsystem)}
|
|
}
|
|
|
|
func fieldTimes(fields map[string]any, eventMS int64) map[string]int64 {
|
|
out := make(map[string]int64, len(fields))
|
|
for key := range fields {
|
|
out[key] = eventMS
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mergedKey(vehicleKey string) string {
|
|
return "vehicle:latest:" + strings.TrimSpace(vehicleKey)
|
|
}
|
|
|
|
func protocolKey(vehicleKey string, protocol envelope.Protocol) string {
|
|
return "vehicle:latest:" + strings.TrimSpace(vehicleKey) + ":" + string(protocol)
|
|
}
|
|
|
|
func realtimeRawKey(vehicleKey string, protocol envelope.Protocol) string {
|
|
return "vehicle:realtime-raw:" + string(protocol) + ":" + strings.TrimSpace(vehicleKey)
|
|
}
|
|
|
|
func realtimeKVValuesKey(protocol envelope.Protocol, vin string) string {
|
|
return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":values"
|
|
}
|
|
|
|
func realtimeKVTypesKey(protocol envelope.Protocol, vin string) string {
|
|
return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":types"
|
|
}
|
|
|
|
func realtimeKVTimesKey(protocol envelope.Protocol, vin string) string {
|
|
return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":times"
|
|
}
|
|
|
|
func realtimeKVMetaKey(protocol envelope.Protocol, vin string) string {
|
|
return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":meta"
|
|
}
|
|
|
|
func realtimeKVFieldPath(domain string, field string) string {
|
|
domain = strings.TrimSpace(domain)
|
|
field = strings.TrimSpace(field)
|
|
switch {
|
|
case domain == "":
|
|
return field
|
|
case field == "":
|
|
return domain
|
|
default:
|
|
return domain + "." + field
|
|
}
|
|
}
|
|
|
|
func eventTimeOrReceivedMS(env envelope.FrameEnvelope) int64 {
|
|
eventMS, _ := envelope.NormalizedEventTimeMS(env)
|
|
return eventMS
|
|
}
|
|
|
|
func onlineKey(protocol envelope.Protocol, vin string) string {
|
|
return "vehicle:online:" + string(protocol) + ":" + strings.TrimSpace(vin)
|
|
}
|
|
|
|
func onlineStateKey(protocol envelope.Protocol, vin string) string {
|
|
return "vehicle:online-state:" + string(protocol) + ":" + strings.TrimSpace(vin)
|
|
}
|
|
|
|
func onlineMember(protocol envelope.Protocol, vin string) string {
|
|
return string(protocol) + ":" + strings.TrimSpace(vin)
|
|
}
|
|
|
|
func realtimeIndexKey(protocol envelope.Protocol) string {
|
|
return "vehicle:rt-index:" + string(protocol)
|
|
}
|
|
|
|
func knownRealtimeProtocols() []envelope.Protocol {
|
|
return []envelope.Protocol{envelope.ProtocolGB32960, envelope.ProtocolJT808, envelope.ProtocolYutongMQTT}
|
|
}
|
|
|
|
func protocolsKey(vehicleKey string) string {
|
|
return "vehicle:protocols:" + strings.TrimSpace(vehicleKey)
|
|
}
|
|
|
|
func normalizeOnlineListQuery(query OnlineListQuery) OnlineListQuery {
|
|
query.Protocol = envelope.Protocol(strings.ToUpper(strings.TrimSpace(string(query.Protocol))))
|
|
if query.Limit <= 0 {
|
|
query.Limit = 100
|
|
}
|
|
if query.Limit > 1000 {
|
|
query.Limit = 1000
|
|
}
|
|
if query.Offset < 0 {
|
|
query.Offset = 0
|
|
}
|
|
return query
|
|
}
|
|
|
|
func splitOnlineMember(member string) (envelope.Protocol, string, bool) {
|
|
protocolText, vin, ok := strings.Cut(strings.TrimSpace(member), ":")
|
|
if !ok || strings.TrimSpace(protocolText) == "" || strings.TrimSpace(vin) == "" {
|
|
return "", "", false
|
|
}
|
|
return envelope.Protocol(protocolText), strings.TrimSpace(vin), true
|
|
}
|
|
|
|
func parseInt64(value string) int64 {
|
|
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
|
return parsed
|
|
}
|
|
|
|
func firstPositiveInt64(values ...int64) int64 {
|
|
for _, value := range values {
|
|
if value > 0 {
|
|
return value
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func firstNonEmptyString(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|