Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/dailygeo/worker.go
T

298 lines
12 KiB
Go

package dailygeo
import (
"context"
"database/sql"
"fmt"
"log"
"math"
"regexp"
"sync"
"time"
)
var Shanghai = time.FixedZone("Asia/Shanghai", 8*3600)
var identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type Point struct {
VIN, Protocol string
Time time.Time
Longitude, Latitude float64
}
type Worker struct {
RequestInterval time.Duration
DB, TD *sql.DB
Database string
Resolver Resolver
Concurrency int
requestMu sync.Mutex
nextRequest time.Time
}
func New(db, td *sql.DB, database string, r Resolver) *Worker {
return &Worker{DB: db, TD: td, Database: database, Resolver: r, Concurrency: 3}
}
func validPoint(p Point) bool {
return p.VIN != "" && !(p.Longitude == -0.999999 && p.Latitude == -0.999999) && !p.Time.IsZero() && p.Longitude != 0 && p.Latitude != 0 && !math.IsNaN(p.Longitude) && !math.IsNaN(p.Latitude) && math.Abs(p.Longitude) <= 180 && math.Abs(p.Latitude) <= 90
}
func newer(a, b Point) bool {
return a.Time.After(b.Time) || (a.Time.Equal(b.Time) && a.Protocol < b.Protocol)
}
// Last() is applied only to non-null valid coordinates, partitioned by VIN and
// protocol. All selected columns therefore refer to the same final source row.
func (w *Worker) RefreshDay(ctx context.Context, date string) (int, error) {
day, e := time.ParseInLocation("2006-01-02", date, Shanghai)
if e != nil {
return 0, e
}
if w.TD == nil || !identifier.MatchString(w.Database) {
return 0, fmt.Errorf("historical location store unavailable")
}
q := fmt.Sprintf(`SELECT vin,protocol,LAST(ts),LAST(longitude),LAST(latitude) FROM %s.vehicle_locations WHERE ts >= '%s' AND ts < '%s' AND NOT (longitude = -0.999999 AND latitude = -0.999999) AND longitude <> 0 AND longitude BETWEEN -180 AND 180 AND latitude <> 0 AND latitude BETWEEN -90 AND 90 PARTITION BY vin,protocol`, w.Database, day.Format(time.RFC3339), day.AddDate(0, 0, 1).Format(time.RFC3339))
rows, e := w.TD.QueryContext(ctx, q)
if e != nil {
return 0, e
}
points := map[string]Point{}
for rows.Next() {
var p Point
if e = rows.Scan(&p.VIN, &p.Protocol, &p.Time, &p.Longitude, &p.Latitude); e != nil {
rows.Close()
return 0, e
}
if !validPoint(p) || p.Time.In(Shanghai).Format("2006-01-02") != date {
continue
}
if old, ok := points[p.VIN]; !ok || newer(p, old) {
points[p.VIN] = p
}
}
e = rows.Err()
rows.Close()
if e != nil {
return 0, e
}
// Repair the device's invalid-location sentinel before applying real points.
// Clearing its timestamp allows an earlier valid point from this day to win.
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET longitude=NULL,latitude=NULL,location_time=NULL,province='',city='',region='',adcode='',status='NO_LOCATION',attempts=0,last_error='',next_attempt_at=NULL,resolved_at=NULL WHERE stat_date=? AND longitude=-0.999999 AND latitude=-0.999999`, date)
if e != nil {
return 0, e
}
for _, p := range points {
if e = w.SavePoint(ctx, p); e != nil {
return 0, e
}
}
// A successful historical lookup with no point is distinct from an unavailable
// history service. Never record NO_LOCATION when the lookup itself failed.
_, e = w.DB.ExecContext(ctx, `INSERT IGNORE INTO vehicle_daily_geography(vin,stat_date,status) SELECT vin,stat_date,'NO_LOCATION' FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN' AND stat_date=?`, date)
return len(points), e
}
const savePointSQL = `INSERT INTO vehicle_daily_geography(vin,stat_date,longitude,latitude,location_time,source_protocol,status) VALUES(?,?,?,?,?,?,'PENDING') ON DUPLICATE KEY UPDATE
province=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',province),
city=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',city),
region=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',region),
adcode=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',adcode),
status=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'PENDING',status),
attempts=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),0,attempts),
next_attempt_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,next_attempt_at),
last_error=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',last_error),
resolved_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,resolved_at),
longitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(longitude),longitude),
latitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(latitude),latitude),
source_protocol=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(source_protocol),source_protocol),
location_time=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(location_time),location_time)`
func (w *Worker) SavePoint(ctx context.Context, p Point) error {
if !validPoint(p) {
return fmt.Errorf("invalid historical location")
}
_, e := w.DB.ExecContext(ctx, savePointSQL, p.VIN, p.Time.In(Shanghai).Format("2006-01-02"), math.Round(p.Longitude*1e6)/1e6, math.Round(p.Latitude*1e6)/1e6, p.Time.In(Shanghai), p.Protocol)
return e
}
type pending struct {
VIN, Date string
Longitude, Latitude float64
}
func (w *Worker) ResolvePending(ctx context.Context, from, to string, limit int) (int, int, error) {
if limit <= 0 || limit > 5000 {
limit = 500
}
rows, e := w.DB.QueryContext(ctx, `SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),longitude,latitude FROM vehicle_daily_geography WHERE stat_date BETWEEN ? AND ? AND status IN ('PENDING','ERROR') AND (next_attempt_at IS NULL OR next_attempt_at<=NOW(3)) ORDER BY stat_date DESC,vin LIMIT ?`, from, to, limit)
if e != nil {
return 0, 0, e
}
items := []pending{}
for rows.Next() {
var p pending
if e = rows.Scan(&p.VIN, &p.Date, &p.Longitude, &p.Latitude); e != nil {
rows.Close()
return 0, 0, e
}
items = append(items, p)
}
e = rows.Err()
rows.Close()
if e != nil {
return 0, 0, e
}
jobs := make(chan pending, len(items))
for _, p := range items {
jobs <- p
}
close(jobs)
count := w.Concurrency
if count < 1 {
count = 1
}
if count > 8 {
count = 8
}
var mu sync.Mutex
var wg sync.WaitGroup
ok, failed := 0, 0
var first error
for i := 0; i < count; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for p := range jobs {
if ctx.Err() != nil {
return
}
e := w.resolveOne(ctx, p)
mu.Lock()
if e == nil {
ok++
} else {
failed++
if first == nil {
first = e
}
}
mu.Unlock()
}
}()
}
wg.Wait()
if ctx.Err() != nil {
return ok, failed, ctx.Err()
}
return ok, failed, first
}
func (w *Worker) resolveOne(ctx context.Context, p pending) error {
var a Address
e := w.DB.QueryRowContext(ctx, `SELECT province,city,region,adcode FROM vehicle_geography_cache WHERE longitude=? AND latitude=? AND resolved_at>=DATE_SUB(NOW(),INTERVAL 180 DAY)`, p.Longitude, p.Latitude).Scan(&a.Province, &a.City, &a.Region, &a.Adcode)
if e != nil && e != sql.ErrNoRows {
return e
}
if e == sql.ErrNoRows {
// At most three external calls in flight by default; quota/network failures
// are persisted and retried with backoff, never exported as a guessed city.
w.requestMu.Lock()
delay := time.Until(w.nextRequest)
if delay > 0 {
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
w.requestMu.Unlock()
return ctx.Err()
case <-timer.C:
}
}
interval := w.RequestInterval
if interval <= 0 {
interval = 20 * time.Millisecond
}
w.nextRequest = time.Now().Add(interval)
w.requestMu.Unlock()
rctx, cancel := context.WithTimeout(ctx, 12*time.Second)
a, e = w.Resolver.Resolve(rctx, p.Longitude, p.Latitude)
cancel()
if e != nil {
_, saveErr := w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET status='ERROR',attempts=attempts+1,last_error=?,next_attempt_at=DATE_ADD(NOW(3),INTERVAL LEAST(3600,60*POW(2,LEAST(attempts,6))) SECOND) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, e.Error(), p.VIN, p.Date, p.Longitude, p.Latitude)
if saveErr != nil {
return saveErr
}
return e
}
_, e = w.DB.ExecContext(ctx, `INSERT INTO vehicle_geography_cache(longitude,latitude,province,city,region,adcode) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE province=VALUES(province),city=VALUES(city),region=VALUES(region),adcode=VALUES(adcode),resolved_at=NOW(3)`, p.Longitude, p.Latitude, a.Province, a.City, a.Region, a.Adcode)
if e != nil {
return e
}
}
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET province=?,city=?,region=?,adcode=?,status='RESOLVED',last_error='',attempts=0,next_attempt_at=NULL,resolved_at=NOW(3) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, a.Province, a.City, a.Region, a.Adcode, p.VIN, p.Date, p.Longitude, p.Latitude)
return e
}
// An advisory lock is tied to this dedicated connection and released even on
// cancellation/crash, preventing multiple API replicas from running the loop.
func (w *Worker) WithLock(ctx context.Context, name string, fn func() error) error {
conn, e := w.DB.Conn(ctx)
if e != nil {
return e
}
defer conn.Close()
var acquired int
if e = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,0)`, name).Scan(&acquired); e != nil {
return e
}
if acquired != 1 {
return fmt.Errorf("daily geography worker already running")
}
defer conn.ExecContext(context.Background(), `SELECT RELEASE_LOCK(?)`, name)
return fn()
}
func (w *Worker) Run(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
round := 0
for {
e := w.WithLock(ctx, "vehicle_daily_geography_live", func() error {
today := time.Now().In(Shanghai)
date := today.Format("2006-01-02")
rctx, c := context.WithTimeout(ctx, 50*time.Second)
defer c()
if _, e := w.RefreshDay(rctx, date); e != nil {
return e
}
if round%60 == 0 {
for d := 1; d <= 2; d++ {
if _, e := w.RefreshDay(rctx, today.AddDate(0, 0, -d).Format("2006-01-02")); e != nil {
return e
}
}
}
n, failed, e := w.ResolvePending(rctx, today.AddDate(0, 0, -2).Format("2006-01-02"), date, 500)
log.Printf("daily_geography resolved=%d failed=%d", n, failed)
if e != nil {
return e
}
// Historical retry is independent of the live queue. The same lock used
// by the CLI prevents duplicate external calls during a bulk backfill.
_ = w.WithLock(rctx, "vehicle_daily_geography_backfill", func() error {
n, failed, e := w.ResolvePending(rctx, "2000-01-01", today.AddDate(0, 0, -3).Format("2006-01-02"), 200)
if n+failed > 0 {
log.Printf("daily_geography_history_retry resolved=%d failed=%d error=%v", n, failed, e)
}
return e
})
return nil
})
if e != nil {
log.Printf("daily_geography: %v", e)
}
round++
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}