Back to Engineering Articles/Geo Serviceability Q-Commerce: Dari Titik GPS ke Hub dalam 10ms

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.

Faisal AffanFaisal Affan
6/20/2026
Geo Serviceability Q-Commerce: Dari Titik GPS ke Hub dalam 10ms — image 1 of 4
1 / 4

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.


Related Engineering & Tech Articles

Geo Serviceability Q-Commerce: Dari Titik GPS ke Hub dala... | Faisal Affan