Back to Engineering Articles/ETA Estimation Q-Commerce: Janji Tiba dalam X Menit

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 AffanFaisal Affan
6/20/2026

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


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
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:

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

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.

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.

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:

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.

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.

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.

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

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.

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.

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


Key Takeaways

ETA is Composable

Jangan treat ETA sebagai satu angka. Pecah jadi picking + queue + travel + buffer. Setiap komponen punya model dan ketidakpastian sendiri.

p80 is The Only Number That Matters

ETA tunggal adalah dusta. Selalu laporkan distribusi. p80 = 80% order akan sampai dalam waktu ini. Pilih p80 atau p95 tergantung SLA.

Cache Aggressively, Invalidate Selectively

Travel time untuk rute yang sama di jam yang sama tidak berubah drastis. Cache 5-15 menit. Tapi invalidasi segera jika ada insiden traffic.

Circuit Breaker on Routing APIs

Google Maps / OSRM bisa down atau rate-limit. Jangan biarkan ETA calculation gagal total. ML fallback + haversine heuristic sebagai last resort.

Monitor Miss Rate, Not Just ETA

ETA akurat tidak berarti sistem bagus. Track miss rate (promised vs actual). Jika >10%, kalibrasi ulang model. Jangan tunggu komplain pelanggan.

Cold Start Strategy

Hub baru tidak punya data. Gunakan parameter default konservatif. Tandai sebagai cold start di monitoring. Kumpulkan 100 order untuk initial fit.

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.


Related Engineering & Tech Articles

ETA Estimation Q-Commerce: Janji Tiba dalam X Menit | Faisal Affan