298 lines
12 KiB
Go
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:
|
|
}
|
|
}
|
|
}
|