Faisalaffan Blog
Faisalaffan Blog
Rekayasa Perangkat Lunak26 menit baca

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 Affan
Faisal Affan
Diterbitkan
Galeri
1 / 3

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?

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

sequenceDiagram
    participant U as User
    participant G as Geo Service<br/>(Go)
    participant R as Redis Cache<br/>(H3 cell -> Hub)
    participant P as PostGIS
    participant H as Hub Service
    participant S as Stock Service

    U->>G: GET /serviceability?lat=-6.2&lng=106.8
    
    G->>G: H3.LatLngToCell(lat, lng, res=9)
    Note over G: Convert GPS to H3 cell
    
    G->>R: GET h3:cell:882a5e3a9ffffff
    alt Cache Hit
        R-->>G: Hub IDs [hub_01, hub_03]
    else Cache Miss
        G->>P: SELECT hub_id FROM hub_coverage<br/>WHERE ST_Contains(polygon, point)
        P-->>G: Hub IDs
        G->>R: SETEX h3:cell:... 3600 [hub_01, hub_03]
    end

    Note over G: Multi-hub tie-break
    G->>H: Get hub details
    H-->>G: ETA, stock availability
    
    G->>S: Get stock for top hub
    S-->>G: Stock available: YES
    
    G-->>U: {"hub_id":"hub_01","eta":18,"stock_status":"available"}

H3 Hexagon vs Geohash vs S2

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

flowchart TD
    A[User GPS: lat, lng] --> B[Resolve H3 Cell]
    B --> C{H3 cell mapped<br/>to hub(s)?}
    C -->|1 hub| D[Return hub]
    C -->|Multiple hubs| E[Tie-break logic]
    C -->|No hub| F[Expand to neighbors]
    
    E --> G{Step 1: Distance<br/>terdekat?}
    G -->|Hub A < 2km| H[Hitung ETA]
    G -->|Hub B < 2km| H
    
    H --> I{Step 2: Stock<br/>> min threshold?}
    I -->|Hub A stock >= 5| J{Step 3: Rider<br/>availability?}
    I -->|Hub B out of stock| D
    
    J -->|Hub A has rider| K[Return Hub A]
    J -->|Hub A no rider| L[Fallback Hub B]
    
    F --> M{Neighbor H3 cell<br/>punya mapping?}
    M -->|Ya| C
    M -->|Tidak| N{Expand ke<br/>resolution 8<br/>(parent)?}
    N -->|Ketemu| D
    N -->|Tidak| O[Return NO_SERVICE]
    
    style K fill:#d1fae5,stroke:#10b981,color:#000
    style O fill:#fee2e2,stroke:#ef4444,color:#000
    style E fill:#dbeafe,stroke:#3b82f6,color:#000

1. H3 Cell Lookup: Resolving GPS to Hub

Implementasi Go dengan uber/h3-go

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

Go
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.

graph TD
    A[New hub coverage polygon] --> B[Convert to H3 cells<br/>at resolution 9]
    B --> C[For each cell, check<br/>ST_Intersects with polygon]
    C --> D[Cell fully inside?]
    D -->|Yes| E[Add to hub mapping]
    D -->|On boundary| F[Check cell centroid]
    F --> G[Centroid inside polygon?]
    G -->|Yes| E
    G -->|No| H[Skip]
    
    E --> I[Save to Postgres<br/>h3_cell_mapping table]
    I --> J[Warm Redis cache<br/>SETEX geo:h3:<cell> hub_ids]
    J --> K[Update GeoService<br/>reload signal]
    
    K --> L[Cool-down: 30s<br/>avoid cache stampede]
    L --> M[Done]
    
    style E fill:#d1fae5,stroke:#10b981,color:#000
    style J fill:#dbeafe,stroke:#3b82f6,color:#000
Go
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

Go
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

Go
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

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.

Artikel terkait

Rekayasa Perangkat Lunak

Search & Catalog 15k SKU Q-Commerce: Relevan + In-Stock Real-time

System design search dan catalog untuk quick commerce dengan 15.000+ SKU. Elasticsearch full-text search dengan multi-match query dan typo tolerance (fuzziness), filter ketersediaan per-hub via Redis pipeline query-time filtering, ranking berbasis relevansi, ketersediaan, margin, dan popularitas, index update via Kafka streaming, dan autocomplete handler. Implementasi Golang lengkap dengan worker concurrency pattern untuk index sync.
Faisal Affan
Faisal Affan
Diterbitkan

Real-time Order Tracking Q-Commerce: Driver Bergerak di Peta Live

System design real-time order tracking untuk quick commerce. Driver GPS ingestion via WebSocket dengan throttling dan validasi, Kalman filter untuk position smoothing, Redis pub/sub fan-out by order ID untuk scale ribuan driver concurrent, dan Server-Sent Events (SSE) push ke user client. Implementasi Golang dengan pattern WebSocket goroutine pool, pub/sub broker, dan concurrent connection management.
Faisal Affan
Faisal Affan
Diterbitkan
Rekayasa Perangkat Lunak
Real-time Inventory Dark Store: Cegah Oversell di Q-Commerce

Real-time Inventory Dark Store: Cegah Oversell di Q-Commerce

Desain inventory per-hub untuk Astro (instant grocery). Reservation pattern dengan Redis DECRBY atomic + TTL, async sync ke Postgres, stock partitioning untuk hot SKU, reaper job untuk reservation leak, dan cycle counting untuk stok fisik vs sistem. Implementasi Golang lengkap.
Faisal Affan
Faisal Affan
Diterbitkan

Promo & Voucher Engine Q-Commerce: Rule Engine Anti-Abuse

System design promo engine untuk quick commerce. Rule engine dengan kondisi komposable (min transaksi, first-N-order, area, kategori) ke efek (discount, free ongkir), atomic counter untuk limit penggunaan Redis Lua, anti-fraud device fingerprinting, stackable vs eksklusif promo resolution, dan budget cap. Implementasi Golang lengkap dengan composable condition interface, AND/OR composite tree, concurrent-safe redeem, serta Kafka event streaming untuk audit trail.
Faisal Affan
Faisal Affan
Diterbitkan