Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats/daily_metric.go

1033 lines
29 KiB
Go

package stats
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"sync"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
const (
maxNegativeMileageJitterKM = 1.0
defaultCacheRetention = 72 * time.Hour
defaultCacheCleanupInterval = 10 * time.Minute
defaultBaselineMissTTL = time.Minute
defaultBaselineHitTTL = 5 * time.Minute
defaultMaxCacheEntries = 1000000
)
type Execer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
type Writer struct {
exec Execer
query Queryer
loc *time.Location
sourceTouchInterval time.Duration
projectionInterval time.Duration
cacheRetention time.Duration
cacheCleanupInterval time.Duration
baselineMissTTL time.Duration
baselineHitTTL time.Duration
maxCacheEntries int
lastCacheCleanup time.Time
lastCacheCleanupStats cacheCleanupStats
cacheEvictions cacheCleanupStats
mu sync.Mutex
lastTotalMileage map[string]float64
lastSourceSeen map[string]time.Time
lastProjection map[string]projectionCacheEntry
baselineCache map[string]sourceBaselineCacheEntry
mileageKeysByPrefix map[string]map[string]struct{}
projectionKeysByPrefix map[string]map[string]struct{}
baselineKeysByPrefix map[string]map[string]struct{}
}
type MetricSample struct {
VIN string
Protocol envelope.Protocol
StatDate string
TotalMileageKM float64
EventTime time.Time
SourceKey string
Phone string
DeviceID string
SourceEndpoint string
PlatformName string
}
type AppendResult struct {
SamplesFound int
SamplesWritten int
SamplesSkippedMissingFields int
SamplesSkippedMissingVIN int
SamplesSkippedMissingMileage int
SamplesRecoveredStationary int
SamplesSkippedNonMileageFrame int
SamplesSkippedNonPositiveMileage int
SamplesSkippedMissingTime int
SamplesSkippedSameMileage int
SamplesSkippedMissingSource int
SamplesAdjustedFutureEventTime int
SourceTouchesAttempted int
SourceTouchesWritten int
SourceTouchesSkippedThrottled int
SourceTouchesSkippedMissing int
SourceTouchesSkippedUnmanaged int
ProjectionsAttempted int
ProjectionsWritten int
ProjectionsSkippedThrottled int
}
type CacheStats struct {
LastTotalMileageEntries int
LastSourceSeenEntries int
LastProjectionEntries int
BaselineEntries int
MaxEntries int
LastCleanupAt time.Time
LastCleanupTotalMileage int
LastCleanupSourceSeen int
LastCleanupProjection int
LastCleanupBaseline int
TotalMileageEvictions int
SourceSeenEvictions int
ProjectionEvictions int
BaselineEvictions int
}
type cacheCleanupStats struct {
totalMileage int
sourceSeen int
projection int
baseline int
}
func NewWriter(exec Execer, loc *time.Location) *Writer {
if exec == nil {
panic("stats execer must not be nil")
}
if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
writer := &Writer{
exec: exec,
loc: loc,
sourceTouchInterval: time.Minute,
projectionInterval: 15 * time.Second,
cacheRetention: defaultCacheRetention,
cacheCleanupInterval: defaultCacheCleanupInterval,
baselineMissTTL: defaultBaselineMissTTL,
baselineHitTTL: defaultBaselineHitTTL,
maxCacheEntries: defaultMaxCacheEntries,
lastTotalMileage: map[string]float64{},
lastSourceSeen: map[string]time.Time{},
lastProjection: map[string]projectionCacheEntry{},
baselineCache: map[string]sourceBaselineCacheEntry{},
mileageKeysByPrefix: map[string]map[string]struct{}{},
projectionKeysByPrefix: map[string]map[string]struct{}{},
baselineKeysByPrefix: map[string]map[string]struct{}{},
}
if query, ok := exec.(Queryer); ok {
writer.query = query
}
return writer
}
func (w *Writer) SetSourceTouchInterval(interval time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if interval < 0 {
interval = 0
}
w.sourceTouchInterval = interval
}
func (w *Writer) SetProjectionInterval(interval time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if interval < 0 {
interval = 0
}
w.projectionInterval = interval
}
func (w *Writer) SetCacheRetention(retention time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if retention < 0 {
retention = 0
}
w.cacheRetention = retention
}
func (w *Writer) SetCacheCleanupInterval(interval time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if interval < 0 {
interval = 0
}
w.cacheCleanupInterval = interval
}
func (w *Writer) SetBaselineMissTTL(ttl time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if ttl < 0 {
ttl = 0
}
w.baselineMissTTL = ttl
}
func (w *Writer) SetBaselineHitTTL(ttl time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if ttl < 0 {
ttl = 0
}
w.baselineHitTTL = ttl
}
func (w *Writer) SetMaxCacheEntries(maxEntries int) {
w.mu.Lock()
defer w.mu.Unlock()
if maxEntries < 0 {
maxEntries = 0
}
w.maxCacheEntries = maxEntries
w.enforceCacheLimitsLocked()
}
func (w *Writer) CacheStats() CacheStats {
w.mu.Lock()
defer w.mu.Unlock()
return CacheStats{
LastTotalMileageEntries: len(w.lastTotalMileage),
LastSourceSeenEntries: len(w.lastSourceSeen),
LastProjectionEntries: len(w.lastProjection),
BaselineEntries: len(w.baselineCache),
MaxEntries: w.maxCacheEntries,
LastCleanupAt: w.lastCacheCleanup,
LastCleanupTotalMileage: w.lastCacheCleanupStats.totalMileage,
LastCleanupSourceSeen: w.lastCacheCleanupStats.sourceSeen,
LastCleanupProjection: w.lastCacheCleanupStats.projection,
LastCleanupBaseline: w.lastCacheCleanupStats.baseline,
TotalMileageEvictions: w.cacheEvictions.totalMileage,
SourceSeenEvictions: w.cacheEvictions.sourceSeen,
ProjectionEvictions: w.cacheEvictions.projection,
BaselineEvictions: w.cacheEvictions.baseline,
}
}
func (w *Writer) EnsureSchema(ctx context.Context) error {
for _, statement := range []string{
DataSourceTableSQL,
DailyMileageSourceTableSQL,
DailyMileageTableSQL,
} {
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
return err
}
}
for _, statement := range DailyMileageAlterSQL {
if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isIgnorableSchemaChangeError(err) {
return err
}
}
return nil
}
func (w *Writer) Append(ctx context.Context, env envelope.FrameEnvelope) error {
_, err := w.AppendWithResult(ctx, env)
return err
}
func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelope) (AppendResult, error) {
var result AppendResult
if len(env.Fields) == 0 {
result.SamplesSkippedMissingFields = 1
return result, nil
}
seenAt := w.sourceSeenAt(env)
w.maybeCleanupCaches(seenAt)
identity, hasSource := NewSourceIdentityFromEnvelope(env)
var sourceResult AppendResult
if hasSource && ShouldManageDataSource(identity) {
if w.shouldTouchSource(identity, seenAt) {
sourceResult.SourceTouchesAttempted = 1
if err := UpsertDataSource(ctx, w.exec, identity, seenAt); err != nil {
return sourceResult, err
}
w.markSourceTouched(identity, seenAt)
sourceResult.SourceTouchesWritten = 1
} else {
sourceResult.SourceTouchesSkippedThrottled = 1
}
} else if hasSource {
sourceResult.SourceTouchesSkippedUnmanaged = 1
}
samples, extractionResult, err := samplesFromEnvelopeWithResult(env, w.loc)
result = extractionResult
result.SourceTouchesAttempted += sourceResult.SourceTouchesAttempted
result.SourceTouchesWritten += sourceResult.SourceTouchesWritten
result.SourceTouchesSkippedThrottled += sourceResult.SourceTouchesSkippedThrottled
result.SourceTouchesSkippedMissing += sourceResult.SourceTouchesSkippedMissing
result.SourceTouchesSkippedUnmanaged += sourceResult.SourceTouchesSkippedUnmanaged
if err != nil {
return result, err
}
var stationaryCandidate *SourceMileageSample
if len(samples) == 0 && hasSource && result.SamplesSkippedMissingMileage > 0 {
sample, candidate, recovered, recoverErr := w.stationaryCarryForward(ctx, env, identity)
if recoverErr != nil {
return result, recoverErr
}
if recovered {
samples = []MetricSample{sample}
stationaryCandidate = &candidate
result.SamplesFound++
result.SamplesRecoveredStationary++
}
}
for _, sample := range samples {
if !hasSource {
result.SamplesSkippedMissingSource++
result.SourceTouchesSkippedMissing++
continue
}
if w.seenSameMileage(sample) {
result.SamplesSkippedSameMileage++
continue
}
candidate := SourceMileageSampleFromMetric(sample, identity)
if stationaryCandidate != nil {
candidate = *stationaryCandidate
} else {
if err := w.applyRealtimeBaseline(ctx, &candidate); err != nil {
return result, err
}
}
projectDaily := w.shouldProjectDailyMileage(sample)
if projectDaily {
result.ProjectionsAttempted++
} else {
result.ProjectionsSkippedThrottled++
}
if err := w.writeMileageSample(ctx, sample, candidate, projectDaily); err != nil {
return result, err
}
w.markBaselineWritten(sample, candidate)
if projectDaily {
w.markProjected(sample)
result.ProjectionsWritten++
}
w.markMileageWritten(sample)
result.SamplesWritten++
}
return result, nil
}
func (w *Writer) stationaryCarryForward(ctx context.Context, env envelope.FrameEnvelope, identity SourceIdentity) (MetricSample, SourceMileageSample, bool, error) {
if env.Protocol != envelope.ProtocolYutongMQTT || strings.TrimSpace(env.VIN) == "" {
return MetricSample{}, SourceMileageSample{}, false, nil
}
location, ok := telemetry.LocationProjectionForProtocol(env.Protocol, env.Fields)
if !ok || location.SpeedKMH == nil || *location.SpeedKMH != 0 {
return MetricSample{}, SourceMileageSample{}, false, nil
}
eventMS, _, ok := envelope.NormalizedEventTimeMSWithReason(env)
if !ok {
return MetricSample{}, SourceMileageSample{}, false, nil
}
eventTime := time.UnixMilli(eventMS).In(w.loc)
sample := MetricSample{
VIN: strings.TrimSpace(env.VIN),
Protocol: env.Protocol,
StatDate: eventTime.Format("2006-01-02"),
EventTime: eventTime,
SourceKey: SourceKeyForSource(env.Protocol, env.Phone, env.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode),
Phone: strings.TrimSpace(env.Phone),
DeviceID: strings.TrimSpace(env.DeviceID),
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
PlatformName: strings.TrimSpace(env.PlatformName),
}
candidate := SourceMileageSampleFromMetric(sample, identity)
baseline, found, err := w.previousBaseline(ctx, candidate)
if err != nil || !found || baseline.LatestTotalKM <= 0 || baseline.LatestEventTime.IsZero() {
return MetricSample{}, SourceMileageSample{}, false, err
}
sample.TotalMileageKM = baseline.LatestTotalKM
candidate = SourceMileageSampleFromMetric(sample, identity)
candidate.FirstTotalKM = baseline.LatestTotalKM
candidate.LatestTotalKM = baseline.LatestTotalKM
candidate.DailyKM = 0
candidate.FirstEventTime = baseline.LatestEventTime
candidate.LatestEventTime = eventTime
candidate.QualityStatus = QualityOK
candidate.QualityReason = QualityReasonStationaryCarry
return sample, candidate, true, nil
}
func (w *Writer) writeMileageSample(ctx context.Context, sample MetricSample, candidate SourceMileageSample, projectDaily bool) error {
if projectDaily {
if beginner, ok := w.exec.(txBeginner); ok {
tx, err := beginner.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := UpsertSourceMileage(ctx, tx, candidate); err != nil {
_ = tx.Rollback()
return err
}
if ShouldNormalizePlatformSourceMileage(candidate) {
if err := NormalizePlatformSourceMileage(ctx, tx, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
_ = tx.Rollback()
return err
}
}
if err := projectDailyMileageWithExec(ctx, tx, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
}
if err := UpsertSourceMileage(ctx, w.exec, candidate); err != nil {
return err
}
if projectDaily {
if ShouldNormalizePlatformSourceMileage(candidate) {
if err := NormalizePlatformSourceMileage(ctx, w.exec, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
return err
}
}
return ProjectDailyMileage(ctx, w.exec, sample.VIN, sample.StatDate, sample.Protocol)
}
return nil
}
func (w *Writer) sourceSeenAt(env envelope.FrameEnvelope) time.Time {
if env.ReceivedAtMS > 0 {
return time.UnixMilli(env.ReceivedAtMS).In(w.loc)
}
if eventMS, ok := statEventTimeMS(env); ok {
return time.UnixMilli(eventMS).In(w.loc)
}
return time.Now().In(w.loc)
}
func (w *Writer) shouldTouchSource(identity SourceIdentity, seenAt time.Time) bool {
key := string(identity.Protocol) + "|" + identity.SourceIP
w.mu.Lock()
defer w.mu.Unlock()
if w.sourceTouchInterval == 0 {
return true
}
last, ok := w.lastSourceSeen[key]
if ok && !seenAt.After(last.Add(w.sourceTouchInterval)) {
return false
}
return true
}
func (w *Writer) markSourceTouched(identity SourceIdentity, seenAt time.Time) {
key := string(identity.Protocol) + "|" + identity.SourceIP
w.mu.Lock()
w.lastSourceSeen[key] = seenAt
w.enforceCacheLimitsLocked()
w.mu.Unlock()
}
func (w *Writer) shouldProjectDailyMileage(sample MetricSample) bool {
key := projectionCacheKey(sample)
w.mu.Lock()
defer w.mu.Unlock()
interval := w.projectionInterval
if interval == 0 {
return true
}
entry, ok := w.lastProjection[key]
if !ok {
return true
}
if _, ok := entry.sourceKeys[sample.SourceKey]; !ok {
return true
}
return sample.EventTime.After(entry.projectedAt.Add(interval))
}
func (w *Writer) markProjected(sample MetricSample) {
key := projectionCacheKey(sample)
prefix := projectionCachePrefix(sample)
w.mu.Lock()
defer w.mu.Unlock()
w.deleteProjectionPrefixExceptLocked(prefix, key)
entry := w.lastProjection[key]
if entry.sourceKeys == nil {
entry.sourceKeys = map[string]struct{}{}
}
entry.sourceKeys[sample.SourceKey] = struct{}{}
entry.projectedAt = sample.EventTime
w.lastProjection[key] = entry
w.addCacheKeyLocked(w.projectionKeysByPrefix, prefix, key)
w.enforceCacheLimitsLocked()
}
func (w *Writer) seenSameMileage(sample MetricSample) bool {
key := mileageCacheKey(sample)
w.mu.Lock()
defer w.mu.Unlock()
if last, ok := w.lastTotalMileage[key]; ok && last == sample.TotalMileageKM {
return true
}
return false
}
func (w *Writer) markMileageWritten(sample MetricSample) {
prefix := mileageCachePrefix(sample)
key := mileageCacheKey(sample)
w.mu.Lock()
defer w.mu.Unlock()
w.deleteMileagePrefixExceptLocked(prefix, key)
w.deleteBaselinePrefixExceptLocked(prefix, key)
w.lastTotalMileage[key] = sample.TotalMileageKM
w.addCacheKeyLocked(w.mileageKeysByPrefix, prefix, key)
w.enforceCacheLimitsLocked()
}
func (w *Writer) maybeCleanupCaches(now time.Time) {
if now.IsZero() {
now = time.Now().In(w.loc)
}
w.mu.Lock()
defer w.mu.Unlock()
retention := w.cacheRetention
if retention <= 0 {
w.enforceCacheLimitsLocked()
return
}
interval := w.cacheCleanupInterval
if interval > 0 && !w.lastCacheCleanup.IsZero() && !now.After(w.lastCacheCleanup.Add(interval)) {
w.enforceCacheLimitsLocked()
return
}
w.lastCacheCleanup = now
cleanupStats := cacheCleanupStats{}
cutoff := now.Add(-retention)
cutoffDate := cutoff.In(w.loc).Format("2006-01-02")
for key, seenAt := range w.lastSourceSeen {
if !seenAt.IsZero() && seenAt.Before(cutoff) {
delete(w.lastSourceSeen, key)
cleanupStats.sourceSeen++
}
}
for key, entry := range w.lastProjection {
if entry.projectedAt.IsZero() || entry.projectedAt.Before(cutoff) || cacheKeyDateBefore(key, cutoffDate) {
w.deleteProjectionKeyLocked(key)
cleanupStats.projection++
}
}
for key := range w.lastTotalMileage {
if cacheKeyDateBefore(key, cutoffDate) {
w.deleteMileageKeyLocked(key)
cleanupStats.totalMileage++
}
}
for key := range w.baselineCache {
if cacheKeyDateBefore(key, cutoffDate) {
w.deleteBaselineKeyLocked(key)
cleanupStats.baseline++
}
}
w.lastCacheCleanupStats = cleanupStats
w.enforceCacheLimitsLocked()
}
func (w *Writer) applyRealtimeBaseline(ctx context.Context, candidate *SourceMileageSample) error {
if candidate == nil {
return nil
}
baseline, found, err := w.previousBaseline(ctx, *candidate)
if err != nil {
return err
}
if !found {
// If no earlier odometer exists at all, use the first current-day sample.
// Later samples retain that boundary through the in-memory baseline cache.
candidate.DailyKM = 0
candidate.QualityStatus = QualityOK
candidate.QualityReason = QualityReasonCurrentDayFirst
return nil
}
candidate.FirstTotalKM = baseline.LatestTotalKM
candidate.FirstEventTime = baseline.LatestEventTime
candidate.DailyKM = DailyMileageFromDayBoundary(baseline.LatestTotalKM, candidate.LatestTotalKM)
candidate.QualityStatus = QualityOK
candidate.QualityReason = baseline.QualityReason
if candidate.QualityReason == "" {
candidate.QualityReason = QualityReasonHistorical
}
ApplyMileageQualityRules(candidate)
return nil
}
func (w *Writer) previousBaseline(ctx context.Context, candidate SourceMileageSample) (sourceBaseline, bool, error) {
cacheKey := mileageCacheKey(MetricSample{
VIN: candidate.VIN,
Protocol: candidate.Protocol,
StatDate: candidate.StatDate,
SourceKey: candidate.SourceKey,
})
now := time.Now()
w.mu.Lock()
if cached, ok := w.baselineCache[cacheKey]; ok {
ttl := w.baselineMissTTL
if cached.found {
ttl = w.baselineHitTTL
}
expired := ttl == 0 || cached.cachedAt.IsZero() || now.Sub(cached.cachedAt) >= ttl
if !expired {
w.mu.Unlock()
return cached.baseline, cached.found, nil
}
w.deleteBaselineKeyLocked(cacheKey)
}
w.mu.Unlock()
baseline, found, err := lookupPreviousSourceBaseline(ctx, w.query, candidate.VIN, candidate.StatDate, candidate.Protocol, candidate.SourceKey)
if err != nil {
return sourceBaseline{}, false, err
}
if found {
w.cacheBaseline(candidate, cacheKey, sourceBaselineCacheEntry{baseline: baseline, found: true})
} else {
w.cacheBaseline(candidate, cacheKey, sourceBaselineCacheEntry{found: false})
}
return baseline, found, nil
}
func (w *Writer) markBaselineWritten(sample MetricSample, candidate SourceMileageSample) {
if candidate.QualityStatus != QualityOK {
return
}
baselineTotal := candidate.FirstTotalKM
if baselineTotal <= 0 {
baselineTotal = candidate.LatestTotalKM
}
baselineTime := candidate.FirstEventTime
if baselineTime.IsZero() {
baselineTime = candidate.LatestEventTime
}
if baselineTotal <= 0 || baselineTime.IsZero() {
return
}
cacheKey := mileageCacheKey(sample)
w.cacheBaseline(candidate, cacheKey, sourceBaselineCacheEntry{
baseline: sourceBaseline{
LatestTotalKM: baselineTotal,
LatestEventTime: baselineTime,
QualityReason: candidate.QualityReason,
},
found: true,
})
}
func (w *Writer) cacheBaseline(candidate SourceMileageSample, cacheKey string, entry sourceBaselineCacheEntry) {
if cacheKey == "" {
return
}
w.mu.Lock()
if previous, ok := w.baselineCache[cacheKey]; ok &&
previous.found && entry.found &&
previous.baseline.LatestTotalKM == entry.baseline.LatestTotalKM &&
previous.baseline.LatestEventTime.Equal(entry.baseline.LatestEventTime) &&
!previous.cachedAt.IsZero() {
// markBaselineWritten runs for every accepted sample. Preserve the
// original cache age when the durable day-boundary baseline has not
// changed, otherwise an active vehicle would keep stale history forever.
entry.cachedAt = previous.cachedAt
}
if entry.cachedAt.IsZero() {
entry.cachedAt = time.Now()
}
w.baselineCache[cacheKey] = entry
w.addCacheKeyLocked(w.baselineKeysByPrefix, mileageCachePrefix(MetricSample{
VIN: candidate.VIN,
Protocol: candidate.Protocol,
SourceKey: candidate.SourceKey,
}), cacheKey)
w.enforceCacheLimitsLocked()
w.mu.Unlock()
}
func (w *Writer) addCacheKeyLocked(index map[string]map[string]struct{}, prefix string, key string) {
if prefix == "" || key == "" {
return
}
keys := index[prefix]
if keys == nil {
keys = map[string]struct{}{}
index[prefix] = keys
}
keys[key] = struct{}{}
}
func (w *Writer) deleteMileagePrefixExceptLocked(prefix string, keep string) {
for key := range w.mileageKeysByPrefix[prefix] {
if key == keep {
continue
}
w.deleteMileageKeyLocked(key)
}
}
func (w *Writer) deleteBaselinePrefixExceptLocked(prefix string, keep string) {
for key := range w.baselineKeysByPrefix[prefix] {
if key == keep {
continue
}
w.deleteBaselineKeyLocked(key)
}
}
func (w *Writer) deleteProjectionPrefixExceptLocked(prefix string, keep string) {
for key := range w.projectionKeysByPrefix[prefix] {
if key == keep {
continue
}
w.deleteProjectionKeyLocked(key)
}
}
func (w *Writer) deleteMileageKeyLocked(key string) {
delete(w.lastTotalMileage, key)
w.deleteIndexedCacheKeyLocked(w.mileageKeysByPrefix, cacheKeyPrefix(key), key)
}
func (w *Writer) deleteBaselineKeyLocked(key string) {
delete(w.baselineCache, key)
w.deleteIndexedCacheKeyLocked(w.baselineKeysByPrefix, cacheKeyPrefix(key), key)
}
func (w *Writer) deleteProjectionKeyLocked(key string) {
delete(w.lastProjection, key)
w.deleteIndexedCacheKeyLocked(w.projectionKeysByPrefix, cacheKeyPrefix(key), key)
}
func (w *Writer) deleteIndexedCacheKeyLocked(index map[string]map[string]struct{}, prefix string, key string) {
if prefix == "" || key == "" {
return
}
keys := index[prefix]
if len(keys) == 0 {
return
}
delete(keys, key)
if len(keys) == 0 {
delete(index, prefix)
}
}
func (w *Writer) enforceCacheLimitsLocked() {
if w.maxCacheEntries <= 0 {
return
}
for len(w.lastTotalMileage) > w.maxCacheEntries {
victim := oldestCacheKeyByDate(w.lastTotalMileage)
if victim == "" {
break
}
w.deleteMileageKeyLocked(victim)
w.cacheEvictions.totalMileage++
}
for len(w.baselineCache) > w.maxCacheEntries {
victim := oldestCacheKeyByDate(w.baselineCache)
if victim == "" {
break
}
w.deleteBaselineKeyLocked(victim)
w.cacheEvictions.baseline++
}
for len(w.lastProjection) > w.maxCacheEntries {
victim := oldestProjectionCacheKey(w.lastProjection)
if victim == "" {
break
}
w.deleteProjectionKeyLocked(victim)
w.cacheEvictions.projection++
}
for len(w.lastSourceSeen) > w.maxCacheEntries {
victim := oldestSourceSeenKey(w.lastSourceSeen)
if victim == "" {
break
}
delete(w.lastSourceSeen, victim)
w.cacheEvictions.sourceSeen++
}
}
func mileageCachePrefix(sample MetricSample) string {
return fmt.Sprintf("%s|%s|%s|", sample.VIN, sample.Protocol, sample.SourceKey)
}
func mileageCacheKey(sample MetricSample) string {
return mileageCachePrefix(sample) + sample.StatDate
}
func projectionCacheKey(sample MetricSample) string {
return projectionCachePrefix(sample) + sample.StatDate
}
func projectionCachePrefix(sample MetricSample) string {
return fmt.Sprintf("%s|%s|", sample.VIN, sample.Protocol)
}
func cacheKeyDateBefore(key string, cutoffDate string) bool {
date := cacheKeyDate(key)
if len(date) != len("2006-01-02") {
return false
}
return date < cutoffDate
}
func cacheKeyPrefix(key string) string {
index := strings.LastIndex(key, "|")
if index < 0 {
return ""
}
return key[:index+1]
}
func cacheKeyDate(key string) string {
index := strings.LastIndex(key, "|")
if index < 0 || index == len(key)-1 {
return ""
}
return key[index+1:]
}
func oldestCacheKeyByDate[T any](items map[string]T) string {
victim := ""
victimDate := ""
for key := range items {
date := cacheKeyDate(key)
if date == "" {
date = "9999-99-99"
}
if victim == "" || date < victimDate || (date == victimDate && key < victim) {
victim = key
victimDate = date
}
}
return victim
}
func oldestProjectionCacheKey(items map[string]projectionCacheEntry) string {
victim := ""
var victimTime time.Time
for key, entry := range items {
if victim == "" ||
(!entry.projectedAt.IsZero() && (victimTime.IsZero() || entry.projectedAt.Before(victimTime))) ||
(entry.projectedAt.Equal(victimTime) && key < victim) {
victim = key
victimTime = entry.projectedAt
}
}
if victim != "" {
return victim
}
return oldestCacheKeyByDate(items)
}
func oldestSourceSeenKey(items map[string]time.Time) string {
victim := ""
var victimTime time.Time
for key, seenAt := range items {
if victim == "" ||
(!seenAt.IsZero() && (victimTime.IsZero() || seenAt.Before(victimTime))) ||
(seenAt.Equal(victimTime) && key < victim) {
victim = key
victimTime = seenAt
}
}
return victim
}
type projectionCacheEntry struct {
projectedAt time.Time
sourceKeys map[string]struct{}
}
type sourceBaselineCacheEntry struct {
baseline sourceBaseline
found bool
cachedAt time.Time
}
func SamplesFromEnvelope(env envelope.FrameEnvelope, loc *time.Location) ([]MetricSample, error) {
samples, _, err := samplesFromEnvelopeWithResult(env, loc)
return samples, err
}
func samplesFromEnvelopeWithResult(env envelope.FrameEnvelope, loc *time.Location) ([]MetricSample, AppendResult, error) {
var result AppendResult
if len(env.Fields) == 0 {
result.SamplesSkippedMissingFields = 1
return nil, result, nil
}
vin := strings.TrimSpace(env.VIN)
if vin == "" {
result.SamplesSkippedMissingVIN = 1
return nil, result, nil
}
totalMileage, ok := totalMileageKMFromEnvelope(env)
if !ok {
if isMileageCandidateEnvelope(env) {
result.SamplesSkippedMissingMileage = 1
} else {
result.SamplesSkippedNonMileageFrame = 1
}
return nil, result, nil
}
if totalMileage <= 0 {
result.SamplesSkippedNonPositiveMileage = 1
return nil, result, nil
}
if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
eventMS, reason, ok := envelope.NormalizedEventTimeMSWithReason(env)
if !ok {
result.SamplesSkippedMissingTime = 1
return nil, result, nil
}
if reason == envelope.EventTimeReasonReceivedFutureEvent {
result.SamplesAdjustedFutureEventTime = 1
}
statDate := time.UnixMilli(eventMS).In(loc).Format("2006-01-02")
result.SamplesFound = 1
return []MetricSample{{
VIN: vin,
Protocol: env.Protocol,
StatDate: statDate,
TotalMileageKM: totalMileage,
EventTime: time.UnixMilli(eventMS).In(loc),
SourceKey: sourceKey(env),
Phone: strings.TrimSpace(env.Phone),
DeviceID: strings.TrimSpace(env.DeviceID),
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
PlatformName: strings.TrimSpace(env.PlatformName),
}}, result, nil
}
func statEventTimeMS(env envelope.FrameEnvelope) (int64, bool) {
return envelope.NormalizedEventTimeMS(env)
}
func sourceKey(env envelope.FrameEnvelope) string {
return SourceKeyForSource(env.Protocol, env.Phone, env.DeviceID, NormalizeSourceIP(env.SourceEndpoint), env.SourceKind, env.SourceCode)
}
func isIgnorableSchemaChangeError(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "duplicate column") ||
strings.Contains(text, "1060") ||
strings.Contains(text, "duplicate key name") ||
strings.Contains(text, "1061") ||
strings.Contains(text, "can't drop") ||
strings.Contains(text, "1091")
}
type mileageFieldMapping = telemetry.MileageFieldMapping
func totalMileageKMFromEnvelope(env envelope.FrameEnvelope) (float64, bool) {
return telemetry.TotalMileageKM(env.Protocol, env.Fields)
}
func mileageMappingsByProtocol(protocol envelope.Protocol) []mileageFieldMapping {
return telemetry.MileageFieldMappings(protocol)
}
func isMileageCandidateEnvelope(env envelope.FrameEnvelope) bool {
switch env.Protocol {
case envelope.ProtocolJT808:
messageID := strings.TrimSpace(env.MessageID)
return strings.EqualFold(messageID, "0x0200") ||
hasFieldPrefix(env.Fields, "jt808.location.") ||
hasAnyField(env.Fields, envelope.FieldLatitude, envelope.FieldLongitude, envelope.FieldSpeedKMH)
case envelope.ProtocolGB32960:
return hasFieldPrefix(env.Fields, "gb32960.vehicle.") ||
hasAnyField(env.Fields, envelope.FieldLatitude, envelope.FieldLongitude, envelope.FieldSpeedKMH, envelope.FieldSOCPercent)
case envelope.ProtocolYutongMQTT:
return hasFieldPrefix(env.Fields, "yutong_mqtt.data.") ||
hasFieldPrefix(env.Fields, "yutong_mqtt.root.data.") ||
hasAnyField(env.Fields, envelope.FieldLatitude, envelope.FieldLongitude, envelope.FieldSpeedKMH, envelope.FieldSOCPercent)
default:
return true
}
}
func hasFieldPrefix(fields map[string]any, prefix string) bool {
if len(fields) == 0 || prefix == "" {
return false
}
for key := range fields {
if strings.HasPrefix(key, prefix) {
return true
}
}
return false
}
func hasAnyField(fields map[string]any, keys ...string) bool {
if len(fields) == 0 {
return false
}
for _, key := range keys {
if _, ok := fields[key]; ok {
return true
}
}
return false
}
func floatField(env envelope.FrameEnvelope, key string) (float64, bool) {
if env.Fields == nil {
return 0, false
}
value, ok := env.Fields[key]
if !ok || value == nil {
return 0, false
}
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case uint16:
return float64(typed), true
case uint32:
return float64(typed), true
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return parsed, err == nil
default:
return 0, false
}
}