Files
doormile_backend/internal/assignment/balance_pg_test.go

267 lines
7.9 KiB
Go

package assignment
import (
"errors"
"fmt"
"sync"
"testing"
"time"
"doormile/constants"
"doormile/models"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// Balanced auto-assignment (docs/balanced-assignment-plan.md, Phase A)
// against a real Postgres. Skipped unless REGISTRY_TEST_DSN is set — see
// eligibility_pg_test.go.
// onDuty adds a rider with live GPS and an open duty session.
func onDuty(t *testing.T, gdb *gorm.DB, id int, sessionStart time.Time) {
t.Helper()
rider(t, gdb, id, constants.MilerAvailable, time.Minute)
mustDo(t, gdb.Create(&models.MilerDutyLog{Userid: id, Loginat: sessionStart}).Error)
}
// assignOne runs one attempt the way tryAssign does (minus the Redis GEO
// lookup, which `nearby` stands in for) and returns the rider it went to.
func assignOne(t *testing.T, gdb *gorm.DB, bookingID int, nearby []redis.GeoLocation) (int, error) {
t.Helper()
var b models.PickupBooking
if err := gdb.First(&b, bookingID).Error; err != nil {
return 0, err
}
cand, pool, dec, found := selectMilerWithAI(&b, nearby)
if !found {
return 0, nil
}
final, err := commitAssignment(&b, cand, pool, dec)
if err != nil {
return 0, err
}
return final.profile.Userid, nil
}
// near puts every rider at a different distance from the pickup, so a
// distance-first rule would pile everything onto rider ids[0].
func near(ids ...int) []redis.GeoLocation {
out := make([]redis.GeoLocation, len(ids))
for i, id := range ids {
out[i] = redis.GeoLocation{Name: fmt.Sprint(id), Dist: 0.5 + float64(i)*1.5}
}
return out
}
func perRider(t *testing.T, gdb *gorm.DB) map[int]int64 {
t.Helper()
type row struct {
Mileruserid int
N int64
}
var rows []row
mustDo(t, gdb.Table("bookingassignments").Select("mileruserid, COUNT(*) AS n").
Where("assignmentstatus IN ?", []string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Group("mileruserid").Scan(&rows).Error)
out := map[int]int64{}
for _, r := range rows {
out[r.Mileruserid] = r.N
}
return out
}
func spread(m map[int]int64, ids ...int) (lo, hi int64) {
lo, hi = 1<<62, -1
for _, id := range ids {
n := m[id]
if n < lo {
lo = n
}
if n > hi {
hi = n
}
}
return lo, hi
}
func balanceSetup(t *testing.T) *gorm.DB {
gdb := eligibilityDB(t)
t.Setenv("AI_LAYER_BASE_URL", "http://127.0.0.1:1") // unreachable: the fallback chooses
t.Setenv("MILER_MAX_ACTIVE_BOOKINGS", "20")
return gdb
}
// 5 riders, 50 orders one after another: 10 each, although rider 1 is the
// nearest to every pickup.
func TestBalancedSplitSequential(t *testing.T) {
gdb := balanceSetup(t)
ids := []int{1, 2, 3, 4, 5}
for _, id := range ids {
onDuty(t, gdb, id, time.Now().Add(-time.Hour))
}
for i := 0; i < 50; i++ {
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
if _, err := assignOne(t, gdb, b, near(ids...)); err != nil {
t.Fatal(err)
}
}
got := perRider(t, gdb)
for _, id := range ids {
if got[id] != 10 {
t.Fatalf("per rider = %v, want 10 each", got)
}
}
}
// Any count splits within one: 23 orders over 5 riders -> 5,5,5,4,4.
func TestBalancedSplitUneven(t *testing.T) {
gdb := balanceSetup(t)
ids := []int{1, 2, 3, 4, 5}
for _, id := range ids {
onDuty(t, gdb, id, time.Now().Add(-time.Hour))
}
for i := 0; i < 23; i++ {
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
if _, err := assignOne(t, gdb, b, near(ids...)); err != nil {
t.Fatal(err)
}
}
if lo, hi := spread(perRider(t, gdb), ids...); lo != 4 || hi != 5 {
t.Fatalf("spread %d..%d, want 4..5", lo, hi)
}
}
// A bulk upload: 50 orders attempted at the same moment. Without the final
// re-check under the lock they all saw the same counts and piled onto one
// rider. Still balanced, nobody over the ceiling, one rider per order.
func TestBalancedSplitConcurrentBurst(t *testing.T) {
gdb := balanceSetup(t)
ids := []int{1, 2, 3, 4, 5}
for _, id := range ids {
onDuty(t, gdb, id, time.Now().Add(-time.Hour))
}
bookings := make([]int, 50)
for i := range bookings {
bookings[i] = order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
}
var wg sync.WaitGroup
errs := make(chan error, len(bookings))
for _, b := range bookings {
wg.Add(1)
go func(b int) {
defer wg.Done()
if _, err := assignOne(t, gdb, b, near(ids...)); err != nil {
errs <- err
}
}(b)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
got := perRider(t, gdb)
if lo, hi := spread(got, ids...); hi-lo > 1 {
t.Fatalf("burst split %v: spread %d..%d, want within 1", got, lo, hi)
}
var total int64
for _, n := range got {
total += n
}
var perOrder int64
gdb.Raw("SELECT COALESCE(MAX(n),0) FROM (SELECT COUNT(*) n FROM bookingassignments GROUP BY bookingid) x").Scan(&perOrder)
if total != 50 || perOrder != 1 {
t.Fatalf("assigned %d (want 50), max riders on one order %d (want 1)", total, perOrder)
}
}
// Riders come and go: 3 riders hold 4 each, 2 log in fresh. The next 8 orders
// go to the newcomers (4 each), then everyone shares.
func TestNewRidersCatchUpThenShare(t *testing.T) {
gdb := balanceSetup(t)
busy := []int{1, 2, 3}
fresh := []int{4, 5}
for _, id := range busy {
onDuty(t, gdb, id, time.Now().Add(-3*time.Hour))
for i := 0; i < 4; i++ {
order(t, gdb, id, constants.BookingMilerAssigned, constants.AssignmentAccepted, time.Now().Add(-time.Hour))
}
}
for _, id := range fresh {
onDuty(t, gdb, id, time.Now())
}
all := append(append([]int{}, busy...), fresh...)
for i := 0; i < 8; i++ {
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
id, err := assignOne(t, gdb, b, near(all...))
if err != nil {
t.Fatal(err)
}
if id != 4 && id != 5 {
t.Fatalf("order %d went to busy rider %d while a newcomer had fewer", i, id)
}
}
if lo, hi := spread(perRider(t, gdb), all...); lo != 4 || hi != 4 {
t.Fatalf("after catch-up spread %d..%d, want all 4", lo, hi)
}
// From here on everyone shares: 5 more orders -> one each.
for i := 0; i < 5; i++ {
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
if _, err := assignOne(t, gdb, b, near(all...)); err != nil {
t.Fatal(err)
}
}
if lo, hi := spread(perRider(t, gdb), all...); lo != 5 || hi != 5 {
t.Fatalf("after sharing spread %d..%d, want all 5", lo, hi)
}
}
// Tie on orders in hand: fewer orders this duty session wins, then distance.
func TestTieBreakSessionThenDistance(t *testing.T) {
gdb := balanceSetup(t)
// Rider 1: nearest, but has had 2 orders this session (both now closed).
onDuty(t, gdb, 1, time.Now().Add(-2*time.Hour))
for i := 0; i < 2; i++ {
order(t, gdb, 1, constants.BookingConvertedConsignment, constants.AssignmentCompleted, time.Now().Add(-time.Hour))
}
// Riders 2 and 3: no orders this session; 2 is nearer than 3.
onDuty(t, gdb, 2, time.Now().Add(-2*time.Hour))
onDuty(t, gdb, 3, time.Now().Add(-2*time.Hour))
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
id, err := assignOne(t, gdb, b, near(1, 2, 3))
if err != nil {
t.Fatal(err)
}
if id != 2 {
t.Fatalf("went to %d, want 2 (fewest this session, then nearest)", id)
}
}
// Everyone at the ceiling: the order waits (no assignment, no error that
// would stop the retries).
func TestAllRidersAtCeilingWaits(t *testing.T) {
gdb := balanceSetup(t)
t.Setenv("MILER_MAX_ACTIVE_BOOKINGS", "2")
for _, id := range []int{1, 2} {
onDuty(t, gdb, id, time.Now().Add(-time.Hour))
for i := 0; i < 2; i++ {
order(t, gdb, id, constants.BookingMilerAssigned, constants.AssignmentAccepted, time.Now())
}
}
b := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
id, err := assignOne(t, gdb, b, near(1, 2))
if err != nil && !errors.Is(err, errNoRiderCapacity) {
t.Fatal(err)
}
if id != 0 {
t.Fatalf("assigned to %d although everyone is at the ceiling", id)
}
var bk models.PickupBooking
mustDo(t, gdb.First(&bk, b).Error)
if bk.Status != constants.BookingPendingPickup || bk.Assignedmileruserid != nil {
t.Fatalf("booking should still be pending: %+v", bk.Status)
}
}