Faisalaffan Blog
Faisalaffan Blog
Rekayasa Perangkat Lunak21 menit baca

ETA Estimation Q-Commerce: Janji Tiba dalam X Menit

System design ETA estimation untuk quick-commerce (q-commerce) dengan janji delivery dalam 15-30 menit. Pelajari picking time model berdasarkan jumlah item dan beban hub, dispatch queue wait time estimation, travel time via OSRM routing engine dengan ML model untuk prediksi traffic dan cuaca, p80 conservative ETA, miss rate monitoring dengan Prometheus, serta edge cases seperti traffic spike dan driver offline. Dilengkapi implementasi Golang dengan circuit breaker, Redis caching, gRPC client, dan concurrent pipeline.

Faisal Affan
Faisal Affan
Diterbitkan

ETA Estimation Q-Commerce: Janji Tiba dalam X Menit

ETA Estimation Q-Commerce: Janji Tiba dalam X Menit

"Janji ETA yang over-promise adalah pembunuh trust paling cepat di Q-Commerce. Lebih baik deliver di menit ke-28 dengan janji 30, daripada janji 15 tapi sampai 25."

TL;DR

ETA estimation di Q-Commerce bukan sekadar bagi jarak dengan kecepatan. Ia adalah masalah composability: picking time + queue wait + travel time + conservative buffer. Setiap komponen punya model sendiri-sendiri dengan ketidakpastian masing-masing. Artikel ini membahas implementasi ETA calculator service di Go, integrasi OSRM/Google Maps routing dengan circuit breaker, ML prediction client via gRPC, Redis caching layer, Prometheus monitoring, dan strategi p80/p95 untuk menjaga akurasi janji.


Masalah: Mengapa ETA Q-Commerce Sulit Diprediksi?

Di Q-Commerce, ETA adalah janji yang terikat SLA. Jika kamu janji 20 menit tapi sampai 30 menit, pelanggan kecewa dan churn. Jika kamu janji 40 menit (terlalu konservatif), pelanggan memilih kompetitor.

Kompleksitas ETA Q-Commerce:

  1. Multi-phase: Bukan hanya travel time. Ada picking time di hub, waiting time di antrian dispatch, dan travel time dari hub ke pelanggan.
  2. Dynamic variables: Jumlah item, beban hub (berapa order concurrent), jumlah driver available, traffic, cuaca, hari libur — semuanya berubah setiap detik.
  3. Non-linear scaling: 10 item tidak berarti 2x lebih lama dari 5 item. Ada fixed overhead untuk scanning, packing, dan handoff.
  4. Spatial variance: ETA ke pelanggan di pusat kota vs pinggiran berbeda drastis karena traffic dan jarak hub.
  5. Cascading delays: Jika picking molor 5 menit, dan traffic menambah 3 menit, total delay bukan 8 menit — karena driver mungkin sudah mengambil order batch lain yang juga terpengaruh.
Key Principle

Jangan pernah mengembalikan ETA tunggal. Selalu kembalikan p80 ETA (80% probability delivery within this time) atau rentang (20-25 menit). ETA tunggal adalah bohong — tidak ada prediksi yang exact.


Arsitektur ETA Estimation

sequenceDiagram
    participant User as User App
    participant OrderSvc as Order Service
    participant ETA as ETA Calculator
    participant Picking as Picking Estimator
    participant Queue as Queue Wait Estimator
    participant Travel as Travel Time Fetcher
    participant ML as ML Prediction Client
    participant OSRM as OSRM/Google Maps
    participant Monitor as ETA Monitor

    User->>OrderSvc: Checkout / View ETA
    OrderSvc->>ETA: CalculateETA(order, hub)
    ETA->>Picking: EstimatePicking(item_count, hub_load)
    Picking-->>ETA: picking_time_s
    
    ETA->>Queue: EstimateQueueWait(queue_depth, drivers_available)
    Queue-->>ETA: queue_wait_s
    
    ETA->>Travel: FetchTravelTime(hub_lat, hub_lng, cust_lat, cust_lng)
    Travel->>ML: PredictTravelTime(route, hour, weekday, weather)
    ML-->>Travel: predicted_travel_s
    Travel->>OSRM: GET /route/v1/driving/...
    OSRM-->>Travel: route + duration
    Travel-->>ETA: travel_time_s
    
    ETA->>ETA: ApplyConservativeBuffer(total_time)
    ETA-->>OrderSvc: ETA{p50, p80, p95}
    OrderSvc-->>User: Estimated delivery time
    
    Note over ETA,Monitor: Async monitoring
    ETA->>Monitor: RecordETA(promised, actual, components)
    Monitor->>Monitor: CalculateMissRate()

System Components

1. ETA Calculator Service: Kompositor Utama

ETA Calculator adalah service yang menggabungkan semua sub-estimasi. Ia bertanggung jawab untuk:

  • Memanggil sub-estimator secara concurrent
  • Menggabungkan hasil dengan formula yang tepat
  • Menambahkan conservative buffer
  • Mengembalikan ETA dalam format p50/p80/p95
Go
package eta

import (
	"context"
	"fmt"
	"math"
	"sync"
	"time"
)

// ETAConfig controls estimation parameters.
type ETAConfig struct {
	PickingPerItemMean   float64 // seconds per item (mean)
	PickingPerItemStd    float64 // standard deviation
	HubOverheadFixed     float64 // fixed overhead per order (scan, pack)
	QueueBaseLatency     float64 // base queue wait when no queue
	TravelSpeedMean      float64 // m/s mean
	ConservativeBufferPct float64 // buffer as % of total (e.g., 0.20 = 20%)
	MaxETA               float64 // absolute max ETA in seconds (1800 = 30 min)
}

// ETARequest is the input to the ETA calculator.
type ETARequest struct {
	OrderID       string  `json:"order_id"`
	HubID         string  `json:"hub_id"`
	ItemCount     int     `json:"item_count"`
	HubLat, HubLng float64 `json:"hub_location"`
	CustLat, CustLng float64 `json:"customer_location"`
	QueueDepth    int     `json:"queue_depth"`
	DriversAvail  int     `json:"drivers_available"`
	Hour          int     `json:"hour"`          // 0-23
	Weekday       int     `json:"weekday"`        // 0=Sunday
}

// ETAResponse contains the final ETA estimates.
type ETAResponse struct {
	P50ETA         float64            `json:"p50_eta_s"`
	P80ETA         float64            `json:"p80_eta_s"`
	P95ETA         float64            `json:"p95_eta_s"`
	Components     ETAComponents      `json:"components"`
	FormattedETA   string             `json:"formatted_eta"`
}

// ETAComponents show the breakdown for debugging.
type ETAComponents struct {
	PickingTime      float64 `json:"picking_time_s"`
	QueueWaitTime    float64 `json:"queue_wait_time_s"`
	TravelTime       float64 `json:"travel_time_s"`
	ConservativeBuffer float64 `json:"conservative_buffer_s"`
	Total            float64 `json:"total_s"`
}

// ETAEstimator composes all ETA components.
type ETAEstimator struct {
	cfg          ETAConfig
	pickingSvc   *PickingEstimator
	queueSvc     *QueueEstimator
	travelSvc    *TravelTimeFetcher
}

// NewETAEstimator creates the estimator.
func NewETAEstimator(cfg ETAConfig, picking *PickingEstimator, queue *QueueEstimator, travel *TravelTimeFetcher) *ETAEstimator {
	return &ETAEstimator{
		cfg:        cfg,
		pickingSvc: picking,
		queueSvc:   queue,
		travelSvc:  travel,
	}
}

// Calculate runs all sub-estimations concurrently and combines results.
func (e *ETAEstimator) Calculate(ctx context.Context, req ETARequest) (*ETAResponse, error) {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	type subResult struct {
		picking float64
		queue   float64
		travel  float64
	}

	var (
		result subResult
		mu     sync.Mutex
		wg     sync.WaitGroup
		errs   []error
		errMu  sync.Mutex
	)

	collectErr := func(err error) {
		errMu.Lock()
		errs = append(errs, err)
		errMu.Unlock()
	}

	// Concurrent: picking estimation
	wg.Add(1)
	go func() {
		defer wg.Done()
		p, err := e.pickingSvc.Estimate(ctx, req.ItemCount, req.HubID)
		if err != nil {
			collectErr(fmt.Errorf("picking: %w", err))
			return
		}
		mu.Lock()
		result.picking = p
		mu.Unlock()
	}()

	// Concurrent: queue wait estimation
	wg.Add(1)
	go func() {
		defer wg.Done()
		q, err := e.queueSvc.Estimate(ctx, req.QueueDepth, req.DriversAvail)
		if err != nil {
			collectErr(fmt.Errorf("queue: %w", err))
			return
		}
		mu.Lock()
		result.queue = q
		mu.Unlock()
	}()

	// Concurrent: travel time estimation
	wg.Add(1)
	go func() {
		defer wg.Done()
		t, err := e.travelSvc.Fetch(ctx, req.HubLat, req.HubLng, req.CustLat, req.CustLng, req.Hour, req.Weekday)
		if err != nil {
			collectErr(fmt.Errorf("travel: %w", err))
			return
		}
		mu.Lock()
		result.travel = t
		mu.Unlock()
	}()

	wg.Wait()

	if len(errs) > 0 {
		// If any component fails, fall back to conservative estimate
		return e.fallbackEstimate(req), nil
	}

	// Combine components
	totalBase := result.picking + result.queue + result.travel
	buffer := totalBase * e.cfg.ConservativeBufferPct
	total := totalBase + buffer

	if total > e.cfg.MaxETA {
		total = e.cfg.MaxETA
		buffer = total - totalBase
	}

	// p50 = base, p80 = base + buffer, p95 = base + buffer*2
	p50 := totalBase
	p80 := total
	p95 := math.Min(totalBase+buffer*2, e.cfg.MaxETA)

	resp := &ETAResponse{
		P50ETA: p50,
		P80ETA: p80,
		P95ETA: p95,
		Components: ETAComponents{
			PickingTime:        result.picking,
			QueueWaitTime:      result.queue,
			TravelTime:         result.travel,
			ConservativeBuffer: buffer,
			Total:              total,
		},
		FormattedETA: formatETA(p80),
	}

	return resp, nil
}

// Fallback when sub-services fail.
func (e *ETAEstimator) fallbackEstimate(req ETARequest) *ETAResponse {
	// Conservative fallback based on simple distance heuristic
	dist := haversine(req.HubLat, req.HubLng, req.CustLat, req.CustLng)
	avgSpeed := 8.33 // m/s (~30 km/h)
	travelEst := dist / avgSpeed

	pickingEst := e.cfg.HubOverheadFixed + float64(req.ItemCount)*e.cfg.PickingPerItemMean
	queueEst := e.cfg.QueueBaseLatency

	total := pickingEst + queueEst + travelEst
	buffer := total * 0.30 // more conservative buffer for fallback

	resp := &ETAResponse{
		P80ETA: total + buffer,
		Components: ETAComponents{
			PickingTime:        pickingEst,
			QueueWaitTime:      queueEst,
			TravelTime:         travelEst,
			ConservativeBuffer: buffer,
			Total:              total + buffer,
		},
		FormattedETA: formatETA(total + buffer),
	}
	return resp
}

// formatETA converts seconds to "X-Y min" format.
func formatETA(seconds float64) string {
	min := int(math.Ceil(seconds / 60))
	if min < 1 {
		min = 1
	}
	return fmt.Sprintf("%d min", min)
}

// haversine calculates distance in meters.
func haversine(lat1, lng1, lat2, lng2 float64) float64 {
	const R = 6371000
	dLat := (lat2 - lat1) * math.Pi / 180
	dLng := (lng2 - lng1) * math.Pi / 180
	a := math.Sin(dLat/2)*math.Sin(dLat/2) +
		math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*
			math.Sin(dLng/2)*math.Sin(dLng/2)
	c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
	return R * c
}

2. Picking Time Model

Picking time di hub tergantung pada jumlah item, tata letak hub, dan beban concurrent picker. Model statistik sederhana:

Plaintext
picking_time = hub_overhead + N * per_item_mean + random_noise(per_item_std)

Tapi di production, kita perlu model yang lebih canggih — terutama ketika hub sedang sibuk (antrian picking).

Go
package eta

import (
	"context"
	"fmt"
	"math"
	"math/rand"
	"sync"
	"time"
)

// HubLoad tracks current picking load per hub.
type HubLoad struct {
	mu           sync.RWMutex
	hubPicking   map[string]int // hub_id -> concurrent orders being picked
	pickerCapacity int          // max concurrent pickers per hub
}

// NewHubLoad creates a new tracker.
func NewHubLoad(pickerCapacity int) *HubLoad {
	return &HubLoad{
		hubPicking:     make(map[string]int),
		pickerCapacity: pickerCapacity,
	}
}

// AddPicking increments load when an order starts picking.
func (h *HubLoad) AddPicking(hubID string) {
	h.mu.Lock()
	h.hubPicking[hubID]++
	h.mu.Unlock()
}

// RemovePicking decrements when picking completes.
func (h *HubLoad) RemovePicking(hubID string) {
	h.mu.Lock()
	if v := h.hubPicking[hubID]; v > 0 {
		h.hubPicking[hubID]--
	}
	h.mu.Unlock()
}

// GetLoad returns current picking load ratio (0..1+).
func (h *HubLoad) GetLoad(hubID string) float64 {
	h.mu.RLock()
	defer h.mu.RUnlock()
	count := h.hubPicking[hubID]
	if h.pickerCapacity == 0 {
		return 0
	}
	return float64(count) / float64(h.pickerCapacity)
}

// PickingEstimator predicts picking duration.
type PickingEstimator struct {
	cfg           ETAConfig
	hubLoad       *HubLoad
}

// NewPickingEstimator creates the estimator.
func NewPickingEstimator(cfg ETAConfig, hubLoad *HubLoad) *PickingEstimator {
	return &PickingEstimator{
		cfg:     cfg,
		hubLoad: hubLoad,
	}
}

// Estimate returns predicted picking time in seconds.
func (pe *PickingEstimator) Estimate(ctx context.Context, itemCount int, hubID string) (float64, error) {
	// Fixed overhead: scan order, print label, grab bags
	fixedOverhead := pe.cfg.HubOverheadFixed

	// Per-item time: mean + small random noise for realism
	perItemMean := pe.cfg.PickingPerItemMean
	perItemStd := pe.cfg.PickingPerItemStd

	// Base picking time
	baseTime := fixedOverhead + float64(itemCount)*perItemMean

	// Add noise (deterministic per item count for consistency)
	noise := perItemStd * rand.NormFloat64()

	// Hub load multiplier: if load > 1.0 (over capacity), picking slows down
	load := pe.hubLoad.GetLoad(hubID)
	loadMultiplier := 1.0
	if load > 1.0 {
		// Overcapacity: linear degradation
		loadMultiplier = 1.0 + (load-1.0)*0.3
	}

	total := (baseTime + noise) * loadMultiplier

	if total < fixedOverhead {
		total = fixedOverhead
	}

	return math.Round(total*10) / 10, nil
}
Model Validation

Di production, kamu perlu historical data untuk kalibrasi model. Track actual picking time vs predicted, lalu hitung bias. Jika rata-rata error >20%, adjust per_item_mean atau hub_overhead. Contoh data yang perlu dikumpulkan: picking_started_at, picking_completed_at, item_count, hub_id, picker_id.


3. Travel Time Fetcher: OSRM + ML + Circuit Breaker

Travel time adalah komponen paling tidak pasti. Google Maps atau OSRM bisa memberikan estimasi rute, tapi akurasinya tergantung pada kondisi real-time (traffic, cuaca, event). Kita kombinasikan routing engine dengan ML model yang belajar dari historical patterns.

flowchart TD
    A[ETA Calculator] --> B[Travel Time Fetcher]
    B --> C{Use Cache?}
    C -->|Yes| D[Redis Cache]
    C -->|No| E{Circuit Breaker OK?}
    E -->|Open| F[Fallback: ML-only]
    E -->|Closed| G[OSRM / Google Maps]
    G --> H[Parse Response]
    H --> I[Cache Result]
    G -->|Error/Timeout| J[Circuit Breaker Count]
    J -->|3 errors| K[Circuit Open]
    K -->|After 30s| L[Circuit Half-Open]
    L -->|Next req OK| M[Circuit Closed]
    
    F --> N[ML Model Prediction]
    N --> O[Feature Vector]
    O --> P[Hour, Weekday, Weather,<br/>Historical AVG, Distance]
    P --> Q[Predicted Travel Time]
    Q --> R[Combine with Route (if available)]
    I --> R
    R --> S[Return to ETA Calculator]
Go
package eta

import (
	"context"
	"encoding/json"
	"fmt"
	"math"
	"net/http"
	"sync"
	"time"

	"github.com/redis/go-redis/v9"
)

// TravelTimeFetcher fetches travel time from routing API with ML fallback.
type TravelTimeFetcher struct {
	httpClient    *http.Client
	rdb           *redis.Client
	mlClient      *MLPredictionClient
	osrmBaseURL   string
	apiKey        string
	circuitBreaker *CircuitBreaker
	cacheTTL      time.Duration
}

// NewTravelTimeFetcher creates the fetcher.
func NewTravelTimeFetcher(
	httpClient *http.Client,
	rdb *redis.Client,
	mlClient *MLPredictionClient,
	osrmBaseURL string,
	apiKey string,
	cacheTTL time.Duration,
) *TravelTimeFetcher {
	return &TravelTimeFetcher{
		httpClient:    httpClient,
		rdb:           rdb,
		mlClient:      mlClient,
		osrmBaseURL:   osrmBaseURL,
		apiKey:        apiKey,
		circuitBreaker: NewCircuitBreaker(3, 30*time.Second),
		cacheTTL:      cacheTTL,
	}
}

// Fetch combines routing API + ML prediction for travel time.
func (tf *TravelTimeFetcher) Fetch(ctx context.Context, hubLat, hubLng, custLat, custLng float64, hour, weekday int) (float64, error) {
	// Generate cache key
	cacheKey := fmt.Sprintf("eta:travel:%0.4f:%0.4f:%0.4f:%0.4f:%d:%d",
		hubLat, hubLng, custLat, custLng, hour, weekday)

	// Try cache first
	if cached, err := tf.rdb.Get(ctx, cacheKey).Float64(); err == nil {
		return cached, nil
	}

	// Try OSRM/Google Maps routing API (with circuit breaker)
	var routeDuration float64
	if tf.circuitBreaker.Allow() {
		var err error
		routeDuration, err = tf.callRoutingAPI(ctx, hubLat, hubLng, custLat, custLng)
		if err != nil {
			tf.circuitBreaker.Failure()
		} else {
			tf.circuitBreaker.Success()
		}
	}

	// Get ML prediction
	mlDuration, err := tf.mlClient.Predict(ctx, hubLat, hubLng, custLat, custLng, hour, weekday)
	if err != nil {
		// If ML also fails and we have route, use route directly
		if routeDuration > 0 {
			mlDuration = routeDuration
		} else {
			// Last resort: haversine * speed factor
			dist := haversine(hubLat, hubLng, custLat, custLng)
			mlDuration = dist / 8.33 // ~30 km/h
		}
	}

	// Final: weighted average (more weight to ML if available)
	var finalDuration float64
	if routeDuration > 0 && mlDuration > 0 {
		// Route from API but ML adjusts for conditions
		finalDuration = routeDuration*0.6 + mlDuration*0.4
	} else if routeDuration > 0 {
		finalDuration = routeDuration
	} else {
		finalDuration = mlDuration
	}

	// Cache async
	go func() {
		cctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		tf.rdb.Set(cctx, cacheKey, finalDuration, tf.cacheTTL)
		cancel()
	}()

	return math.Round(finalDuration), nil
}

// callRoutingAPI calls OSRM or Google Maps Directions API.
func (tf *TravelTimeFetcher) callRoutingAPI(ctx context.Context, hubLat, hubLng, custLat, custLng float64) (float64, error) {
	url := fmt.Sprintf("%s/route/v1/driving/%f,%f;%f,%f?overview=false",
		tf.osrmBaseURL, hubLng, hubLat, custLng, custLat)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return 0, fmt.Errorf("create request: %w", err)
	}

	if tf.apiKey != "" {
		req.Header.Set("Authorization", "Bearer "+tf.apiKey)
	}

	resp, err := tf.httpClient.Do(req)
	if err != nil {
		return 0, fmt.Errorf("routing api call: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return 0, fmt.Errorf("routing api status: %d", resp.StatusCode)
	}

	var result struct {
		Routes []struct {
			Duration float64 `json:"duration"`
		} `json:"routes"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return 0, fmt.Errorf("decode routing response: %w", err)
	}

	if len(result.Routes) == 0 {
		return 0, fmt.Errorf("no routes found")
	}

	return result.Routes[0].Duration, nil
}

// CircuitBreaker protects the routing API from cascading failures.
type CircuitBreaker struct {
	mu            sync.Mutex
	failCount     int
	maxFailures   int
	resetTimeout  time.Duration
	lastFailure   time.Time
	state         string // closed, open, half-open
}

// NewCircuitBreaker creates a circuit breaker.
func NewCircuitBreaker(maxFailures int, resetTimeout time.Duration) *CircuitBreaker {
	return &CircuitBreaker{
		maxFailures:  maxFailures,
		resetTimeout: resetTimeout,
		state:        "closed",
	}
}

// Allow returns true if the request should be sent.
func (cb *CircuitBreaker) Allow() bool {
	cb.mu.Lock()
	defer cb.mu.Unlock()

	switch cb.state {
	case "closed":
		return true
	case "open":
		if time.Since(cb.lastFailure) >= cb.resetTimeout {
			cb.state = "half-open"
			return true
		}
		return false
	case "half-open":
		return true
	default:
		return true
	}
}

// Success records a successful call.
func (cb *CircuitBreaker) Success() {
	cb.mu.Lock()
	defer cb.mu.Unlock()
	cb.failCount = 0
	cb.state = "closed"
}

// Failure records a failed call.
func (cb *CircuitBreaker) Failure() {
	cb.mu.Lock()
	defer cb.mu.Unlock()
	cb.failCount++
	cb.lastFailure = time.Now()
	if cb.failCount >= cb.maxFailures {
		cb.state = "open"
	}
}

4. ML Prediction Client: gRPC ke ML Model Service

Model ML untuk prediksi travel time menerima feature vector dan mengembalikan estimasi. Kita panggil via gRPC.

Go
package eta

import (
	"context"
	"fmt"
	"math"
	"time"

	pb "eta/gen/go/mlprediction/v1"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

// MLPredictionClient calls the ML model service for travel time predictions.
type MLPredictionClient struct {
	conn   *grpc.ClientConn
	client pb.PredictionServiceClient
}

// NewMLPredictionClient creates a gRPC client.
func NewMLPredictionClient(serverAddr string, timeout time.Duration) (*MLPredictionClient, error) {
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()

	conn, err := grpc.DialContext(ctx, serverAddr,
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(1024*1024)),
	)
	if err != nil {
		return nil, fmt.Errorf("ml grpc dial: %w", err)
	}

	return &MLPredictionClient{
		conn:   conn,
		client: pb.NewPredictionServiceClient(conn),
	}, nil
}

// Predict sends feature vector and returns predicted travel time in seconds.
func (m *MLPredictionClient) Predict(ctx context.Context, hubLat, hubLng, custLat, custLng float64, hour, weekday int) (float64, error) {
	dist := haversine(hubLat, hubLng, custLat, custLng)

	req := &pb.PredictRequest{
		Features: &pb.Features{
			DistanceMeters:  dist,
			Hour:            int32(hour),
			Weekday:         int32(weekday),
			OriginLat:       hubLat,
			OriginLng:       hubLng,
			DestLat:         custLat,
			DestLng:         custLng,
		},
	}

	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	resp, err := m.client.Predict(ctx, req)
	if err != nil {
		return 0, fmt.Errorf("ml predict: %w", err)
	}

	return resp.PredictedDurationSeconds, nil
}

// Close gracefully shuts down the connection.
func (m *MLPredictionClient) Close() error {
	return m.conn.Close()
}

Protobuf definition untuk ML prediction service:

Protobuf
syntax = "proto3";

package mlprediction.v1;

service PredictionService {
  rpc Predict(PredictRequest) returns (PredictResponse);
}

message Features {
  double distance_meters = 1;
  int32 hour = 2;
  int32 weekday = 3;
  double origin_lat = 4;
  double origin_lng = 5;
  double dest_lat = 6;
  double dest_lng = 7;
  // Optional fields used by the model
  double weather_score = 8;      // 0=clear, 1=rain, 2=storm
  int32 holiday_factor = 9;      // 1=normal, 1.5=holiday
  double historical_p50 = 10;    // historical p50 for this route-time
}

message PredictRequest {
  Features features = 1;
}

message PredictResponse {
  double predicted_duration_seconds = 1;
  double confidence_score = 2;     // 0..1
  string model_version = 3;
}

5. Queue Wait Estimator

Sebelum picking dimulai, order harus menunggu di antrian dispatch. Waktu tunggu tergantung pada jumlah order di antrian dan jumlah driver idle.

Go
package eta

import (
	"context"
	"fmt"
	"math"
	"time"
)

// QueueEstimator predicts queue wait time.
type QueueEstimator struct {
	cfg            ETAConfig
	dispatchLatency time.Duration // average dispatch cycle time
}

// NewQueueEstimator creates the estimator.
func NewQueueEstimator(cfg ETAConfig, dispatchLatency time.Duration) *QueueEstimator {
	return &QueueEstimator{
		cfg:            cfg,
		dispatchLatency: dispatchLatency,
	}
}

// Estimate returns predicted queue wait time in seconds.
func (qe *QueueEstimator) Estimate(ctx context.Context, queueDepth, driversAvail int) (float64, error) {
	if driversAvail == 0 {
		// No drivers — queue will grow, estimate high
		return qe.cfg.QueueBaseLatency * 3, nil
	}

	// Each dispatch cycle processes ~N orders (batch size)
	avgBatchSize := float64(driversAvail) * 1.5 // each driver can take ~1.5 orders on avg

	cyclesNeeded := float64(queueDepth) / avgBatchSize
	if cyclesNeeded < 1 {
		cyclesNeeded = 1
	}

	waitTime := cyclesNeeded * qe.dispatchLatency.Seconds()

	if waitTime < qe.cfg.QueueBaseLatency {
		waitTime = qe.cfg.QueueBaseLatency
	}

	return math.Round(waitTime), nil
}

6. ETA Monitor: Track Miss Rate

Setiap order yang selesai harus dicocokkan dengan ETA yang dijanjikan. Ini feedback loop untuk kalibrasi model.

flowchart TD
    subgraph Monitoring["ETA Monitoring Stack"]
        A1[Order Completed] --> A2[Calculate Actual vs Promised]
        A2 --> A3{Miss?}
        A3 -->|Yes| A4[Increment Miss Counter]
        A3 -->|No| A5[Increment Hit Counter]
        
        A4 --> A6[Miss Rate = Misses / Total]
        A6 --> A7{p80 Miss Rate}
        A7 -->|> 10%| A8[Alert: High Miss Rate]
        A7 -->|< 5%| A9[All Good]
        
        A2 --> B1[Record Component Errors]
        B1 --> B2[Picking Time Error]
        B1 --> B3[Travel Time Error]
        B1 --> B4[Queue Wait Error]
        
        B2 --> C1[Model Re-calibration Trigger]
        B3 --> C1
        B4 --> C1
    end
    
    subgraph Prometheus["Prometheus Metrics"]
        D1[eta_promised_seconds]
        D2[eta_actual_seconds]
        D3[eta_miss_total]
        D4[eta_component_picking_error]
        D5[eta_component_travel_error]
    end
    
    A2 --> D1
    A2 --> D2
    A4 --> D3
    B2 --> D4
    B3 --> D5
    
    subgraph Grafana["Grafana Dashboard"]
        E1[Miss Rate Over Time]
        E2[ETA Distribution Heatmap]
        E3[Component Error Breakdown]
    end
    
    D1 --> E1
    D2 --> E2
    D3 --> E1
    D4 --> E3
    D5 --> E3
Go
package eta

import (
	"context"
	"fmt"
	"sync"
	"time"
)

// ETAMonitor tracks promised vs actual delivery times.
type ETAMonitor struct {
	mu          sync.RWMutex
	missRate    float64
	totalOrders int
	missCount   int
	threshold   float64 // max acceptable miss rate (e.g., 0.10 = 10%)
	alertChan   chan Alert
}

// ETAObservation records one delivery's ETA data.
type ETAObservation struct {
	OrderID       string         `json:"order_id"`
	PromisedETA   float64        `json:"promised_eta_s"`
	ActualETA     float64        `json:"actual_eta_s"`
	Components    ETAComponents  `json:"components"`
	Miss          bool           `json:"miss"`
	CreatedAt     time.Time      `json:"created_at"`
}

// Alert is triggered when miss rate exceeds threshold.
type Alert struct {
	Type      string    `json:"type"`
	MissRate  float64   `json:"miss_rate"`
	Threshold float64   `json:"threshold"`
	Timestamp time.Time `json:"timestamp"`
}

// NewETAMonitor creates the monitor.
func NewETAMonitor(threshold float64) *ETAMonitor {
	return &ETAMonitor{
		threshold: threshold,
		alertChan: make(chan Alert, 10),
	}
}

// Record submits an ETA observation.
func (m *ETAMonitor) Record(ctx context.Context, obs ETAObservation) error {
	m.mu.Lock()
	defer m.mu.Unlock()

	m.totalOrders++
	if obs.Miss {
		m.missCount++
	}

	m.missRate = float64(m.missCount) / float64(m.totalOrders)

	// Emit alert if miss rate exceeds threshold
	if m.missRate > m.threshold {
		select {
		case m.alertChan <- Alert{
			Type:      "eta_high_miss_rate",
			MissRate:  m.missRate,
			Threshold: m.threshold,
			Timestamp: time.Now().UTC(),
		}:
		default:
			// Alert channel full, skip
		}
	}

	return nil
}

// MissRate returns the current miss rate.
func (m *ETAMonitor) MissRate() float64 {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return m.missRate
}

// AlertChan returns the alert channel.
func (m *ETAMonitor) AlertChan() <-chan Alert {
	return m.alertChan
}

// Prometheus-compatible metrics (implementation sketch).
type ETAMetrics struct {
	mu       sync.Mutex
	metrics  map[string]float64
}

// RecordObservation records metrics for Prometheus exposition.
func (em *ETAMetrics) RecordObservation(ctx context.Context, obs ETAObservation) {
	em.mu.Lock()
	defer em.mu.Unlock()

	em.metrics["eta_total_orders"]++
	if obs.Miss {
		em.metrics["eta_miss_total"]++
	}

	// Component-level errors
	pickingError := obs.ActualETA - obs.Components.PickingTime
	em.metrics["eta_picking_error_sum"] += pickingError

	travelError := obs.ActualETA - obs.Components.TravelTime
	em.metrics["eta_travel_error_sum"] += travelError
}

Edge Cases dan Penanganannya

1. Traffic Spike (Hujan, Kecelakaan, Event)

Masalah: Tiba-tiba hujan deras. Traffic melambat 2x lipat. OSRM routing API masih mengembalikan estimasi berdasarkan speed limit, bukan real-time traffic.

Solusi: ML model harus mendeteksi anomaly dari data real-time. Jika travel time aktual di 15 menit terakhir untuk rute serupa >1.5x historical, apply traffic multiplier.

Go
// TrafficAwareEstimator adjusts ETA based on recent traffic observations.
type TrafficAwareEstimator struct {
	recentTravelTimes *slidingWindow // map[routeHash][]float64
	mu                sync.RWMutex
}

type slidingWindow struct {
	buckets map[int64][]float64 // unix minute -> travel times
	maxAge  int64               // keep last N minutes
}

func (tae *TrafficAwareEstimator) getTrafficMultiplier(routeHash string) float64 {
	recent := tae.recentTravelTimes.GetRecent(routeHash, 15) // last 15 min
	if len(recent) < 5 {
		return 1.0 // not enough data
	}

	avg := avgFloat64(recent)
	baseline := tae.getBaseline(routeHash) // historical average

	if baseline == 0 {
		return 1.0
	}

	ratio := avg / baseline
	if ratio > 2.0 {
		return 2.0 // cap at 2x
	}
	return ratio
}

2. Hub Overload

Masalah: Hari libur nasional. Order volume 5x normal. Picking time model under-prediksi karena antrian picker panjang.

Solusi: Model hub load dengan concurrency tracking. Jika jumlah concurrent pick > capacity, hitting multiplier non-linear.

Plaintext
effective_picking_time = base_time * (1 + 0.2 * (concurrent_orders / capacity)^2)

3. Driver Tidak Ada yang Available

Masalah: Semua driver sedang sibuk. Queue wait estimator harus mengantisipasi — bukan hanya melihat queue depth, tapi juga driver ETA selesai.

Solusi: Driver completion prediction. Jika 5 driver sedang delivering dengan estimated completion dalam 10 menit, queue wait bisa dihitung dari kapan driver pertama available, bukan dari queue depth statis.

4. ETA yang Berubah-ubah

Masalah: Setiap refresh halaman, ETA berubah karena ML model memberikan output yang berbeda (randomness).

Solusi: Sticky ETA — setelah ETA pertama dihitung, simpan di Redis dengan TTL 30 detik. Refresh dalam window yang sama mengembalikan ETA yang sama. Ini mencegah kebingungan pelanggan.

Go
func (e *ETAEstimator) getStickyETA(ctx context.Context, req ETARequest) (*ETAResponse, error) {
	stickyKey := fmt.Sprintf("eta:sticky:%s", req.OrderID)

	// Try existing sticky ETA
	var cached ETAResponse
	if err := e.rdb.Get(ctx, stickyKey).Scan(&cached); err == nil {
		return &cached, nil
	}

	// Calculate fresh ETA
	resp, err := e.Calculate(ctx, req)
	if err != nil {
		return nil, err
	}

	// Cache for 30 seconds
	e.rdb.Set(ctx, stickyKey, resp, 30*time.Second)
	return resp, nil
}

5. Cold Start (Belum Ada Data Historis)

Masalah: Hub baru buka. Tidak ada historical data untuk ML model. Queue dan picking model belum terkalibrasi.

Solusi: Fallback heuristic — gunakan parameter default yang konservatif. Tandai prediksi sebagai cold_start: true sehingga monitoring bisa membedakan. Kumpulkan data 100 order pertama untuk initial calibration.

Go
type ETAMode string

const (
	ETAModeColdStart ETAMode = "cold_start"
	ETAModeNormal    ETAMode = "normal"
)

func (e *ETAEstimator) detectMode(hubID string) ETAMode {
	orderCount, _ := e.rdb.Get(context.Background(), fmt.Sprintf("hub:%s:total_orders", hubID)).Int()
	if orderCount < 100 {
		return ETAModeColdStart
	}
	return ETAModeNormal
}

ETA Decomposition Flowchart

flowchart LR
    subgraph ETA["ETA Decomposition"]
        A[Order Created] --> B[Picking Time]
        B --> B1["Fixed Overhead (8s)"]
        B --> B2["Per-Item (3s/item)"]
        B --> B3["Hub Load Multiplier"]
        
        A --> C[Dispatch Queue Wait]
        C --> C1["Queue Depth"]
        C --> C2["Batch Cycle Time"]
        C --> C3["Drivers Available"]
        
        A --> D[Travel Time]
        D --> D1["OSRM Route Duration"]
        D --> D2["ML Condition Adjustment"]
        D2 --> D2a["Hour of Day"]
        D2 --> D2b["Traffic Factor"]
        D2 --> D2c["Weather Factor"]
        D2 --> D2d["Holiday Factor"]
        
        A --> E[Conservative Buffer]
        E --> E1["20% of Total"]
        E --> E2["Min 120s"]
        E --> E3["Max 300s"]
    end
    
    B1 + B2 + B3 --> F[Picking Subtotal]
    C1 + C2 + C3 --> G[Queue Subtotal]
    D1 + D2 --> H[Travel Subtotal]
    
    F + G + H + E --> I[FINAL ETA]
    
    I --> J[Show to Customer]
    J --> K[Order Delivered]
    K --> L[Compare: Promised vs Actual]
    L --> M{Miss?}
    M -->|Yes| N[Adjust Models]
    M -->|No| O[Keep Calibrated]

Key Takeaways

Penutup

ETA estimation adalah masalah yang tidak akan pernah selesai — selama variabel dunia nyata berubah (traffic, cuaca, hari libur, behaviour pelanggan), model harus terus diperbarui. Kuncinya bukan pada model yang sempurna, tapi pada feedback loop yang cepat: janji → deliver → ukur miss → adjust. Dengan arsitektur composable components, circuit breaker, caching, dan Prometheus monitoring seperti di atas, kamu bisa membangun sistem ETA yang akurat dan resilient.

Kode di atas adalah production-ready template. Untuk production, tambahkan: distributed tracing, A/B testing untuk buffer percentage tuning, dan automated model retraining pipeline berdasarkan weekly miss rate data.

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