// 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") }