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.
- ETA Estimation Q-Commerce: Janji Tiba dalam X Menit
- Masalah: Mengapa ETA Q-Commerce Sulit Diprediksi?
- Arsitektur ETA Estimation
- System Components
- 1. ETA Calculator Service: Kompositor Utama
- 2. Picking Time Model
- 3. Travel Time Fetcher: OSRM + ML + Circuit Breaker
- 4. ML Prediction Client: gRPC ke ML Model Service
- 5. Queue Wait Estimator
- 6. ETA Monitor: Track Miss Rate
- Edge Cases dan Penanganannya
- 1. Traffic Spike (Hujan, Kecelakaan, Event)
- 2. Hub Overload
- 3. Driver Tidak Ada yang Available
- 4. ETA yang Berubah-ubah
- 5. Cold Start (Belum Ada Data Historis)
- ETA Decomposition Flowchart
- Key Takeaways
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:
- Multi-phase: Bukan hanya travel time. Ada picking time di hub, waiting time di antrian dispatch, dan travel time dari hub ke pelanggan.
- Dynamic variables: Jumlah item, beban hub (berapa order concurrent), jumlah driver available, traffic, cuaca, hari libur — semuanya berubah setiap detik.
- Non-linear scaling: 10 item tidak berarti 2x lebih lama dari 5 item. Ada fixed overhead untuk scanning, packing, dan handoff.
- Spatial variance: ETA ke pelanggan di pusat kota vs pinggiran berbeda drastis karena traffic dan jarak hub.
- 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.