8.5 KiB
Balanced auto-assignment: implementation plan
Status: planned, not started (2026-10-06). Goal: however many orders and riders there are, split the orders equally among the riders, with riders logging in and out all day.
0. Where things stand (context for whoever picks this up)
Already built (2026-10-06)
| Fix | Where |
|---|---|
Rider search falls back to GEORADIUS on Redis < 6.2; /ready shows redis_geo |
internal/milergeo/ (committed 9b94be1) |
| Only today's, non-cancelled open orders count towards the per-rider limit | internal/assignment/ai_layer.go → openStopsToday |
| Only riders with live GPS (default 15 min) are offered orders | ai_layer.go → milerHasFreshGPS, setting ASSIGNMENT_MAX_GPS_AGE_MINUTES |
| Console cancel (single and bulk) frees the rider (closes the assignment) | controllers/adminController.go → closeOpenAssignments |
| Pending-order sweeper: retries every unassigned pending order every 5 min (last 72 h) | internal/assignment/sweeper.go, started in main.go |
| No double assignment: the order is claimed only if it still has no rider and isn't cancelled | crm_assignment.go → claimBooking (used by both commit paths) |
Tests: internal/assignment/eligibility_pg_test.go, sweeper_test.go, controllers/cancel_frees_rider_pg_test.go (real-Postgres tests skip unless REGISTRY_TEST_DSN is set).
How a rider is chosen today (what this plan changes)
- Find riders within 10 km of the pickup (Redis GEO), whose status allows work, with live GPS, holding fewer than
MILER_MAX_ACTIVE_BOOKINGS(default 3) of today's open orders. - Ask the AI service (
routemate …/decide-assignment). That endpoint currently returns 404, so the backend falls back to:score = distance_km + 2 × open_orders_today − 0.5 × rating (lowest wins)
The problem: distance dominates, so riders near the pickups get more orders and the split is not equal. MILER_MAX_ACTIVE_BOOKINGS is only a ceiling and can't make it equal. A code change is required.
Relevant facts found in the code
- A rider can't end duty while holding Assigned/Accepted orders (
MilerEndDuty). - If a rider's app dies, their orders stay with them: nothing releases an order a rider never accepted. (
InternalReassignexists inadminController.go, but nothing calls it automatically.) - Nothing reacts when a rider starts duty: they only get orders from the next retry or sweep.
- Each duty session is recorded in
milerdutylogs(loginat,logoutat). - The rider app sends GPS about every 30 s in the background (
PUT /miler/location).
1. The balancing rule
Riders come and go, so "same number of orders today" isn't fair: a rider logging in at 3 PM would get every new order until they catch up.
| Rule | With riders joining and leaving |
|---|---|
| Equal orders today | ❌ late joiners get flooded |
| Equal orders in hand right now ✅ | fair at every moment; a new rider gets a fair share of new orders; a rider who leaves just drops out |
Rule: each new order goes to the eligible rider holding the fewest unfinished orders right now. Ties are broken by:
- fewest orders this duty session (since
milerdutylogs.loginat); - nearest to the pickup;
- highest rating.
Balancing happens among riders near each pickup (10 km), so each area balances its own riders.
| Situation | Expected |
|---|---|
| 5 riders, 50 orders | 10 each, at most 1 apart (if the ceiling allows) |
| 23 orders, 5 riders | 5, 5, 5, 4, 4 |
| 3 riders hold 4 each; 2 riders log in | the next 8 orders go to the 2 new riders, then everyone shares |
| A rider goes offline (no GPS) | gets nothing new; the others share |
"In hand" = assignments Assigned/Accepted on orders that aren't Cancelled, Delivered or otherwise finished. Note: for hyperlocal parcels the assignment stays open until delivery, which is correct, because the rider is still carrying it.
2. Code changes (doormile_backend)
Phase A: equal split (core) · ~½ day
| File / function | Change |
|---|---|
internal/assignment/ai_layer.go → collectEligibleCandidates |
For each eligible rider, compute in-hand count (replaces the today-only count for ranking; the today-only count can stay as the ceiling check) and session count (assignments since the latest open milerdutylogs.loginat). Store both on milerCandidate / aiCandidate. |
ai_layer.go → selectMilerWithAI |
Before calling the AI, keep only candidates with the minimum in-hand count. The AI or fallback then chooses among them, so balance holds even when the AI endpoint returns. |
ai_layer.go → pickBestFromCandidates |
Replace the weighted formula with ordering by in-hand ↑, session count ↑, distance ↑, rating ↓. |
internal/assignment/crm_assignment.go → commitAssignment, customer_assignment.go → commitCustomerAssignment |
Bulk safety: 50 orders arriving at once run in parallel and would all pick the same "least-loaded" rider. Inside the commit transaction, lock the rider's milerprofiles row (SELECT … FOR UPDATE), recount their in-hand orders, and refuse (try the next candidate) if they're at the ceiling or no longer the least loaded. Keep claimBooking as is. |
Phase B: riders joining and leaving · ~½ day
| File / function | Change |
|---|---|
controllers/milerAppController.go → MilerStartDuty |
Rider comes online: after duty starts (and the GPS is indexed), trigger an immediate sweep of pending orders near them (new helper in sweeper.go, e.g. SweepNear(lat, lon)). New riders get orders within seconds, not up to 5 min. |
internal/assignment/sweeper.go |
Rider stops responding: release orders that are still Assigned (never Accepted) after ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES (new, e.g. 10): close that assignment as Reassigned, clear pickupbookings.assignedmileruserid, set the status back to Pending_Pickup, and let the sweeper reassign. Never release an Accepted order. Reuse the logic of InternalReassign where possible. |
Phase C: settings (server, no code)
| Setting | Recommended | Purpose |
|---|---|---|
MILER_MAX_ACTIVE_BOOKINGS |
20 on the server (code default stays 3) | safety ceiling only; balancing decides the split |
ASSIGNMENT_MAX_GPS_AGE_MINUTES |
15 (exists) | only riders whose app is running |
ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES |
10 (new, Phase B) | release orders never accepted |
ASSIGNMENT_SWEEP_SECONDS |
300 (exists) | pending-order retry interval |
ASSIGNMENT_SWEEP_MAX_AGE_HOURS |
72 (exists) | older pending orders are left alone |
3. Tests (real Postgres, same pattern as eligibility_pg_test.go)
- 5 riders, 50 orders assigned one by one → 10 each, max − min ≤ 1.
- 50 orders created concurrently → still balanced, no rider over the ceiling, every order exactly 1 rider.
- 3 busy riders + 2 who just started duty → new orders go to the newcomers until level.
- A rider with stale GPS gets nothing new.
- An order still Assigned after the timeout is released and goes to the least-loaded rider; an Accepted order is never released.
- Riders outside 10 km are never used.
- A tie on in-hand count is broken by session count, then distance, then rating.
- Starting duty triggers assignment of nearby pending orders (Phase B).
Plus a unit test for the new ranking (pickBestFromCandidates) and the new setting parser.
4. Rollout
- Deploy Phase A with
MILER_MAX_ACTIVE_BOOKINGS=20. For one day, compare orders per rider (should be within 1 of each other among riders in the same area). - Deploy Phase B.
- If riders are sent too far, add a cap such as "prefer balance, but not more than X km further than the nearest eligible rider" (
ASSIGNMENT_BALANCE_MAX_EXTRA_KM).
5. Unchanged
The 10 km radius, the live-GPS rule, cancel freeing the rider, the pending-order sweeper, claimBooking, manual assignment (AssignMilerToBooking), hub batch assign (HubBatchAssign, its own cap of 5), the express dispatch agent, and the customer-app flow.
6. Open points (decide before or during the work)
- Distance vs balance: do you want the extra-km cap from rollout step 3 from day one?
- The AI endpoint (
/api/v1/doormile/decide-assignmentonroutemate.workolik.com) is missing. Once restored, it will choose only among the least-loaded riders (Phase A guarantees this). - Hub batch assign has its own greedy logic and cap (5). Should it use the same balancing later?