fix: tighten source-aware mileage projection
This commit is contained in:
@@ -19,9 +19,11 @@ type Execer interface {
|
||||
|
||||
type Writer struct {
|
||||
exec Execer
|
||||
query Queryer
|
||||
loc *time.Location
|
||||
mu sync.Mutex
|
||||
lastTotalMileage map[string]float64
|
||||
baselineCache map[string]sourceBaselineCacheEntry
|
||||
}
|
||||
|
||||
type MetricSample struct {
|
||||
@@ -43,7 +45,16 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
return &Writer{exec: exec, loc: loc, lastTotalMileage: map[string]float64{}}
|
||||
writer := &Writer{
|
||||
exec: exec,
|
||||
loc: loc,
|
||||
lastTotalMileage: map[string]float64{},
|
||||
baselineCache: map[string]sourceBaselineCacheEntry{},
|
||||
}
|
||||
if query, ok := exec.(Queryer); ok {
|
||||
writer.query = query
|
||||
}
|
||||
return writer
|
||||
}
|
||||
|
||||
func (w *Writer) EnsureSchema(ctx context.Context) error {
|
||||
@@ -79,6 +90,9 @@ func (w *Writer) Append(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
return err
|
||||
}
|
||||
candidate := SourceMileageSampleFromMetric(sample, identity)
|
||||
if err := w.applyRealtimeBaseline(ctx, &candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := UpsertSourceMileage(ctx, w.exec, candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -92,8 +106,7 @@ func (w *Writer) Append(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
}
|
||||
|
||||
func (w *Writer) seenSameMileage(sample MetricSample) bool {
|
||||
prefix := fmt.Sprintf("%s|%s|", sample.VIN, sample.Protocol)
|
||||
key := prefix + sample.StatDate
|
||||
key := mileageCacheKey(sample)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if last, ok := w.lastTotalMileage[key]; ok && last == sample.TotalMileageKM {
|
||||
@@ -103,8 +116,8 @@ func (w *Writer) seenSameMileage(sample MetricSample) bool {
|
||||
}
|
||||
|
||||
func (w *Writer) markMileageWritten(sample MetricSample) {
|
||||
prefix := fmt.Sprintf("%s|%s|", sample.VIN, sample.Protocol)
|
||||
key := prefix + sample.StatDate
|
||||
prefix := mileageCachePrefix(sample)
|
||||
key := mileageCacheKey(sample)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for existing := range w.lastTotalMileage {
|
||||
@@ -112,9 +125,77 @@ func (w *Writer) markMileageWritten(sample MetricSample) {
|
||||
delete(w.lastTotalMileage, existing)
|
||||
}
|
||||
}
|
||||
for existing := range w.baselineCache {
|
||||
if strings.HasPrefix(existing, prefix) && existing != key {
|
||||
delete(w.baselineCache, existing)
|
||||
}
|
||||
}
|
||||
w.lastTotalMileage[key] = sample.TotalMileageKM
|
||||
}
|
||||
|
||||
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 {
|
||||
candidate.QualityStatus = QualityNoPreviousBaseline
|
||||
candidate.QualityReason = "missing_previous_source"
|
||||
return nil
|
||||
}
|
||||
candidate.FirstTotalKM = baseline.LatestTotalKM
|
||||
candidate.FirstEventTime = baseline.LatestEventTime
|
||||
candidate.DailyKM = candidate.LatestTotalKM - baseline.LatestTotalKM
|
||||
candidate.QualityStatus = QualityOK
|
||||
candidate.QualityReason = "same_source_previous_day"
|
||||
if candidate.DailyKM < 0 || candidate.DailyKM > maxSelectedDailyMileageKM {
|
||||
candidate.QualityStatus = QualityInvalidDelta
|
||||
candidate.QualityReason = "outside_daily_range"
|
||||
}
|
||||
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,
|
||||
})
|
||||
w.mu.Lock()
|
||||
if cached, ok := w.baselineCache[cacheKey]; ok {
|
||||
w.mu.Unlock()
|
||||
return cached.baseline, cached.found, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.baselineCache[cacheKey] = sourceBaselineCacheEntry{baseline: baseline, found: found}
|
||||
w.mu.Unlock()
|
||||
return baseline, found, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type sourceBaselineCacheEntry struct {
|
||||
baseline sourceBaseline
|
||||
found bool
|
||||
}
|
||||
|
||||
func SamplesFromEnvelope(env envelope.FrameEnvelope, loc *time.Location) ([]MetricSample, error) {
|
||||
vin := strings.TrimSpace(env.VIN)
|
||||
if vin == "" {
|
||||
|
||||
Reference in New Issue
Block a user