milergeo added
This commit is contained in:
131
internal/milergeo/milergeo.go
Normal file
131
internal/milergeo/milergeo.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Package milergeo is the one place that reads and writes the live rider
|
||||
// positions in Redis (the `milers:locations` GEO set). Auto-assignment, the
|
||||
// customer pickup-slot check and the agent playground all search it; the rider
|
||||
// location ping and duty start write it.
|
||||
//
|
||||
// Why it exists: every caller used GEOSEARCH, which Redis only has from 6.2.
|
||||
// On an older server the writes (GEOADD) succeed but every search fails, and
|
||||
// the callers treated the failure as "no riders nearby" — so orders sat in
|
||||
// Pending with riders standing next to the pickup and nothing in the console
|
||||
// said why. Search now falls back to GEORADIUS (Redis 3.2+), which answers the
|
||||
// same question, and Probe reports at boot whether the search works at all.
|
||||
package milergeo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Key is the GEO set holding each rider's last reported position, member =
|
||||
// the rider's userid as a decimal string.
|
||||
const Key = "milers:locations"
|
||||
|
||||
// Client is the slice of the Redis client this package needs; *redis.Client
|
||||
// satisfies it. An interface so the fallback can be tested without a server.
|
||||
type Client interface {
|
||||
GeoAdd(ctx context.Context, key string, geoLocation ...*redis.GeoLocation) *redis.IntCmd
|
||||
GeoSearchLocation(ctx context.Context, key string, q *redis.GeoSearchLocationQuery) *redis.GeoSearchLocationCmd
|
||||
GeoRadius(ctx context.Context, key string, longitude, latitude float64, query *redis.GeoRadiusQuery) *redis.GeoLocationCmd
|
||||
}
|
||||
|
||||
// legacyOnly flips to true the first time the server rejects GEOSEARCH as an
|
||||
// unknown command, so later searches go straight to GEORADIUS instead of
|
||||
// paying for a failed round trip every time.
|
||||
var legacyOnly atomic.Bool
|
||||
|
||||
// Search returns the riders within radiusKm of (lat, lon), nearest first, at
|
||||
// most count of them, each with Dist (km) set. It uses GEOSEARCH and falls back
|
||||
// to GEORADIUS when the server is older than Redis 6.2.
|
||||
//
|
||||
// Any other error (timeout, WRONGTYPE on the key, ...) is returned as is: the
|
||||
// caller must not mistake a broken search for an empty street.
|
||||
func Search(ctx context.Context, rdb Client, lat, lon, radiusKm float64, count int) ([]redis.GeoLocation, error) {
|
||||
if isNil(rdb) {
|
||||
return nil, fmt.Errorf("redis not available")
|
||||
}
|
||||
if !legacyOnly.Load() {
|
||||
locs, err := rdb.GeoSearchLocation(ctx, Key, &redis.GeoSearchLocationQuery{
|
||||
GeoSearchQuery: redis.GeoSearchQuery{
|
||||
Longitude: lon,
|
||||
Latitude: lat,
|
||||
Radius: radiusKm,
|
||||
RadiusUnit: "km",
|
||||
Sort: "ASC",
|
||||
Count: count,
|
||||
},
|
||||
WithDist: true,
|
||||
}).Result()
|
||||
if err == nil {
|
||||
return locs, nil
|
||||
}
|
||||
if !isUnknownCommand(err) {
|
||||
return nil, fmt.Errorf("GEOSEARCH %s: %w", Key, err)
|
||||
}
|
||||
legacyOnly.Store(true)
|
||||
}
|
||||
locs, err := rdb.GeoRadius(ctx, Key, lon, lat, &redis.GeoRadiusQuery{
|
||||
Radius: radiusKm,
|
||||
Unit: "km",
|
||||
WithDist: true,
|
||||
Count: count,
|
||||
Sort: "ASC",
|
||||
}).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GEORADIUS %s: %w", Key, err)
|
||||
}
|
||||
return locs, nil
|
||||
}
|
||||
|
||||
// Index records a rider's position. The error is returned so callers can log
|
||||
// it — a failed write here is a rider auto-assignment can never find.
|
||||
func Index(ctx context.Context, rdb Client, milerUserID int, lat, lon float64) error {
|
||||
if isNil(rdb) {
|
||||
return fmt.Errorf("redis not available")
|
||||
}
|
||||
if err := rdb.GeoAdd(ctx, Key, &redis.GeoLocation{
|
||||
Name: fmt.Sprint(milerUserID),
|
||||
Latitude: lat,
|
||||
Longitude: lon,
|
||||
}).Err(); err != nil {
|
||||
return fmt.Errorf("GEOADD %s: %w", Key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Probe runs one search to report, at boot and on /ready, whether rider
|
||||
// search works on this Redis: "ok", "ok (GEORADIUS fallback: Redis older than
|
||||
// 6.2)", or "error: ...". It never fails the caller.
|
||||
func Probe(ctx context.Context, rdb Client) string {
|
||||
if isNil(rdb) {
|
||||
return "error: redis not available"
|
||||
}
|
||||
if _, err := Search(ctx, rdb, 11.0168, 76.9558, 1, 1); err != nil {
|
||||
return "error: " + err.Error()
|
||||
}
|
||||
if legacyOnly.Load() {
|
||||
return "ok (GEORADIUS fallback: Redis older than 6.2)"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// isNil also catches a nil *redis.Client inside the interface (db.Rdb before
|
||||
// InitRedis), which a plain == nil does not.
|
||||
func isNil(rdb Client) bool {
|
||||
if rdb == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(rdb)
|
||||
return v.Kind() == reflect.Ptr && v.IsNil()
|
||||
}
|
||||
|
||||
// isUnknownCommand: how Redis < 6.2 (and proxies that filter commands) answer
|
||||
// a command they do not have, e.g. "ERR unknown command 'GEOSEARCH'".
|
||||
func isUnknownCommand(err error) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unknown command")
|
||||
}
|
||||
Reference in New Issue
Block a user