updates on the order bulk fix
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user