Skip to content

Commit ab21191

Browse files
Merge pull request #209 from codecrafters-io/eddie/redis-geospatial
Redis Geospatial Stages
2 parents 8feec09 + 4311bee commit ab21191

38 files changed

+5591
-753
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,4 @@ jobs:
4545
- uses: dominikh/[email protected]
4646
with:
4747
version: "2025.1.1"
48-
install-go: false
48+
install-go: false

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ test_zset_with_redis: build
8282
CODECRAFTERS_TEST_CASES_JSON="[{\"slug\":\"ct1\",\"tester_log_prefix\":\"stage-701\",\"title\":\"Stage #701: ZADD-1\"},{\"slug\":\"hf1\",\"tester_log_prefix\":\"stage-702\",\"title\":\"Stage #702: ZADD-2\"}, {\"slug\":\"lg6\",\"tester_log_prefix\":\"stage-703\",\"title\":\"Stage #703: ZRANK\"}, {\"slug\":\"ic1\",\"tester_log_prefix\":\"stage-704\",\"title\":\"Stage #704: ZRANGE-1\"}, {\"slug\":\"bj4\",\"tester_log_prefix\":\"stage-705\",\"title\":\"Stage #705: ZRANGE-2\"}, {\"slug\":\"kn4\",\"tester_log_prefix\":\"stage-706\",\"title\":\"Stage #706: ZCARD\"}, {\"slug\":\"gd7\",\"tester_log_prefix\":\"stage-707\",\"title\":\"Stage #707: ZSCORE\"}, {\"slug\":\"sq7\",\"tester_log_prefix\":\"stage-708\",\"title\":\"Stage #708: ZREM\"} ]" \
8383
dist/main.out
8484

85+
test_geospatial_with_redis: build
86+
CODECRAFTERS_REPOSITORY_DIR=./internal/test_helpers/pass_all \
87+
CODECRAFTERS_TEST_CASES_JSON="[{\"slug\":\"zt4\",\"tester_log_prefix\":\"stage-801\",\"title\":\"Stage #801: GEOADD-1\"},{\"slug\":\"ck3\",\"tester_log_prefix\":\"stage-802\",\"title\":\"Stage #802: GEOADD-2\"}, {\"slug\":\"tn5\",\"tester_log_prefix\":\"stage-803\",\"title\":\"Stage #803: GEOADD-3\"}, {\"slug\":\"cr3\",\"tester_log_prefix\":\"stage-804\",\"title\":\"Stage #804: GEOADD-4\"}, {\"slug\":\"xg4\",\"tester_log_prefix\":\"stage-805\",\"title\":\"Stage #805: GEOPOS-1\"}, {\"slug\":\"hb5\",\"tester_log_prefix\":\"stage-806\",\"title\":\"Stage #806: GEOPOS-2\"}, {\"slug\":\"ek6\",\"tester_log_prefix\":\"stage-807\",\"title\":\"Stage #807: GEODIST\"}, {\"slug\":\"rm9\",\"tester_log_prefix\":\"stage-808\",\"title\":\"Stage #808: GEOSEARCH\"}]" \
88+
dist/main.out
89+
8590
test_all_with_redis:
8691
make test_base_with_redis || true
8792
make test_repl_with_redis || true

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.24
55
toolchain go1.24.2
66

77
require (
8-
github.com/codecrafters-io/tester-utils v0.4.8-0.20250812094029-ee2952f64984
8+
github.com/codecrafters-io/tester-utils v0.4.8-0.20250822020829-f572c78c46fc
99
github.com/hdt3213/rdb v1.2.0
1010
github.com/stretchr/testify v1.10.0
1111
github.com/tidwall/pretty v1.2.1

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F
66
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
77
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
88
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
9-
github.com/codecrafters-io/tester-utils v0.4.8-0.20250812094029-ee2952f64984 h1:daR57dTad/ZGTCOklh9syN6WXxqKQivkFjia4/nBCL4=
10-
github.com/codecrafters-io/tester-utils v0.4.8-0.20250812094029-ee2952f64984/go.mod h1:Fyrv4IebzjWtvKfpYf8ooYDoOtjYe2qx8bV7KAJpX+w=
9+
github.com/codecrafters-io/tester-utils v0.4.8-0.20250822020829-f572c78c46fc h1:IeVtegwreCuVUvx7THUROaEUEyNkM7pGMjvYBK6ImLU=
10+
github.com/codecrafters-io/tester-utils v0.4.8-0.20250822020829-f572c78c46fc/go.mod h1:Fyrv4IebzjWtvKfpYf8ooYDoOtjYe2qx8bV7KAJpX+w=
1111
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1212
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1313
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package location
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"strconv"
7+
)
8+
9+
const (
10+
LATITUDE_MAX = 85.05112878
11+
LONGITUDE_MAX = 180.0
12+
LATITUDE_MIN = -LATITUDE_MAX
13+
LONGITUDE_MIN = -LONGITUDE_MAX
14+
EARTH_RADIUS_IN_METERS = 6372797.560856
15+
)
16+
17+
type Coordinates struct {
18+
Latitude float64
19+
Longitude float64
20+
}
21+
22+
func NewCoordinates(latitude float64, longitude float64) Coordinates {
23+
if !isValidLatitudeLongitudePair(latitude, longitude) {
24+
panic(fmt.Sprintf("Codecrafters Internal Error - Invalid coordinates (lat=%.8f,lon=%.8f) in NewCoordinates()",
25+
latitude, longitude,
26+
))
27+
}
28+
29+
return Coordinates{
30+
Latitude: latitude,
31+
Longitude: longitude,
32+
}
33+
}
34+
35+
func NewInvalidCoordinates(latitude float64, longitude float64) Coordinates {
36+
if isValidLatitudeLongitudePair(latitude, longitude) {
37+
panic(fmt.Sprintf(
38+
"Codecrafters Internal Error - Valid coordinates (lat=%.8f, lon=%.8f) in NewInvalidCoordinates()",
39+
latitude, longitude,
40+
))
41+
}
42+
43+
return Coordinates{
44+
Latitude: latitude,
45+
Longitude: longitude,
46+
}
47+
}
48+
49+
// GetGeoGridCenterCoordinates decodes latitude and longitude from the geoCode
50+
// The decoded coordinates will be slightly different from the original one.
51+
// It is due to the fact that Redis drops precision at 52 bits.
52+
// The coordinates of the returned coordinate is the center of the smallest geo grid the coordinates
53+
// are a part of
54+
func (c Coordinates) GetGeoGridCenterCoordinates() Coordinates {
55+
geoCode := c.GetGeoCode()
56+
return decodeGeoCodeToCoordinates(geoCode)
57+
}
58+
59+
// GetGeoCode returns the [WGS84](https://en.wikipedia.org/wiki/World_Geodetic_System) geocode of a coordinate pair
60+
// This is the same geocode used by Redis
61+
func (c Coordinates) GetGeoCode() uint64 {
62+
// Normalize to the range 0-2^26
63+
latitudeOffset := (c.Latitude - LATITUDE_MIN) / (LATITUDE_MAX - LATITUDE_MIN)
64+
longitudeOffset := (c.Longitude - LONGITUDE_MIN) / (LONGITUDE_MAX - LONGITUDE_MIN)
65+
66+
latitudeOffset *= (1 << 26)
67+
longitudeOffset *= (1 << 26)
68+
69+
// Spread latitude bits
70+
x := uint64(latitudeOffset)
71+
x = (x | (x << 16)) & 0x0000FFFF0000FFFF
72+
x = (x | (x << 8)) & 0x00FF00FF00FF00FF
73+
x = (x | (x << 4)) & 0x0F0F0F0F0F0F0F0F
74+
x = (x | (x << 2)) & 0x3333333333333333
75+
x = (x | (x << 1)) & 0x5555555555555555
76+
77+
// Spread longitude bits
78+
y := uint64(longitudeOffset)
79+
y = (y | (y << 16)) & 0x0000FFFF0000FFFF
80+
y = (y | (y << 8)) & 0x00FF00FF00FF00FF
81+
y = (y | (y << 4)) & 0x0F0F0F0F0F0F0F0F
82+
y = (y | (y << 2)) & 0x3333333333333333
83+
y = (y | (y << 1)) & 0x5555555555555555
84+
85+
return x | (y << 1)
86+
}
87+
88+
// DistanceFrom returns distance between two pair of coordinates using haversine great circle distance formula
89+
// While calculating distance, the coordinates actually used is the center of the geogrid instead of the
90+
// original coordinates. It is done to mimic Redis' way of calculating distance
91+
func (c Coordinates) DistanceFrom(coordinates Coordinates) float64 {
92+
c1 := c.GetGeoGridCenterCoordinates()
93+
c2 := coordinates.GetGeoGridCenterCoordinates()
94+
95+
lat1radians := degreesToRadians(c1.Latitude)
96+
lat2radians := degreesToRadians(c2.Latitude)
97+
lon1radians := degreesToRadians(c1.Longitude)
98+
lon2radians := degreesToRadians(c2.Longitude)
99+
100+
v := math.Sin((lon2radians - lon1radians) / 2)
101+
u := math.Sin((lat2radians - lat1radians) / 2)
102+
103+
a := u*u + math.Cos(lat1radians)*math.Cos(lat2radians)*v*v
104+
return 2.0 * EARTH_RADIUS_IN_METERS * math.Asin(math.Sqrt(a))
105+
}
106+
107+
// LongitudeAsRedisCommandArg converts longitude of a coordinate to its
108+
// string representation with full precision
109+
func (c Coordinates) LongitudeAsRedisCommandArg() string {
110+
return strconv.FormatFloat(c.Longitude, 'f', -1, 64)
111+
}
112+
113+
// LatitudeAsRedisCommandArg converts latitude of a coordinate to its
114+
// string representation with full precision
115+
func (c Coordinates) LatitudeAsRedisCommandArg() string {
116+
return strconv.FormatFloat(c.Latitude, 'f', -1, 64)
117+
}
118+
119+
// decodeGeoCodeToCoordinates decodes a geocode and returns the coordinates of
120+
// the center of the geocode's decoded area
121+
func decodeGeoCodeToCoordinates(geoCode uint64) Coordinates {
122+
y := geoCode >> 1
123+
x := geoCode
124+
125+
// Compact bits back to 32-bit ints
126+
x = geoCode & 0x5555555555555555
127+
x = (x | (x >> 1)) & 0x3333333333333333
128+
x = (x | (x >> 2)) & 0x0F0F0F0F0F0F0F0F
129+
x = (x | (x >> 4)) & 0x00FF00FF00FF00FF
130+
x = (x | (x >> 8)) & 0x0000FFFF0000FFFF
131+
x = (x | (x >> 16)) & 0x00000000FFFFFFFF
132+
133+
y = y & 0x5555555555555555
134+
y = (y | (y >> 1)) & 0x3333333333333333
135+
y = (y | (y >> 2)) & 0x0F0F0F0F0F0F0F0F
136+
y = (y | (y >> 4)) & 0x00FF00FF00FF00FF
137+
y = (y | (y >> 8)) & 0x0000FFFF0000FFFF
138+
y = (y | (y >> 16)) & 0x00000000FFFFFFFF
139+
140+
latitude_scale := LATITUDE_MAX - LATITUDE_MIN
141+
longitude_scale := LONGITUDE_MAX - LONGITUDE_MIN
142+
143+
gridLatitudeNumber := uint32(x)
144+
gridLongitudeNumber := uint32(y)
145+
146+
gridLatitudeMin := LATITUDE_MIN + latitude_scale*(float64(gridLatitudeNumber)*1.0/(1<<26))
147+
gridLatitudeMax := LATITUDE_MIN + latitude_scale*(float64(gridLatitudeNumber+1)*1.0/(1<<26))
148+
gridLongitudeMin := LONGITUDE_MIN + longitude_scale*(float64(gridLongitudeNumber)*1.0/(1<<26))
149+
gridLongitudeMax := LONGITUDE_MIN + longitude_scale*(float64(gridLongitudeNumber+1)*1.0/(1<<26))
150+
151+
latitude := (gridLatitudeMin + gridLatitudeMax) / 2
152+
longitude := (gridLongitudeMin + gridLongitudeMax) / 2
153+
154+
if !isValidLatitudeLongitudePair(latitude, longitude) {
155+
panic(
156+
fmt.Sprintf("Codecrafters Internal Error - Decoded coordinates (lat=%.8f, lon=%.8f) is out of valid range", latitude, longitude),
157+
)
158+
}
159+
160+
return NewCoordinates(latitude, longitude)
161+
}
162+
163+
func degreesToRadians(deg float64) float64 {
164+
return deg * math.Pi / 180
165+
}
166+
167+
func isValidLatitudeLongitudePair(latitude float64, longitude float64) bool {
168+
return latitude >= LATITUDE_MIN && latitude <= LATITUDE_MAX &&
169+
longitude >= LONGITUDE_MIN && longitude <= LONGITUDE_MAX
170+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package location
2+
3+
type Location struct {
4+
Coordinates Coordinates
5+
Name string
6+
}
7+
8+
func (l Location) GetLatitude() float64 {
9+
return l.Coordinates.Latitude
10+
}
11+
12+
func (l Location) GetLongitude() float64 {
13+
return l.Coordinates.Longitude
14+
}
15+
16+
// GetGeoGridCenterCoordinates decodes latitude and longitude from the geoCode of a location
17+
func (l Location) GetGeoGridCenterCoordinates() Coordinates {
18+
return l.Coordinates.GetGeoGridCenterCoordinates()
19+
}
20+
21+
// GetGeoCode returns the [WGS84](https://en.wikipedia.org/wiki/World_Geodetic_System) geocode of a location
22+
func (l Location) GetGeoCode() uint64 {
23+
return l.Coordinates.GetGeoCode()
24+
}
25+
26+
// DistanceFrom returns distance between two locations
27+
func (l Location) DistanceFrom(location Location) float64 {
28+
return l.Coordinates.DistanceFrom(location.Coordinates)
29+
}
30+
31+
// LongitudeAsRedisCommandArg converts a location's longitude to its string representation
32+
func (l Location) LongitudeAsRedisCommandArg() string {
33+
return l.Coordinates.LongitudeAsRedisCommandArg()
34+
}
35+
36+
// LatitudeAsRedisCommandArg converts a location's latitude to its string representation
37+
func (l Location) LatitudeAsRedisCommandArg() string {
38+
return l.Coordinates.LatitudeAsRedisCommandArg()
39+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package location
2+
3+
import (
4+
"github.com/codecrafters-io/tester-utils/random"
5+
)
6+
7+
type LocationSet struct {
8+
locations []Location
9+
}
10+
11+
func NewLocationSet() *LocationSet {
12+
return &LocationSet{}
13+
}
14+
15+
func (ls *LocationSet) AddLocation(location Location) *LocationSet {
16+
ls.locations = append(ls.locations, location)
17+
return ls
18+
}
19+
20+
func (ls *LocationSet) Size() int {
21+
return len(ls.locations)
22+
}
23+
24+
// CenterCoordinates returns a Coordinate pair whose latitude and longitude are respectively the mean-value of
25+
// latitude and longitude of all the locations in the set.
26+
// This is different center (not equidistant from all points) compared to the circumcenter of a spherical triangle
27+
// (https://brsr.github.io/2021/05/02/spherical-triangle-centers.html), which is equidistant from all the points
28+
// It is done because we want to include some and exclude other locations while testing for geosearch
29+
func (ls *LocationSet) CenterCoordinates() Coordinates {
30+
latitudeAverage := 0.0
31+
longitudeAverage := 0.0
32+
33+
for _, location := range ls.locations {
34+
latitudeAverage += location.GetLatitude()
35+
longitudeAverage += location.GetLongitude()
36+
}
37+
38+
latitudeAverage = latitudeAverage / float64(ls.Size())
39+
longitudeAverage = longitudeAverage / float64(ls.Size())
40+
41+
return NewCoordinates(latitudeAverage, longitudeAverage)
42+
}
43+
44+
// ClosestTo returns the location in the LocationSet that is closest to the supplied coordinates
45+
func (ls *LocationSet) ClosestTo(referenceCoordinates Coordinates) Location {
46+
if ls.Size() == 0 {
47+
panic("Codecrafters Internal Error - Cannot find closest location from empty LocationSet")
48+
}
49+
50+
closestLocation := ls.locations[0]
51+
closestDistance := referenceCoordinates.DistanceFrom(closestLocation.Coordinates)
52+
53+
for _, loc := range ls.locations {
54+
distance := referenceCoordinates.DistanceFrom(loc.Coordinates)
55+
56+
if distance < closestDistance {
57+
closestDistance = distance
58+
closestLocation = loc
59+
}
60+
}
61+
62+
return closestLocation
63+
}
64+
65+
// FarthestFrom returns the location in the LocationSet that is farthest from the supplied coordinates
66+
func (ls *LocationSet) FarthestFrom(referenceCoordinates Coordinates) Location {
67+
if ls.Size() == 0 {
68+
panic("Codecrafters Internal Error - Cannot find farthest location from empty LocationSet")
69+
}
70+
71+
farthestLocation := ls.locations[0]
72+
farthestDistance := farthestLocation.Coordinates.DistanceFrom(referenceCoordinates)
73+
74+
for _, location := range ls.locations {
75+
distance := referenceCoordinates.DistanceFrom(location.Coordinates)
76+
77+
if distance > farthestDistance {
78+
farthestDistance = distance
79+
farthestLocation = location
80+
}
81+
}
82+
83+
return farthestLocation
84+
}
85+
86+
// WithinRadius returns a new LocationSet with all the locations that are within a given radius from the given location
87+
func (ls *LocationSet) WithinRadius(referenceCoordinates Coordinates, radius float64) *LocationSet {
88+
result := NewLocationSet()
89+
90+
for _, location := range ls.locations {
91+
distance := referenceCoordinates.DistanceFrom(location.Coordinates)
92+
93+
if distance <= radius {
94+
result.AddLocation(location)
95+
}
96+
}
97+
98+
return result
99+
}
100+
101+
// GetLocations returns a copy of all the locations in the location set
102+
func (ls *LocationSet) GetLocations() []Location {
103+
locations := make([]Location, len(ls.locations))
104+
copy(locations, ls.locations)
105+
return locations
106+
}
107+
108+
// GetLocationNames returns the name of all the locations in the location set
109+
func (ls *LocationSet) GetLocationNames() []string {
110+
locationNames := make([]string, len(ls.locations))
111+
112+
for i, location := range ls.locations {
113+
locationNames[i] = location.Name
114+
}
115+
116+
return locationNames
117+
}
118+
119+
// GenerateRandomLocationSet returns a LocationSet with 'count' number of valid random locations
120+
func GenerateRandomLocationSet(count int) *LocationSet {
121+
locationSet := NewLocationSet()
122+
locationNames := random.RandomWords(count)
123+
124+
for i := range count {
125+
latitude := random.RandomFloat64(LATITUDE_MIN, LATITUDE_MAX)
126+
longitude := random.RandomFloat64(LONGITUDE_MIN, LONGITUDE_MAX)
127+
128+
locationSet.AddLocation(Location{
129+
Name: locationNames[i],
130+
Coordinates: NewCoordinates(latitude, longitude),
131+
})
132+
}
133+
134+
return locationSet
135+
}

internal/instrumented_resp_connection/instrumented_resp_connection.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,11 @@ func (c *InstrumentedRespConnection) UpdateBaseLogger(l *logger.Logger) {
8585
c.logger = newLogger
8686
c.UpdateCallBacks(defaultCallbacks(c.logger))
8787
}
88+
89+
func (c *InstrumentedRespConnection) SetReadValueInterceptor(transformer func(value resp_value.Value) resp_value.Value) {
90+
c.ReadValueInterceptor = transformer
91+
}
92+
93+
func (c *InstrumentedRespConnection) UnsetReadValueInterceptor() {
94+
c.ReadValueInterceptor = nil
95+
}

0 commit comments

Comments
 (0)