geo: haversine and the nearest-region pick with deterministic ties (nearest-region §6.3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+134
−0
internal/geo/nearest.go+55 −0internal/geo/nearest_test.go+56 −0internal/geo/point.go+23 −0
diff --git a/internal/geo/nearest.go b/internal/geo/nearest.go| new file mode 100644 |
| index 000000000..cdb7ac9bf |
| --- /dev/null |
| +++ b/internal/geo/nearest.go |
| @@ -0,0 +1,55 @@ |
| +package geo |
| + |
| +import "math" |
| + |
| +// Candidate is one region a guess may pick. Code is in the record |
| +// vocabulary ("" is the deployment's home); the caller has already |
| +// filtered to eligible, policy-allowed candidates. |
| +type Candidate struct { |
| + Code string |
| + Home bool |
| + AWSRegion string |
| +} |
| + |
| +// NearestWith returns the candidate nearest p, measured to each |
| +// candidate's AWS region through pointOf. |
| +// |
| +// - A candidate whose region has no point is left out. |
| +// - If the HOME is among cands and has no point, ok is false: picking |
| +// among the rest could choose a far region over an unmeasured near |
| +// home, and the home is where today's behaviour lives (spec §6.3). |
| +// - Distances are compared in whole kilometres. A tie goes to the home, |
| +// then to the lexically smallest code, so every console answers alike. |
| +func NearestWith(p Point, cands []Candidate, pointOf func(string) (Point, bool)) (code string, ok bool) { |
| + best := -1 |
| + bestKm := math.MaxInt |
| + for i, c := range cands { |
| + cp, has := pointOf(c.AWSRegion) |
| + if !has { |
| + if c.Home { |
| + return "", false |
| + } |
| + continue |
| + } |
| + km := int(math.Round(DistanceKm(p, cp))) |
| + switch { |
| + case best < 0, km < bestKm: |
| + case km == bestKm && better(c, cands[best]): |
| + default: |
| + continue |
| + } |
| + best, bestKm = i, km |
| + } |
| + if best < 0 { |
| + return "", false |
| + } |
| + return cands[best].Code, true |
| +} |
| + |
| +// better breaks an exact tie: the home first, then the smaller code. |
| +func better(a, b Candidate) bool { |
| + if a.Home != b.Home { |
| + return a.Home |
| + } |
| + return a.Code < b.Code |
| +} |
diff --git a/internal/geo/nearest_test.go b/internal/geo/nearest_test.go| new file mode 100644 |
| index 000000000..61bcb61c0 |
| --- /dev/null |
| +++ b/internal/geo/nearest_test.go |
| @@ -0,0 +1,56 @@ |
| +package geo |
| + |
| +import ( |
| + "math" |
| + "testing" |
| +) |
| + |
| +var testPoints = map[string]Point{ |
| + "eu-west-1": {53.35, -6.26}, |
| + "eu-west-2": {51.51, -0.13}, |
| + "ap-southeast-2": {-33.87, 151.21}, |
| +} |
| + |
| +func testPointOf(r string) (Point, bool) { p, ok := testPoints[r]; return p, ok } |
| + |
| +func TestDistanceKmDublinLondon(t *testing.T) { |
| + d := DistanceKm(testPoints["eu-west-1"], testPoints["eu-west-2"]) |
| + if math.Abs(d-464) > 5 { |
| + t.Fatalf("Dublin to London = %.1f km, want about 464", d) |
| + } |
| +} |
| + |
| +func TestNearest(t *testing.T) { |
| + london := Point{51.5, -0.12} |
| + sydney := Point{-33.9, 151.2} |
| + home := Candidate{Code: "", Home: true, AWSRegion: "eu-west-1"} |
| + uk := Candidate{Code: "uk", AWSRegion: "eu-west-2"} |
| + au := Candidate{Code: "au", AWSRegion: "ap-southeast-2"} |
| + |
| + cases := []struct { |
| + name string |
| + p Point |
| + cands []Candidate |
| + want string |
| + wantOK bool |
| + }{ |
| + {"single", london, []Candidate{home}, "", true}, |
| + {"clear winner", london, []Candidate{home, uk, au}, "uk", true}, |
| + {"far side", sydney, []Candidate{home, uk, au}, "au", true}, |
| + {"home with no point gives no answer", london, |
| + []Candidate{{Code: "", Home: true, AWSRegion: ""}, uk}, "", false}, |
| + {"non-home with no point is left out", london, |
| + []Candidate{home, {Code: "zz", AWSRegion: "xx-nowhere-1"}}, "", true}, |
| + {"empty", london, nil, "", false}, |
| + {"tie goes to the home", testPoints["eu-west-2"], |
| + []Candidate{{Code: "uk2", AWSRegion: "eu-west-2"}, {Code: "", Home: true, AWSRegion: "eu-west-2"}}, "", true}, |
| + {"non-home tie goes to the smaller code", testPoints["eu-west-2"], |
| + []Candidate{{Code: "uk2", AWSRegion: "eu-west-2"}, {Code: "uk", AWSRegion: "eu-west-2"}}, "uk", true}, |
| + } |
| + for _, c := range cases { |
| + got, ok := NearestWith(c.p, c.cands, testPointOf) |
| + if got != c.want || ok != c.wantOK { |
| + t.Errorf("%s: got (%q, %v), want (%q, %v)", c.name, got, ok, c.want, c.wantOK) |
| + } |
| + } |
| +} |
diff --git a/internal/geo/point.go b/internal/geo/point.go| new file mode 100644 |
| index 000000000..e374cbf91 |
| --- /dev/null |
| +++ b/internal/geo/point.go |
| @@ -0,0 +1,23 @@ |
| +// Package geo turns a timezone or an IP into a point on the globe and picks |
| +// the nearest of a set of AWS regions (nearest-region spec §6). It holds |
| +// points only for the length of one call: nothing here stores, logs or |
| +// returns a caller's coordinates. |
| +package geo |
| + |
| +import "math" |
| + |
| +// Point is a latitude and longitude in degrees. |
| +type Point struct{ Lat, Lon float64 } |
| + |
| +// earthRadiusKm is the IUGG mean Earth radius. |
| +const earthRadiusKm = 6371.0088 |
| + |
| +// DistanceKm is the great-circle (haversine) distance between a and b. |
| +func DistanceKm(a, b Point) float64 { |
| + rad := func(d float64) float64 { return d * math.Pi / 180 } |
| + dLat := rad(b.Lat - a.Lat) |
| + dLon := rad(b.Lon - a.Lon) |
| + h := math.Sin(dLat/2)*math.Sin(dLat/2) + |
| + math.Cos(rad(a.Lat))*math.Cos(rad(b.Lat))*math.Sin(dLon/2)*math.Sin(dLon/2) |
| + return 2 * earthRadiusKm * math.Asin(math.Sqrt(h)) |
| +} |