Geo Serviceability Q-Commerce: Dari Titik GPS ke Hub dalam 10ms
System design geo-serviceability untuk Astro. H3 hexagon indexing untuk O(1) lookup, PostGIS polygon untuk boundary verification, Redis geo-index untuk caching cell-to-hub mapping, tie-break logic untuk overlapping coverage, dan fallback strategy. Golang implementation dengan uber/h3-go, PostGIS, dan Redis.

- Geo Serviceability Q-Commerce: Dari Titik GPS ke Hub dalam 10ms
- Masalah: Kenapa Ini Susah?
- Arsitektur Geo Serviceability
- H3 Hexagon vs Geohash vs S2
- Tie-Break Logic untuk Multi-Hub Coverage
- 1. H3 Cell Lookup: Resolving GPS to Hub
- Implementasi Go dengan uber/h3-go
- Edge Cases
- 2. PostGIS Polygon Boundary Verification
- Problem
- Implementasi Go dengan PostGIS
- 3. Cache Warming: Precompute H3 Cell to Hub Mapping
- Problem
- Solution: Cache Warming Job
- 4. Hub Assignment Service: HTTP Handler dengan Middleware
- 5. Fallback Strategy: Graceful Degradation
- Problem
- Solution: Multi-layer Fallback
- Key Takeaways
Geo Serviceability Q-Commerce: Dari Titik GPS ke Hub dalam 10ms
TL;DR
Setiap kali user buka aplikasi q-commerce, sistem harus menentukan: user ini bisa dapet delivery dari hub mana dalam < 30 menit? Ini bukan soal plot titik di peta — ada geometri kompleks, coverage polygon, boundary ambiguity, dan ribuan request per detik. Artikel ini membahas H3 hexagon indexing untuk O(1) lookup, PostGIS polygon boundary verification, Redis caching cell-to-hub mapping, tie-break logic untuk overlapping coverage, dan fallback strategy. Semua implementasi Golang.
Masalah: Kenapa Ini Susah?
10ms Response Time
Setiap buka app, geo lookup harus selesai < 10ms. User gak bisa nunggu 500ms cuma buat nentuin hub.
Polygon Geometry
Coverage area bukan circle — bentuknya irregular polygon karena batasan jalan, sungai, tol.
Overlapping Coverage
Dua hub bisa cover area yang sama. Mana yang dipilih? Butuh tie-break logic.
Boundary Ambiguity
User di perbatasan dua hub. Hujan deras? Kirim dari hub yang lebih dekat. Stock habis? Kirim dari hub lain.
Hub Dynamics
Hub bisa buka/tutup, coverage berubah, stock berubah. Cache harus invalidate real-time.
Real Incident
Sebuah q-commerce di India mengalami masalah: user di perbatasan dua hub selalu dapat "no service area" padahal dua-duanya bisa deliver. Penyebabnya: boundary check hanya pakai titik pusat PostGIS, gak handle edge-of-polygon properly. Ribuan order hilang tiap hari. Fix-nya butuh 2 minggu — termasuk switch ke H3 hexagon indexing.
Arsitektur Geo Serviceability
H3 Hexagon vs Geohash vs S2
Geohash
Rectangular cells. Boundary ambiguity tinggi karena shape-nya persegi. Gak cocok untuk coverage polygon yang irregular.
S2 (Google)
Spherical geometry. Akurat, kompleks. Butuh library besar. Populer di Google Maps.
H3 (Uber)
Hexagonal cells. Setiap cell punya 6 tetangga. Lebih natural untuk coverage mapping. Open source, library Go mature.
Kenapa H3?
Hexagon punya properti unik: semua tetangga berjarak sama (tidak seperti persegi yang punya 4 edge neighbor + 4 corner neighbor). Ini penting buat boundary smoothing dan coverage expansion. Ditambah, H3 resolution 9 punya cell area ~0.1 km2 — cukup presisi untuk q-commerce yang punya radius delivery 3-5 km.
Tie-Break Logic untuk Multi-Hub Coverage
1. H3 Cell Lookup: Resolving GPS to Hub
Implementasi Go dengan uber/h3-go
package geo
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"time"
"github.com/redis/go-redis/v9"
"github.com/uber/h3-go/v4"
)
// HubAssignment represents a hub assignment result for a user location.
type HubAssignment struct {
HubID string `json:"hub_id"`
HubName string `json:"hub_name"`
DistanceKm float64 `json:"distance_km"`
ETA int `json:"eta_minutes"`
StockStatus string `json:"stock_status"` // "available", "limited", "out_of_stock"
}
// GeoService handles geo-spatial hub assignment using H3 indexing.
type GeoService struct {
rdb *redis.Client
hubRepo HubRepository
cacheTTL time.Duration
h3Resolution int
logger *slog.Logger
}
// HubRepository provides hub data and coverage information.
type HubRepository interface {
GetHubByID(ctx context.Context, hubID string) (*Hub, error)
GetHubCoveragePolygon(ctx context.Context, hubID string) (string, error) // Returns WKT polygon
GetHubsByLocation(ctx context.Context, lat, lng float64) ([]*Hub, error)
GetStockStatus(ctx context.Context, hubID, productCategory string) (string, error)
GetAvailableRiders(ctx context.Context, hubID string) (int, error)
GetHubsByH3Cell(ctx context.Context, cellToken string) ([]string, error)
}
// Hub represents a dark store / hub.
type Hub struct {
ID string `json:"id"`
Name string `json:"name"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
MaxRadiusKm float64 `json:"max_radius_km"`
IsActive bool `json:"is_active"`
CoverageWKT string `json:"coverage_wkt"` // Well-Known Text polygon
}
// NewGeoService creates a new GeoService.
func NewGeoService(rdb *redis.Client, hubRepo HubRepository, h3Resolution int, logger *slog.Logger) *GeoService {
if h3Resolution == 0 {
h3Resolution = 9 // ~0.1 km2 per cell
}
return &GeoService{
rdb: rdb,
hubRepo: hubRepo,
cacheTTL: 1 * time.Hour,
h3Resolution: h3Resolution,
logger: logger,
}
}
// GetServiceableHub determines the best hub for a given GPS coordinate.
func (s *GeoService) GetServiceableHub(ctx context.Context, lat, lng float64, productCategory string) (*HubAssignment, error) {
// Step 1: Resolve lat/lng to H3 cell.
cell := s.latLngToCell(lat, lng)
cellToken := cell.String()
// Step 2: Check cache.
hubIDs, err := s.getHubIDsFromCache(ctx, cellToken)
if err != nil {
// Cache miss — lookup from PostGIS.
hubIDs, err = s.resolveHubIDsFromPostGIS(ctx, lat, lng, cellToken)
if err != nil {
return nil, fmt.Errorf("geo: resolve hub ids: %w", err)
}
}
if len(hubIDs) == 0 {
// Step 3: Expand to neighbor cells.
hubIDs = s.expandToNeighbors(ctx, cell, cellToken)
}
if len(hubIDs) == 0 {
// Step 4: Try parent cell (lower resolution = larger area).
hubIDs = s.expandToParent(ctx, cell)
}
if len(hubIDs) == 0 {
return nil, &NoServiceAreaError{
Lat: lat,
Lng: lng,
}
}
// Step 5: Tie-break if multiple hubs.
assignment, err := s.tieBreakHubs(ctx, hubIDs, lat, lng, productCategory)
if err != nil {
return nil, fmt.Errorf("geo: tie break: %w", err)
}
return assignment, nil
}
// latLngToCell converts GPS coordinates to an H3 cell at the configured resolution.
func (s *GeoService) latLngToCell(lat, lng float64) h3.Cell {
latLng := h3.NewLatLng(lat, lng)
return h3.LatLngToCell(latLng, s.h3Resolution)
}
// getHubIDsFromCache checks Redis for cached hub IDs for a given H3 cell token.
func (s *GeoService) getHubIDsFromCache(ctx context.Context, cellToken string) ([]string, error) {
cacheKey := fmt.Sprintf("geo:h3:%s", cellToken)
data, err := s.rdb.Get(ctx, cacheKey).Result()
if err != nil {
if err == redis.Nil {
return nil, nil // Cache miss
}
return nil, fmt.Errorf("geo: redis get: %w", err)
}
var hubIDs []string
if err := json.Unmarshal([]byte(data), &hubIDs); err != nil {
return nil, fmt.Errorf("geo: unmarshal cache: %w", err)
}
return hubIDs, nil
}
// resolveHubIDsFromPostGIS queries PostGIS for hubs covering the given point.
func (s *GeoService) resolveHubIDsFromPostGIS(ctx context.Context, lat, lng float64, cellToken string) ([]string, error) {
hubIDs, err := s.hubRepo.GetHubsByLocation(ctx, lat, lng)
if err != nil {
return nil, fmt.Errorf("geo: postgis query: %w", err)
}
// Cache the result.
if len(hubIDs) > 0 {
cacheKey := fmt.Sprintf("geo:h3:%s", cellToken)
data, _ := json.Marshal(hubIDs)
if err := s.rdb.Set(ctx, cacheKey, data, s.cacheTTL).Err(); err != nil {
s.logger.WarnContext(ctx, "geo: cache set failed",
"error", err,
"cell", cellToken,
)
}
}
return hubIDs, nil
}
// expandToNeighbors checks all 6 neighboring H3 cells for hub coverage.
func (s *GeoService) expandToNeighbors(ctx context.Context, cell h3.Cell, originalCellToken string) []string {
neighbors := h3.GridRing(cell, 1) // Ring 1 = immediate 6 neighbors
visited := make(map[string]bool)
var allHubIDs []string
for _, neighbor := range neighbors {
token := neighbor.String()
if visited[token] {
continue
}
visited[token] = true
hubIDs, err := s.getHubIDsFromCache(ctx, token)
if err != nil || len(hubIDs) == 0 {
continue
}
// Cache the result for the original cell too (anti-thrashing).
originalKey := fmt.Sprintf("geo:h3:%s", originalCellToken)
cacheData, _ := json.Marshal(hubIDs)
s.rdb.Set(ctx, originalKey, cacheData, s.cacheTTL)
allHubIDs = append(allHubIDs, hubIDs...)
}
// Deduplicate.
return uniqueStrings(allHubIDs)
}
// expandToParent tries coarser resolution if no hub found at current resolution.
func (s *GeoService) expandToParent(ctx context.Context, cell h3.Cell) []string {
for res := s.h3Resolution - 1; res >= 4; res-- {
parentCell := cell
// H3: get the parent cell at lower resolution.
parentCell = h3.Parent(parentCell)
// Re-try at this resolution.
hubIDs, _ := s.getHubIDsFromCache(ctx, parentCell.String())
if len(hubIDs) > 0 {
return hubIDs
}
}
return nil
}
// tieBreakHubs selects the best hub from multiple candidates.
func (s *GeoService) tieBreakHubs(ctx context.Context, hubIDs []string, lat, lng float64, productCategory string) (*HubAssignment, error) {
type scoredHub struct {
hub *Hub
distKm float64
score float64
}
var candidates []scoredHub
for _, hubID := range hubIDs {
hub, err := s.hubRepo.GetHubByID(ctx, hubID)
if err != nil || !hub.IsActive {
continue
}
distKm := haversineDistance(lat, lng, hub.Latitude, hub.Longitude)
if distKm > hub.MaxRadiusKm {
continue // Outside max delivery radius.
}
candidates = append(candidates, scoredHub{
hub: hub,
distKm: distKm,
score: 0,
})
}
if len(candidates) == 0 {
return nil, &NoServiceAreaError{Lat: lat, Lng: lng}
}
// Score each hub.
for i, c := range candidates {
score := 0.0
// Factor 1: Distance (lower is better, normalized to 0-1).
distanceScore := 1.0 - (c.distKm / c.hub.MaxRadiusKm)
score += distanceScore * 0.4 // 40% weight
// Factor 2: Stock availability.
stockStatus, err := s.hubRepo.GetStockStatus(ctx, c.hub.ID, productCategory)
if err == nil {
switch stockStatus {
case "available":
score += 0.4 // 40% weight
case "limited":
score += 0.2
case "out_of_stock":
score -= 0.5
}
}
// Factor 3: Rider availability.
riders, err := s.hubRepo.GetAvailableRiders(ctx, c.hub.ID)
if err == nil && riders > 0 {
riderScore := math.Min(float64(riders)/10.0, 0.2) // 20% weight max
score += riderScore
}
candidates[i].score = score
}
// Sort by score descending.
best := candidates[0]
for _, c := range candidates[1:] {
if c.score > best.score {
best = c
}
}
eta := calculateETA(best.distKm)
return &HubAssignment{
HubID: best.hub.ID,
HubName: best.hub.Name,
DistanceKm: math.Round(best.distKm*100) / 100,
ETA: eta,
}, nil
}
// haversineDistance calculates the great-circle distance between two GPS points in km.
func haversineDistance(lat1, lng1, lat2, lng2 float64) float64 {
const R = 6371.0 // Earth radius in km
dLat := (lat2 - lat1) * math.Pi / 180.0
dLng := (lng2 - lng1) * math.Pi / 180.0
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*math.Pi/180.0)*math.Cos(lat2*math.Pi/180.0)*
math.Sin(dLng/2)*math.Sin(dLng/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
}
// calculateETA estimates delivery time in minutes based on distance.
func calculateETA(distanceKm float64) int {
// Average speed 25 km/h (urban scooter) + 5 min pickup + 2 min dropoff.
baseTime := 7 // minutes for pickup + dropoff
travelTime := int(math.Ceil(distanceKm / 25.0 * 60.0))
return baseTime + travelTime
}
func uniqueStrings(slice []string) []string {
seen := make(map[string]struct{})
result := make([]string, 0, len(slice))
for _, s := range slice {
if _, ok := seen[s]; !ok {
seen[s] = struct{}{}
result = append(result, s)
}
}
return result
}
// NoServiceAreaError is returned when no hub can serve the given location.
type NoServiceAreaError struct {
Lat float64
Lng float64
}
func (e *NoServiceAreaError) Error() string {
return fmt.Sprintf("geo: no service area for (%.4f, %.4f)", e.Lat, e.Lng)
}
// IsNoServiceArea reports whether err is a NoServiceAreaError.
func IsNoServiceArea(err error) bool {
_, ok := err.(*NoServiceAreaError)
return ok
}Edge Cases
Edge Case: GPS Noise
GPS user bisa melompat 50-100m dalam 1 detik karena noise, terutama di indoor. Kalau user di perbatasan hub, lompatan GPS bisa cause flip-flop hub assignment. Solusi: H3 cell comparison. Cek apakah user masih di H3 cell yang sama dengan request sebelumnya. Kalau iya, jangan reassign.
Edge Case: High-Rise Building
User di apartemen lantai 20 punya GPS yang kurang akurat. Bisa terdeteksi di luar coverage polygon padahal secara fisik di dalam. Solusi: fallback ke IP geolocation + wifi BSSID sebagai supplementary signal.
2. PostGIS Polygon Boundary Verification
Problem
H3 cell lookup cepat, tapi ada kalanya user tepat di boundary polygon coverage. H3 cell bisa mencakup area di luar polygon. Butuh verifikasi eksak dengan PostGIS ST_Contains.
Implementasi Go dengan PostGIS
package geo
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// PostGISRepository implements HubRepository with PostGIS spatial queries.
type PostGISRepository struct {
pool *pgxpool.Pool
}
// NewPostGISRepository creates a new PostGISRepository.
func NewPostGISRepository(pool *pgxpool.Pool) *PostGISRepository {
return &PostGISRepository{pool: pool}
}
// GetHubsByLocation finds all hubs whose coverage polygon contains the given point.
// Uses PostGIS spatial index for O(log n) lookup.
func (r *PostGISRepository) GetHubsByLocation(ctx context.Context, lat, lng float64) ([]string, error) {
query := `
SELECT h.id
FROM hubs h
INNER JOIN hub_coverage hc ON hc.hub_id = h.id
WHERE h.is_active = true
AND ST_Contains(
hc.coverage_polygon,
ST_SetSRID(ST_MakePoint($1, $2), 4326)
)
ORDER BY
h.priority ASC NULLS LAST,
ST_Distance(
hc.coverage_polygon,
ST_SetSRID(ST_MakePoint($1, $2), 4326)
) ASC
`
// PostGIS uses (lng, lat) order for points.
rows, err := r.pool.Query(ctx, query, lng, lat)
if err != nil {
return nil, fmt.Errorf("postgis: get hubs by location: %w", err)
}
defer rows.Close()
var hubIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("postgis: scan hub id: %w", err)
}
hubIDs = append(hubIDs, id)
}
return hubIDs, nil
}
// GetHubByID retrieves hub details from Postgres.
func (r *PostGISRepository) GetHubByID(ctx context.Context, hubID string) (*Hub, error) {
query := `
SELECT id, name, latitude, longitude, max_radius_km, is_active
FROM hubs
WHERE id = $1
`
var hub Hub
err := r.pool.QueryRow(ctx, query, hubID).Scan(
&hub.ID, &hub.Name, &hub.Latitude, &hub.Longitude,
&hub.MaxRadiusKm, &hub.IsActive,
)
if err != nil {
return nil, fmt.Errorf("postgis: get hub %s: %w", hubID, err)
}
return &hub, nil
}
// GetHubCoveragePolygon returns the coverage polygon as WKT string.
func (r *PostGISRepository) GetHubCoveragePolygon(ctx context.Context, hubID string) (string, error) {
query := `
SELECT ST_AsText(coverage_polygon)
FROM hub_coverage
WHERE hub_id = $1
`
var wkt string
err := r.pool.QueryRow(ctx, query, hubID).Scan(&wkt)
if err != nil {
return "", fmt.Errorf("postgis: get coverage polygon: %w", err)
}
return wkt, nil
}
// GetStockStatus retrieves stock availability for a product category at a hub.
func (r *PostGISRepository) GetStockStatus(ctx context.Context, hubID, productCategory string) (string, error) {
query := `
SELECT
CASE
WHEN hi.available_quantity - hi.reserved_quantity >= 10 THEN 'available'
WHEN hi.available_quantity - hi.reserved_quantity > 0 THEN 'limited'
ELSE 'out_of_stock'
END as status
FROM hub_inventory hi
JOIN products p ON p.sku = hi.sku
WHERE hi.hub_id = $1
AND p.category = $2
ORDER BY hi.available_quantity - hi.reserved_quantity DESC
LIMIT 1
`
var status string
err := r.pool.QueryRow(ctx, query, hubID, productCategory).Scan(&status)
if err != nil {
return "unknown", nil // Don't fail the whole request on stock check error.
}
return status, nil
}
// GetAvailableRiders returns the number of available riders at a hub.
func (r *PostGISRepository) GetAvailableRiders(ctx context.Context, hubID string) (int, error) {
query := `
SELECT COUNT(*)
FROM riders
WHERE hub_id = $1
AND is_online = true
AND status = 'idle'
`
var count int
err := r.pool.QueryRow(ctx, query, hubID).Scan(&count)
if err != nil {
return 0, nil // Don't fail on rider check error.
}
return count, nil
}
// GetHubsByH3Cell retrieves hubs from PostGIS by H3 cell token.
func (r *PostGISRepository) GetHubsByH3Cell(ctx context.Context, cellToken string) ([]string, error) {
query := `
SELECT h.id
FROM hubs h
JOIN h3_cell_mapping m ON m.hub_id = h.id
WHERE m.h3_cell_token = $1
AND h.is_active = true
`
rows, err := r.pool.Query(ctx, query, cellToken)
if err != nil {
return nil, fmt.Errorf("postgis: get hubs by h3 cell: %w", err)
}
defer rows.Close()
var hubIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("postgis: scan: %w", err)
}
hubIDs = append(hubIDs, id)
}
return hubIDs, nil
}
// PostGISSchema returns DDL for geo serviceability tables.
func PostGISSchema() string {
return `
-- Enable PostGIS (run once)
CREATE EXTENSION IF NOT EXISTS postgis;
-- Hubs table
CREATE TABLE IF NOT EXISTS hubs (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
latitude DOUBLE PRECISION NOT NULL,
longitude DOUBLE PRECISION NOT NULL,
max_radius_km DOUBLE PRECISION NOT NULL DEFAULT 5.0,
is_active BOOLEAN NOT NULL DEFAULT true,
priority INT DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Hub coverage polygons (could be multiple per hub for disjoint areas)
CREATE TABLE IF NOT EXISTS hub_coverage (
id BIGSERIAL PRIMARY KEY,
hub_id VARCHAR(50) NOT NULL REFERENCES hubs(id),
coverage_polygon GEOGRAPHY(POLYGON, 4326) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_hub_coverage_geo ON hub_coverage USING GIST (coverage_polygon);
CREATE INDEX idx_hub_coverage_hub ON hub_coverage(hub_id);
-- H3 cell to hub mapping (precomputed)
CREATE TABLE IF NOT EXISTS h3_cell_mapping (
h3_cell_token VARCHAR(50) NOT NULL,
hub_id VARCHAR(50) NOT NULL REFERENCES hubs(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (h3_cell_token, hub_id)
);
CREATE INDEX idx_h3_cell_mapping_cell ON h3_cell_mapping(h3_cell_token);
-- Sample query to verify coverage:
-- SELECT id, name
-- FROM hubs h
-- JOIN hub_coverage hc ON hc.hub_id = h.id
-- WHERE ST_Contains(
-- hc.coverage_polygon,
-- ST_SetSRID(ST_MakePoint(106.8, -6.2), 4326)
-- );
`
}3. Cache Warming: Precompute H3 Cell to Hub Mapping
Problem
Cold start. Saat hub baru dibuka, gak ada cache. Ribuan user pertama kena cache miss — semuanya query ke PostGIS. PostGIS polygon query itu mahal (terutama untuk polygon kompleks dengan 1000+ vertices).
Solution: Cache Warming Job
Precompute semua H3 cell di dalam coverage polygon dan simpan mapping-nya di Redis + Postgres.
package geo
import (
"context"
"fmt"
"log/slog"
"math"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"github.com/uber/h3-go/v4"
)
// CacheWarmer precomputes H3 cell to hub mappings for all coverage polygons.
type CacheWarmer struct {
pool *pgxpool.Pool
rdb *redis.Client
h3Resolution int
batchSize int
logger *slog.Logger
}
// NewCacheWarmer creates a new CacheWarmer.
func NewCacheWarmer(pool *pgxpool.Pool, rdb *redis.Client, h3Resolution int, logger *slog.Logger) *CacheWarmer {
return &CacheWarmer{
pool: pool,
rdb: rdb,
h3Resolution: h3Resolution,
batchSize: 1000,
logger: logger,
}
}
// WarmCache recomputes and caches all H3 cell to hub mappings.
func (w *CacheWarmer) WarmCache(ctx context.Context) error {
start := time.Now()
w.logger.InfoContext(ctx, "cache warmer started")
// Get all active hubs with their coverage polygons.
rows, err := w.pool.Query(ctx, `
SELECT h.id, h.name, ST_AsText(hc.coverage_polygon) as polygon_wkt
FROM hubs h
JOIN hub_coverage hc ON hc.hub_id = h.id
WHERE h.is_active = true
`)
if err != nil {
return fmt.Errorf("cache warmer: query hubs: %w", err)
}
defer rows.Close()
totalCells := 0
totalHubs := 0
for rows.Next() {
var hubID, hubName, polygonWKT string
if err := rows.Scan(&hubID, &hubName, &polygonWKT); err != nil {
return fmt.Errorf("cache warmer: scan: %w", err)
}
cells, err := w.processHubCoverage(ctx, hubID, polygonWKT)
if err != nil {
w.logger.ErrorContext(ctx, "cache warmer: process hub failed",
"hub_id", hubID,
"error", err,
)
continue
}
totalCells += cells
totalHubs++
w.logger.InfoContext(ctx, "hub coverage processed",
"hub_id", hubID,
"hub_name", hubName,
"cells", cells,
)
}
elapsed := time.Since(start)
w.logger.InfoContext(ctx, "cache warmer completed",
"hubs_processed", totalHubs,
"total_cells", totalCells,
"duration", elapsed.String(),
)
return nil
}
// processHubCoverage computes all H3 cells intersecting a coverage polygon
// and stores the mapping in Redis.
func (w *CacheWarmer) processHubCoverage(ctx context.Context, hubID, polygonWKT string) (int, error) {
// Parse polygon and compute bounding box.
bounds := estimateBoundingBox(polygonWKT)
// Generate all H3 cells within the bounding box.
cellCount := 0
pipe := w.rdb.Pipeline()
// Get hexagons using H3 polyfill equivalent.
polygon, err := wktToH3Polygon(polygonWKT)
if err != nil {
return 0, fmt.Errorf("parse polygon: %w", err)
}
cells := h3.PolygonToCells(polygon, w.h3Resolution)
for _, cell := range cells {
cellToken := cell.String()
cacheKey := fmt.Sprintf("geo:h3:%s", cellToken)
// Append hub ID to existing cell mapping.
pipe.SAdd(ctx, cacheKey, hubID)
pipe.Expire(ctx, cacheKey, 24*time.Hour)
cellCount++
// Also store in Postgres for persistence.
if cellCount%w.batchSize == 0 {
if _, err := pipe.Exec(ctx); err != nil {
return cellCount, fmt.Errorf("cache warmer: pipeline: %w", err)
}
pipe = w.rdb.Pipeline()
}
}
if cellCount%w.batchSize != 0 {
if _, err := pipe.Exec(ctx); err != nil {
return cellCount, fmt.Errorf("cache warmer: final pipeline: %w", err)
}
}
return cellCount, nil
}
// estimateBoundingBox returns a rough bounding box for a WKT polygon.
// Used for optimization — we generate H3 cells within this box first,
// then filter by actual polygon intersection.
func estimateBoundingBox(wkt string) (minLat, minLng, maxLat, maxLng float64) {
minLat = math.MaxFloat64
minLng = math.MaxFloat64
maxLat = -math.MaxFloat64
maxLng = -math.MaxFloat64
// Simple WKT parser for coordinates.
// In production, use a proper WKT parser library.
var points [][2]float64
fmt.Sscanf(wkt, "POLYGON((%f %f", &points)
// This is simplified — real implementation would parse all coordinates.
return
}
// wktToH3Polygon converts a WKT polygon string to an H3 Polygon.
func wktToH3Polygon(wkt string) (h3.Polygon, error) {
// Parse WKT to extract lat/lng pairs.
// WKT format: POLYGON((lng1 lat1, lng2 lat2, ..., lng1 lat1))
var lng, lat float64
var geoPoints []h3.LatLng
// Simple parser — assumes standard WKT format.
n, err := fmt.Sscanf(wkt, "POLYGON((%f %f", &lng, &lat)
if err != nil || n != 2 {
return h3.Polygon{}, fmt.Errorf("parse WKT start: %w", err)
}
geoPoints = append(geoPoints, h3.NewLatLng(lat, lng))
// Parse remaining points.
remaining := wkt
for {
n, err := fmt.Sscanf(remaining, ", %f %f", &lng, &lat)
if err != nil || n != 2 {
break
}
geoPoints = append(geoPoints, h3.NewLatLng(lat, lng))
// Advance to next point.
if idx := indexAfter(remaining, ","); idx >= 0 {
remaining = remaining[idx:]
} else {
break
}
}
if len(geoPoints) < 3 {
return h3.Polygon{}, fmt.Errorf("WKT polygon needs at least 3 points, got %d", len(geoPoints))
}
return h3.NewPolygon(geoPoints), nil
}
func indexAfter(s string, substr string) int {
idx := len(s)
for i := 0; i < len(s); i++ {
if s[i] == ',' {
return i + 1
}
}
return -1
}
// WarmCachePeriodically starts a periodic cache warmer.
func (w *CacheWarmer) WarmCachePeriodically(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
w.logger.InfoContext(ctx, "periodic cache warmer started",
"interval", interval.String(),
)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := w.WarmCache(ctx); err != nil {
w.logger.ErrorContext(ctx, "periodic cache warm failed", "error", err)
}
}
}
}4. Hub Assignment Service: HTTP Handler dengan Middleware
package geo
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
// ServiceabilityRequest represents an incoming geo serviceability request.
type ServiceabilityRequest struct {
Latitude float64 `json:"lat"`
Longitude float64 `json:"lng"`
ProductCategory string `json:"category,omitempty"`
UserID string `json:"user_id,omitempty"`
}
// ServiceabilityResponse represents the geo serviceability response.
type ServiceabilityResponse struct {
Serviceable bool `json:"serviceable"`
Hub *HubAssignment `json:"hub,omitempty"`
H3Cell string `json:"h3_cell,omitempty"`
CacheHit bool `json:"cache_hit"`
LatencyMs int64 `json:"latency_ms"`
}
// HubAssignmentServer handles HTTP requests for hub assignment.
type HubAssignmentServer struct {
geoService *GeoService
logger *slog.Logger
}
// NewHubAssignmentServer creates a new HubAssignmentServer.
func NewHubAssignmentServer(geoService *GeoService, logger *slog.Logger) *HubAssignmentServer {
return &HubAssignmentServer{
geoService: geoService,
logger: logger,
}
}
// HandleServiceability handles the GET /serviceability endpoint.
func (s *HubAssignmentServer) HandleServiceability(w http.ResponseWriter, r *http.Request) {
start := time.Now()
latStr := r.URL.Query().Get("lat")
lngStr := r.URL.Query().Get("lng")
if latStr == "" || lngStr == "" {
http.Error(w, `{"error":"lat and lng are required"}`, http.StatusBadRequest)
return
}
lat, err := strconv.ParseFloat(latStr, 64)
if err != nil || lat < -90 || lat > 90 {
http.Error(w, `{"error":"invalid lat"}`, http.StatusBadRequest)
return
}
lng, err := strconv.ParseFloat(lngStr, 64)
if err != nil || lng < -180 || lng > 180 {
http.Error(w, `{"error":"invalid lng"}`, http.StatusBadRequest)
return
}
category := r.URL.Query().Get("category")
hub, err := s.geoService.GetServiceableHub(r.Context(), lat, lng, category)
latencyMs := time.Since(start).Milliseconds()
response := ServiceabilityResponse{
CacheHit: false,
LatencyMs: latencyMs,
}
if err != nil {
if IsNoServiceArea(err) {
response.Serviceable = false
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) // 200 with serviceable=false, not 404.
json.NewEncoder(w).Encode(response)
return
}
s.logger.ErrorContext(r.Context(), "serviceability check failed",
"error", err,
"lat", lat,
"lng", lng,
)
http.Error(w, `{"error":"internal_error"}`, http.StatusInternalServerError)
return
}
response.Serviceable = true
response.Hub = hub
// Enrich with H3 cell token for debugging.
cell := h3.NewLatLng(lat, lng)
response.H3Cell = h3.LatLngToCell(cell, 9).String()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleBulkServiceability handles POST /serviceability/bulk for batch lookups.
func (s *HubAssignmentServer) HandleBulkServiceability(w http.ResponseWriter, r *http.Request) {
var req struct {
Locations []struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
ProductCategory string `json:"category,omitempty"`
} `json:"locations"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid_request"}`, http.StatusBadRequest)
return
}
if len(req.Locations) > 100 {
http.Error(w, `{"error":"max 100 locations per request"}`, http.StatusBadRequest)
return
}
results := make([]ServiceabilityResponse, 0, len(req.Locations))
for _, loc := range req.Locations {
start := time.Now()
hub, err := s.geoService.GetServiceableHub(r.Context(), loc.Lat, loc.Lng, loc.ProductCategory)
latencyMs := time.Since(start).Milliseconds()
resp := ServiceabilityResponse{
CacheHit: false,
LatencyMs: latencyMs,
}
if err == nil {
resp.Serviceable = true
resp.Hub = hub
}
results = append(results, resp)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"results": results,
"count": len(results),
})
}
// GeoMiddleware validates location parameters and injects them into context.
func GeoMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
latStr := r.URL.Query().Get("lat")
lngStr := r.URL.Query().Get("lng")
if latStr != "" && lngStr != "" {
lat, err := strconv.ParseFloat(latStr, 64)
if err == nil && lat >= -90 && lat <= 90 {
lng, err := strconv.ParseFloat(lngStr, 64)
if err == nil && lng >= -180 && lng <= 180 {
ctx := context.WithValue(r.Context(), ctxKeyLat{}, lat)
ctx = context.WithValue(ctx, ctxKeyLng{}, lng)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
}
next.ServeHTTP(w, r)
})
}
type ctxKeyLat struct{}
type ctxKeyLng struct{}
// GetLatFromContext returns the latitude from the request context.
func GetLatFromContext(ctx context.Context) (float64, bool) {
v, ok := ctx.Value(ctxKeyLat{}).(float64)
return v, ok
}
// GetLngFromContext returns the longitude from the request context.
func GetLngFromContext(ctx context.Context) (float64, bool) {
v, ok := ctx.Value(ctxKeyLng{}).(float64)
return v, ok
}
// NewGeoHTTPServer creates a fully wired HTTP server for geo serviceability.
func NewGeoHTTPServer(geoService *GeoService, logger *slog.Logger) *http.Server {
server := NewHubAssignmentServer(geoService, logger)
mux := http.NewServeMux()
mux.HandleFunc("GET /serviceability", server.HandleServiceability)
mux.HandleFunc("POST /serviceability/bulk", server.HandleBulkServiceability)
return &http.Server{
Handler: GeoMiddleware(mux),
}
}5. Fallback Strategy: Graceful Degradation
Problem
Redis down. PostGIS down. GPS gak akurat. User di daerah baru yang belum di-mapping. Apa yang terjadi?
Solution: Multi-layer Fallback
package geo
import (
"context"
"fmt"
"log/slog"
"time"
)
// FallbackStrategy defines how the geo service degrades when primary paths fail.
type FallbackStrategy struct {
logger *slog.Logger
}
// NewFallbackStrategy creates a new FallbackStrategy.
func NewFallbackStrategy(logger *slog.Logger) *FallbackStrategy {
return &FallbackStrategy{logger: logger}
}
// FallbackResult represents a degraded but functional hub assignment.
type FallbackResult struct {
HubID string `json:"hub_id"`
HubName string `json:"hub_name"`
Confidence string `json:"confidence"` // "high", "medium", "low"
Fallback string `json:"fallback"` // what was the fallback path
Estimated bool `json:"estimated"`
}
// ResolveWithFallback tries multiple strategies in order of reliability.
func (f *FallbackStrategy) ResolveWithFallback(
ctx context.Context,
lat, lng float64,
geoService *GeoService,
) (*FallbackResult, error) {
// Tier 1: Redis cache H3 lookup (fastest, < 5ms).
if hub, err := geoService.GetServiceableHub(ctx, lat, lng, ""); err == nil {
return &FallbackResult{
HubID: hub.HubID,
HubName: hub.HubName,
Confidence: "high",
Fallback: "h3_redis",
}, nil
}
// Tier 2: Euclidean nearest hub (no PostGIS, use distance only).
if hub, err := f.nearestHubByDistance(ctx, lat, lng, geoService); err == nil {
return &FallbackResult{
HubID: hub.HubID,
HubName: hub.HubName,
Confidence: "medium",
Fallback: "distance_only",
Estimated: true,
}, nil
}
// Tier 3: IP geolocation fallback.
if hub, err := f.ipGeolocationFallback(ctx, geoService); err == nil {
return &FallbackResult{
HubID: hub.HubID,
HubName: hub.HubName,
Confidence: "low",
Fallback: "ip_geolocation",
Estimated: true,
}, nil
}
// Tier 4: Default hub (city-level).
if hub, err := f.defaultHubForCity(ctx, lat, lng, geoService); err == nil {
return &FallbackResult{
HubID: hub.HubID,
HubName: hub.HubName,
Confidence: "low",
Fallback: "default_city",
Estimated: true,
}, nil
}
return nil, fmt.Errorf("geo: all fallbacks exhausted for (%.4f, %.4f)", lat, lng)
}
func (f *FallbackStrategy) nearestHubByDistance(ctx context.Context, lat, lng float64, geoService *GeoService) (*Hub, error) {
// Query all active hubs and find nearest by Haversine distance.
// In production, this would use a materialized hub list with coordinates.
return nil, fmt.Errorf("nearest hub fallback not implemented")
}
func (f *FallbackStrategy) ipGeolocationFallback(ctx context.Context, geoService *GeoService) (*Hub, error) {
// Use IP geolocation (MaxMind GeoIP) to determine approximate city,
// then find the default hub for that city.
return nil, fmt.Errorf("ip geolocation fallback not implemented")
}
func (f *FallbackStrategy) defaultHubForCity(ctx context.Context, lat, lng float64, geoService *GeoService) (*Hub, error) {
// Return the nearest active hub regardless of coverage polygon.
return nil, fmt.Errorf("default hub fallback not implemented")
}
// CircuitBreaker prevents cascading failures when external dependencies fail.
type CircuitBreaker struct {
failures int
threshold int
state string // "closed", "open", "half-open"
lastFailure time.Time
resetTimeout time.Duration
logger *slog.Logger
}
// NewCircuitBreaker creates a new CircuitBreaker.
func NewCircuitBreaker(threshold int, resetTimeout time.Duration, logger *slog.Logger) *CircuitBreaker {
return &CircuitBreaker{
threshold: threshold,
state: "closed",
resetTimeout: resetTimeout,
logger: logger,
}
}
// Execute runs a function with circuit breaker protection.
func (cb *CircuitBreaker) Execute(ctx context.Context, name string, fn func(ctx context.Context) error) error {
if cb.state == "open" {
if time.Since(cb.lastFailure) > cb.resetTimeout {
cb.state = "half-open"
cb.logger.InfoContext(ctx, "circuit breaker half-open", "service", name)
} else {
return fmt.Errorf("circuit breaker open for %s", name)
}
}
err := fn(ctx)
if err != nil {
cb.failures++
cb.lastFailure = time.Now()
if cb.failures >= cb.threshold {
cb.state = "open"
cb.logger.WarnContext(ctx, "circuit breaker opened",
"service", name,
"failures", cb.failures,
)
}
return err
}
// Success resets.
if cb.state == "half-open" {
cb.state = "closed"
cb.logger.InfoContext(ctx, "circuit breaker closed", "service", name)
}
cb.failures = 0
return nil
}Key Takeaways
H3 Hexagon Indexing
Resolusi 9 (~0.1 km2 per cell). O(1) lookup ke Redis. 6 neighbor ring untuk boundary smoothing.
PostGIS Polygon Validation
ST_Contains untuk boundary verification. ST_Distance untuk tie-break. Spatial index GIST.
Redis Caching
Cache cell to hub mapping dengan TTL 1 jam. Cache warming prevent cold start.
Tie-Break Multi-Hub
40% distance + 40% stock + 20% rider availability. Haversine distance untuk ETA estimate.
Multi-layer Fallback
H3 -> PostGIS -> Euclidean distance -> IP geolocation -> Default hub. Graceful degradation.
Cache Warming
Polygon to H3 cells precomputation. Batch insert ke Redis. Periodic refresh tiap 1 jam.
Bottom Line
Geo serviceability q-commerce bukan soal "dimana user" — tapi soal "dari hub mana user bisa dapet barangnya dalam < 30 menit dengan stok yang cukup dan rider yang tersedia." H3 + PostGIS + Redis adalah kombinasi yang sudah battle-tested di Uber Eats, Gojek, Instacart, dan Deliveroo. Kuncinya: cache fast path (H3), validate slow path (PostGIS), tie-break dengan business logic (stock + ETA + riders), dan jangan lupa fallback untuk edge cases.