fix(stats): flush throttled daily mileage projections
This commit is contained in:
@@ -3,7 +3,9 @@ package stats
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -44,6 +46,7 @@ type Writer struct {
|
||||
lastTotalMileage map[string]float64
|
||||
lastSourceSeen map[string]time.Time
|
||||
lastProjection map[string]projectionCacheEntry
|
||||
pendingProjection map[string]pendingProjectionEntry
|
||||
baselineCache map[string]sourceBaselineCacheEntry
|
||||
mileageKeysByPrefix map[string]map[string]struct{}
|
||||
projectionKeysByPrefix map[string]map[string]struct{}
|
||||
@@ -86,21 +89,28 @@ type AppendResult struct {
|
||||
ProjectionsSkippedThrottled int
|
||||
}
|
||||
|
||||
type ProjectionFlushResult struct {
|
||||
Attempted int
|
||||
Written int
|
||||
Failed 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
|
||||
LastTotalMileageEntries int
|
||||
LastSourceSeenEntries int
|
||||
LastProjectionEntries int
|
||||
PendingProjectionEntries 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 {
|
||||
@@ -130,6 +140,7 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
|
||||
lastTotalMileage: map[string]float64{},
|
||||
lastSourceSeen: map[string]time.Time{},
|
||||
lastProjection: map[string]projectionCacheEntry{},
|
||||
pendingProjection: map[string]pendingProjectionEntry{},
|
||||
baselineCache: map[string]sourceBaselineCacheEntry{},
|
||||
mileageKeysByPrefix: map[string]map[string]struct{}{},
|
||||
projectionKeysByPrefix: map[string]map[string]struct{}{},
|
||||
@@ -209,20 +220,21 @@ 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,
|
||||
LastTotalMileageEntries: len(w.lastTotalMileage),
|
||||
LastSourceSeenEntries: len(w.lastSourceSeen),
|
||||
LastProjectionEntries: len(w.lastProjection),
|
||||
PendingProjectionEntries: len(w.pendingProjection),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +339,8 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
||||
if projectDaily {
|
||||
w.markProjected(sample)
|
||||
result.ProjectionsWritten++
|
||||
} else {
|
||||
w.markProjectionPending(sample, time.Now())
|
||||
}
|
||||
w.markMileageWritten(sample)
|
||||
result.SamplesWritten++
|
||||
@@ -474,12 +488,94 @@ func (w *Writer) markProjected(sample MetricSample) {
|
||||
entry.sourceKeys = map[string]struct{}{}
|
||||
}
|
||||
entry.sourceKeys[sample.SourceKey] = struct{}{}
|
||||
entry.projectedAt = sample.EventTime
|
||||
if entry.projectedAt.IsZero() || sample.EventTime.After(entry.projectedAt) {
|
||||
entry.projectedAt = sample.EventTime
|
||||
}
|
||||
w.lastProjection[key] = entry
|
||||
if pending, ok := w.pendingProjection[key]; ok && !pending.sample.EventTime.After(sample.EventTime) {
|
||||
delete(w.pendingProjection, key)
|
||||
}
|
||||
w.addCacheKeyLocked(w.projectionKeysByPrefix, prefix, key)
|
||||
w.enforceCacheLimitsLocked()
|
||||
}
|
||||
|
||||
func (w *Writer) markProjectionPending(sample MetricSample, queuedAt time.Time) {
|
||||
key := projectionCacheKey(sample)
|
||||
prefix := projectionCachePrefix(sample)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
current, ok := w.pendingProjection[key]
|
||||
if !ok || sample.EventTime.After(current.sample.EventTime) ||
|
||||
(sample.EventTime.Equal(current.sample.EventTime) && queuedAt.After(current.queuedAt)) {
|
||||
w.pendingProjection[key] = pendingProjectionEntry{sample: sample, queuedAt: queuedAt}
|
||||
}
|
||||
w.addCacheKeyLocked(w.projectionKeysByPrefix, prefix, key)
|
||||
w.enforceCacheLimitsLocked()
|
||||
}
|
||||
|
||||
// FlushPendingProjections closes the eventual-consistency gap introduced by
|
||||
// projection throttling. Source candidates are persisted immediately, while
|
||||
// final projections are flushed after their throttle window even when a
|
||||
// vehicle stops reporting before another message can trigger projection.
|
||||
func (w *Writer) FlushPendingProjections(ctx context.Context, readyBefore time.Time) (ProjectionFlushResult, error) {
|
||||
type pendingItem struct {
|
||||
key string
|
||||
entry pendingProjectionEntry
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
items := make([]pendingItem, 0, len(w.pendingProjection))
|
||||
for key, entry := range w.pendingProjection {
|
||||
if readyBefore.IsZero() || !entry.queuedAt.After(readyBefore) {
|
||||
items = append(items, pendingItem{key: key, entry: entry})
|
||||
}
|
||||
}
|
||||
w.mu.Unlock()
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].key < items[j].key
|
||||
})
|
||||
|
||||
var result ProjectionFlushResult
|
||||
var flushErr error
|
||||
for _, item := range items {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, errors.Join(flushErr, err)
|
||||
}
|
||||
result.Attempted++
|
||||
if err := w.projectPendingMileage(ctx, item.entry.sample); err != nil {
|
||||
result.Failed++
|
||||
flushErr = errors.Join(flushErr, fmt.Errorf("project pending mileage %s: %w", item.key, err))
|
||||
continue
|
||||
}
|
||||
w.markProjected(item.entry.sample)
|
||||
result.Written++
|
||||
}
|
||||
return result, flushErr
|
||||
}
|
||||
|
||||
func (w *Writer) projectPendingMileage(ctx context.Context, sample MetricSample) error {
|
||||
project := func(exec Execer) error {
|
||||
if strings.Contains(sample.SourceKey, platformSourceKeyPrefix) {
|
||||
if err := NormalizePlatformSourceMileage(ctx, exec, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return projectDailyMileageWithExec(ctx, exec, sample.VIN, sample.StatDate, sample.Protocol)
|
||||
}
|
||||
if beginner, ok := w.exec.(txBeginner); ok {
|
||||
tx, err := beginner.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := project(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
return project(w.exec)
|
||||
}
|
||||
|
||||
func (w *Writer) seenSameMileage(sample MetricSample) bool {
|
||||
key := mileageCacheKey(sample)
|
||||
w.mu.Lock()
|
||||
@@ -719,6 +815,7 @@ func (w *Writer) deleteBaselineKeyLocked(key string) {
|
||||
|
||||
func (w *Writer) deleteProjectionKeyLocked(key string) {
|
||||
delete(w.lastProjection, key)
|
||||
delete(w.pendingProjection, key)
|
||||
w.deleteIndexedCacheKeyLocked(w.projectionKeysByPrefix, cacheKeyPrefix(key), key)
|
||||
}
|
||||
|
||||
@@ -866,6 +963,11 @@ type projectionCacheEntry struct {
|
||||
sourceKeys map[string]struct{}
|
||||
}
|
||||
|
||||
type pendingProjectionEntry struct {
|
||||
sample MetricSample
|
||||
queuedAt time.Time
|
||||
}
|
||||
|
||||
type sourceBaselineCacheEntry struct {
|
||||
baseline sourceBaseline
|
||||
found bool
|
||||
|
||||
Reference in New Issue
Block a user