updates on the order bulk fix

This commit is contained in:
2026-10-06 17:29:07 +05:30
parent efad4d3d12
commit 7ff9dad288
12 changed files with 1029 additions and 48 deletions

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
@@ -16,6 +17,7 @@ import (
"doormile/utils"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// ─── Request / response types ────────────────────────────────────────────────
@@ -68,18 +70,28 @@ type aiDecisionResponse struct {
// audit. Falls back to the original distance/load/rating formula when the AI
// layer is unreachable or times out. Returns (nil, nil, false) when there are no
// eligible candidates or when the AI layer escalates the booking.
func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation) (*milerCandidate, *uint64, bool) {
candidates, aiCandidates := collectEligibleCandidates(nearby)
if len(candidates) == 0 {
return nil, nil, false
func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation) (*milerCandidate, []*milerCandidate, *uint64, bool) {
// Never offer an order back to a rider it was released from, who
// rejected it or who cancelled it.
nearby = withoutRiders(nearby, ridersToSkip(booking.Bookingid))
pool, poolAI := collectEligibleCandidates(nearby)
if len(pool) == 0 {
return nil, nil, nil, false
}
// Balance: only the riders holding the fewest orders are considered. The
// AI (or the fallback) chooses among them, so the split stays even however
// many orders and riders there are. The full pool goes back to the caller
// for the final re-check at commit time (finalizeChoice).
candidates, aiCandidates := leastLoaded(pool, poolAI)
decision, err := callDecisionEngine(booking, aiCandidates)
if err != nil {
utils.Warn("AI_LAYER_FALLBACK: decide-assignment unreachable, using legacy scoring",
"booking_id", booking.Bookingid, "error", err)
best := pickBestFromCandidates(candidates)
return best, nil, best != nil
return best, pool, nil, best != nil
}
utils.Info("Assignment: AI layer responded",
@@ -95,7 +107,7 @@ func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation
"booking_id", booking.Bookingid,
"reasoning", decision.Reasoning,
)
return nil, nil, false
return nil, nil, nil, false
}
var decisionID *uint64
@@ -106,17 +118,42 @@ func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation
for _, c := range candidates {
if c.profile.Userid == decision.ChosenMilerID {
return c, decisionID, true
return c, pool, decisionID, true
}
}
// AI returned an ID that is not in our eligibility set — fall back safely.
// AI returned an ID that is not among the least-loaded riders — fall back.
utils.Warn("AI_LAYER_FALLBACK: chosen miler not in eligible set, using legacy scoring",
"booking_id", booking.Bookingid,
"chosen_miler_id", decision.ChosenMilerID,
)
best := pickBestFromCandidates(candidates)
return best, nil, best != nil
return best, pool, nil, best != nil
}
// leastLoaded keeps the candidates holding the fewest orders in hand (and
// the matching AI rows, which are parallel to them; ai may be nil).
func leastLoaded(cands []*milerCandidate, ai []aiCandidate) ([]*milerCandidate, []aiCandidate) {
if len(cands) == 0 {
return cands, ai
}
least := cands[0].activeBookings
for _, c := range cands[1:] {
if c.activeBookings < least {
least = c.activeBookings
}
}
var outC []*milerCandidate
var outAI []aiCandidate
for i, c := range cands {
if c.activeBookings == least {
outC = append(outC, c)
if i < len(ai) {
outAI = append(outAI, ai[i])
}
}
}
return outC, outAI
}
// ─── Candidate collection ────────────────────────────────────────────────────
@@ -171,6 +208,7 @@ func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, [
profile: profile,
distanceKm: loc.Dist,
activeBookings: activeCount,
sessionStops: sessionStops(db.DB, milerUserID),
})
aiCandidates = append(aiCandidates, aiCandidate{
MilerID: milerUserID,
@@ -195,8 +233,14 @@ func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, [
// pile of old or cancelled orders sat at the cap for good and every new order
// stayed Pending.
func openStopsToday(milerUserID int) int64 {
return openStopsTodayIn(db.DB, milerUserID)
}
// openStopsTodayIn is openStopsToday on a given handle, so the commit can
// recount inside its own transaction.
func openStopsTodayIn(h *gorm.DB, milerUserID int) int64 {
var n int64
db.DB.Table("bookingassignments AS ba").
h.Table("bookingassignments AS ba").
Joins("JOIN pickupbookings pb ON pb.bookingid = ba.bookingid").
Where("ba.mileruserid = ? AND ba.assignmentstatus IN ? AND ba.assignedat >= ? AND pb.status <> ?",
milerUserID,
@@ -221,6 +265,66 @@ func milerHasFreshGPS(milerUserID int) bool {
return n > 0
}
// sessionStops counts the orders given to the rider since their current duty
// session started (the latest milerdutylogs row with no logout), excluding
// ones that fell through (cancelled, rejected, reassigned). 0 when the rider
// has no open session.
func sessionStops(h *gorm.DB, milerUserID int) int64 {
var n int64
h.Table("bookingassignments").
Where("mileruserid = ? AND assignmentstatus NOT IN ? AND assignedat >= "+
"(SELECT MAX(loginat) FROM milerdutylogs WHERE userid = ? AND logoutat IS NULL)",
milerUserID,
[]string{constants.AssignmentCancelled, constants.AssignmentRejected, constants.AssignmentReassigned},
milerUserID).
Count(&n)
return n
}
// errNoRiderCapacity: by the time the commit ran, every candidate had reached
// the per-rider ceiling. The attempt is retried later like "no rider found".
var errNoRiderCapacity = errors.New("every candidate rider is at the order limit")
// decisionLockKey serialises the final rider choice across attempts and
// replicas (a transaction-scoped Postgres advisory lock). Held only for a
// handful of quick counts and the commit, never across the AI call.
const decisionLockKey int64 = 7_270_001
// finalizeChoice re-checks the choice inside the commit transaction. Attempts
// for different orders run in parallel — a bulk upload of 50 starts 50 — and
// each picked "the least-loaded rider" from counts read before the others
// committed, so they would all pile onto the same one. Under the lock the
// loads are recounted: the chosen rider is kept if still among the least
// loaded and under the ceiling, otherwise the best of the least loaded is
// taken instead (same order as betterChoice).
func finalizeChoice(tx *gorm.DB, chosen *milerCandidate, pool []*milerCandidate) (*milerCandidate, error) {
if len(pool) == 0 {
pool = []*milerCandidate{chosen}
}
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", decisionLockKey).Error; err != nil {
return nil, fmt.Errorf("decision lock: %w", err)
}
ceiling := maxActiveBookings()
var open []*milerCandidate
for _, c := range pool {
c.activeBookings = openStopsTodayIn(tx, c.profile.Userid)
c.sessionStops = sessionStops(tx, c.profile.Userid)
if c.activeBookings < ceiling {
open = append(open, c)
}
}
if len(open) == 0 {
return nil, errNoRiderCapacity
}
least, _ := leastLoaded(open, nil)
for _, c := range least {
if c.profile.Userid == chosen.profile.Userid {
return c, nil // the original choice still holds
}
}
return pickBestFromCandidates(least), nil
}
// startOfISTDay is midnight today in India, the boundary for "today's"
// open stops. An absolute instant, so it compares correctly with assignedat
// whether the column stores IST wall-clock or a real timestamp.
@@ -296,19 +400,31 @@ func fetchHubData(hubID *int) (resolvedHubID int, hubLoad int64, hubCapacity int
// eligible slice: score = distance_km*1.0 + active_bookings*2.0 - rating*0.5
func pickBestFromCandidates(candidates []*milerCandidate) *milerCandidate {
var best *milerCandidate
bestScore := 1e18
for _, c := range candidates {
score := c.distanceKm*1.0 + float64(c.activeBookings)*2.0 - c.profile.Rating*0.5
if score < bestScore {
bestScore = score
if best == nil || betterChoice(c, best) {
best = c
}
}
return best
}
// betterChoice is the balancing order: fewest orders in hand, then fewest
// orders this duty session, then nearest to the pickup, then best rated.
// It replaced a weighted score in which distance dominated, so riders near
// the pickups took most of the orders.
func betterChoice(a, b *milerCandidate) bool {
if a.activeBookings != b.activeBookings {
return a.activeBookings < b.activeBookings
}
if a.sessionStops != b.sessionStops {
return a.sessionStops < b.sessionStops
}
if a.distanceKm != b.distanceKm {
return a.distanceKm < b.distanceKm
}
return a.profile.Rating > b.profile.Rating
}
// ─── AI layer HTTP call ──────────────────────────────────────────────────────
func callDecisionEngine(booking *models.PickupBooking, candidates []aiCandidate) (aiDecisionResponse, error) {