mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-26 08:02:19 +00:00
562 lines
19 KiB
Go
562 lines
19 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"math"
|
|
"math/rand"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/behavior"
|
|
"github.com/warmbly/warmbly/internal/bitmask"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/mailhost"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// findNextValidDay finds the next day the Monday-first days mask allows.
|
|
func findNextValidDay(from time.Time, daysBitmask uint8, tz *time.Location) time.Time {
|
|
if daysBitmask == 0 {
|
|
// If no days specified, allow all days
|
|
return from
|
|
}
|
|
|
|
candidate := from.In(tz)
|
|
|
|
// Try up to 7 days
|
|
for i := 0; i < 7; i++ {
|
|
if bitmask.HasWeekday(daysBitmask, candidate.Weekday()) {
|
|
return candidate
|
|
}
|
|
candidate = candidate.Add(24 * time.Hour)
|
|
}
|
|
|
|
// If no valid day found in 7 days, just return the input
|
|
return from
|
|
}
|
|
|
|
// ensureTimeWindow ensures time is within the allowed window (start_time to end_time)
|
|
func ensureTimeWindow(t time.Time, startTime, endTime string, tz *time.Location) time.Time {
|
|
start := parseTimeOfDay(startTime) // Minutes since midnight
|
|
end := parseTimeOfDay(endTime)
|
|
|
|
if start == 0 && end == 0 {
|
|
// No time window specified, allow any time
|
|
return t
|
|
}
|
|
|
|
tLocal := t.In(tz)
|
|
minutesOfDay := tLocal.Hour()*60 + tLocal.Minute()
|
|
|
|
if minutesOfDay < start {
|
|
// Too early, move to start time today
|
|
return time.Date(tLocal.Year(), tLocal.Month(), tLocal.Day(),
|
|
start/60, start%60, 0, 0, tz)
|
|
}
|
|
|
|
if minutesOfDay > end {
|
|
// Too late, move to tomorrow's start time
|
|
next := tLocal.Add(24 * time.Hour)
|
|
return time.Date(next.Year(), next.Month(), next.Day(),
|
|
start/60, start%60, 0, 0, tz)
|
|
}
|
|
|
|
return t
|
|
}
|
|
|
|
// effectiveWindows returns the campaign's authoritative per-day sending
|
|
// schedule. When ScheduleWindows is set it is used as-is; otherwise it is
|
|
// DERIVED from the legacy days bitmask + start/end time (one interval per active
|
|
// day) so campaigns created before the multi-window feature still schedule
|
|
// correctly. The stored days bitmask is Monday-indexed (bit 0 = Monday, as the
|
|
// bitmask package and dashboard write it), so it is mapped to time.Weekday
|
|
// (Sun=0) via (bit+1)%7 — which also corrects the historical off-by-one in the
|
|
// legacy day check.
|
|
func effectiveWindows(c *models.Campaign) models.ScheduleWindows {
|
|
if !c.ScheduleWindows.IsEmpty() {
|
|
return c.ScheduleWindows
|
|
}
|
|
start := parseTimeOfDay(c.StartTime)
|
|
end := parseTimeOfDay(c.EndTime)
|
|
if end <= start {
|
|
// No usable legacy window → unconstrained (any time allowed).
|
|
return models.ScheduleWindows{}
|
|
}
|
|
var sw models.ScheduleWindows
|
|
for bit := 0; bit < 7; bit++ {
|
|
if c.Days == 0 || c.Days&(1<<uint(bit)) != 0 {
|
|
wd := (bit + 1) % 7 // Monday-indexed bit → time.Weekday
|
|
sw[wd] = []models.TimeInterval{{Start: start, End: end}}
|
|
}
|
|
}
|
|
return sw
|
|
}
|
|
|
|
// nextScheduleSlot returns the earliest time >= from that falls inside one of
|
|
// the campaign's per-day sending windows, searching up to 8 days ahead. An
|
|
// empty schedule means "unconstrained" and returns from unchanged. When from is
|
|
// already inside a window its exact instant is preserved (so jitter/min-wait
|
|
// adjustments survive); otherwise it advances to the next interval's start.
|
|
func nextScheduleSlot(from time.Time, sw models.ScheduleWindows, tz *time.Location) time.Time {
|
|
if sw.IsEmpty() {
|
|
return from
|
|
}
|
|
cur := from.In(tz)
|
|
for i := 0; i < 8; i++ {
|
|
y, m, d := cur.Date()
|
|
nowMin := cur.Hour()*60 + cur.Minute()
|
|
|
|
ivs := append([]models.TimeInterval(nil), sw[int(cur.Weekday())]...)
|
|
sort.Slice(ivs, func(a, b int) bool { return ivs[a].Start < ivs[b].Start })
|
|
|
|
for _, iv := range ivs {
|
|
if nowMin < iv.Start {
|
|
return time.Date(y, m, d, iv.Start/60, iv.Start%60, 0, 0, tz)
|
|
}
|
|
if nowMin < iv.End {
|
|
if i == 0 {
|
|
return from // already inside a window — keep the exact instant
|
|
}
|
|
return cur
|
|
}
|
|
}
|
|
// No interval left today — jump to the start of the next day.
|
|
cur = time.Date(y, m, d, 0, 0, 0, 0, tz).Add(24 * time.Hour)
|
|
}
|
|
return from
|
|
}
|
|
|
|
// ensureBusinessHours ensures time is within business hours (8am-8pm)
|
|
func ensureBusinessHours(t time.Time, timezone string) time.Time {
|
|
loc := loadLocation(timezone)
|
|
return ensureTimeWindow(t, "08:00", "20:00", loc)
|
|
}
|
|
|
|
// businessHoursReopen is when the 8am-8pm band next opens for an instant
|
|
// that sits outside it: 8am the same local day before the band, 8am the next
|
|
// local day after it.
|
|
func businessHoursReopen(t time.Time, loc *time.Location) time.Time {
|
|
local := t.In(loc)
|
|
open := time.Date(local.Year(), local.Month(), local.Day(), 8, 0, 0, 0, loc)
|
|
if local.Hour() >= 20 {
|
|
open = open.AddDate(0, 0, 1)
|
|
}
|
|
return open
|
|
}
|
|
|
|
// calculateHoursRemainingUntil calculates hours remaining until a specific end time
|
|
func calculateHoursRemainingUntil(timezone, endTime string) float64 {
|
|
loc := loadLocation(timezone)
|
|
now := time.Now().In(loc)
|
|
endMinutes := parseTimeOfDay(endTime)
|
|
if endMinutes == 0 {
|
|
endMinutes = 20 * 60 // fallback to 8pm
|
|
}
|
|
endOfDay := time.Date(now.Year(), now.Month(), now.Day(), endMinutes/60, endMinutes%60, 0, 0, loc)
|
|
if now.After(endOfDay) {
|
|
return 0
|
|
}
|
|
return max(0, endOfDay.Sub(now).Hours())
|
|
}
|
|
|
|
// calculateFirstSlotTomorrowAt calculates first slot tomorrow at a specific start time
|
|
func calculateFirstSlotTomorrowAt(timezone, startTime string) time.Time {
|
|
loc := loadLocation(timezone)
|
|
now := time.Now().In(loc)
|
|
startMinutes := parseTimeOfDay(startTime)
|
|
if startMinutes == 0 {
|
|
startMinutes = 8 * 60 // fallback to 8am
|
|
}
|
|
tomorrow := now.Add(24 * time.Hour)
|
|
firstSlot := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(),
|
|
startMinutes/60, startMinutes%60, 0, 0, loc)
|
|
jitter := randomJitter(0, 60)
|
|
return finalSlot(firstSlot.Add(time.Minute * time.Duration(jitter)))
|
|
}
|
|
|
|
// humanizeSeconds moves a slot sitting on second :00 (every time.Date build and
|
|
// minute-granular jitter) 1-59s later, so the fleet never sends at second zero.
|
|
// A slot already off :00 keeps its exact time and the pacing that produced it.
|
|
func humanizeSeconds(t time.Time) time.Time {
|
|
if t.Second() != 0 {
|
|
return t
|
|
}
|
|
return t.Add(time.Duration(1+rand.Intn(59)) * time.Second)
|
|
}
|
|
|
|
// poolSendInterval is a campaign chain's gap to its next send: the window left
|
|
// over the pool's budget left, varied 0.55-1.45x by draw so sends come in bursts
|
|
// and lulls. The floor is the pool's spacing (one mailbox gap over the pool
|
|
// size), never the chosen mailbox's, whose own gap STEP 10 enforces.
|
|
func poolSendInterval(remainingMinutes, remainingEmails, gapSeconds, poolSize int, draw float64) time.Duration {
|
|
perSend := float64(remainingMinutes) / float64(max(1, remainingEmails))
|
|
interval := time.Duration(perSend * (0.55 + draw*0.9) * float64(time.Minute))
|
|
floor := time.Second * time.Duration(gapSeconds) / time.Duration(max(1, poolSize))
|
|
return max(interval, floor)
|
|
}
|
|
|
|
// finalSlot is the last thing a scheduler does to a candidate: take it off
|
|
// second :00, then clamp a slot the jitter left in the past to the near future.
|
|
func finalSlot(t time.Time) time.Time {
|
|
return notBefore(humanizeSeconds(t))
|
|
}
|
|
|
|
// avoidRoundTimes adds randomness to avoid exact round times (10:00, 11:00)
|
|
func avoidRoundTimes(t time.Time) time.Time {
|
|
if t.Minute() == 0 {
|
|
// Move to random minute between 3-12
|
|
offset := randomJitter(3, 12)
|
|
return t.Add(time.Minute * time.Duration(offset))
|
|
}
|
|
return t
|
|
}
|
|
|
|
// applyDistributionCurve applies human-like distribution patterns
|
|
// Favors morning (9-11am) and afternoon (2-4pm) peaks
|
|
func applyDistributionCurve(t time.Time, tz *time.Location) time.Time {
|
|
hour := t.In(tz).Hour()
|
|
|
|
// Avoid lunch hour (12-1pm) - 30% chance to push to 1:15pm
|
|
if hour == 12 {
|
|
if rand.Float64() < 0.3 {
|
|
minutes := 75 + randomJitter(0, 30)
|
|
return t.Add(time.Minute * time.Duration(minutes))
|
|
}
|
|
}
|
|
|
|
// Slightly avoid very early (before 9am) and very late (after 6pm)
|
|
// Add small random delays to push toward peak hours
|
|
if hour < 9 {
|
|
// Small chance to push to 9am
|
|
if rand.Float64() < 0.2 {
|
|
target := time.Date(t.Year(), t.Month(), t.Day(), 9, 0, 0, 0, tz)
|
|
if target.After(t) {
|
|
offset := randomJitter(0, 30)
|
|
return target.Add(time.Minute * time.Duration(offset))
|
|
}
|
|
}
|
|
}
|
|
|
|
return t
|
|
}
|
|
|
|
// resolveConflicts resolves scheduling conflicts with existing tasks
|
|
// Ensures minimum spacing between emails from the same account
|
|
func resolveConflicts(desired time.Time, scheduled []repository.Task, minWait int) time.Time {
|
|
if len(scheduled) == 0 {
|
|
return desired
|
|
}
|
|
|
|
// Sort tasks by scheduled time
|
|
sort.Slice(scheduled, func(i, j int) bool {
|
|
if scheduled[i].ScheduledAt == nil || scheduled[j].ScheduledAt == nil {
|
|
return false
|
|
}
|
|
return scheduled[i].ScheduledAt.Before(*scheduled[j].ScheduledAt)
|
|
})
|
|
|
|
candidate := desired
|
|
maxAttempts := 100
|
|
|
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
|
hasConflict := false
|
|
|
|
for _, task := range scheduled {
|
|
if task.ScheduledAt == nil {
|
|
continue
|
|
}
|
|
|
|
diff := math.Abs(candidate.Sub(*task.ScheduledAt).Seconds())
|
|
|
|
if diff < float64(minWait) {
|
|
// Conflict! Move candidate after this task
|
|
hasConflict = true
|
|
candidate = task.ScheduledAt.Add(time.Second * time.Duration(minWait))
|
|
|
|
// Add small random jitter to avoid creating a new conflict
|
|
jitterMinutes := randomJitter(1, 5)
|
|
candidate = candidate.Add(time.Minute * time.Duration(jitterMinutes))
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasConflict {
|
|
return candidate
|
|
}
|
|
}
|
|
|
|
// If still conflicts after 100 attempts, push to next hour
|
|
return candidate.Add(time.Hour)
|
|
}
|
|
|
|
// AccountCandidate holds an email account with its computed scheduling weight
|
|
type AccountCandidate struct {
|
|
Account models.Email
|
|
RemainingToday int
|
|
WarmupAgeDays int
|
|
Weight float64
|
|
|
|
// Per-sender rotation metadata (explicit sender strategy only). SenderWeight
|
|
// multiplies the base Weight in weighted mode; RotationPosition drives
|
|
// round_robin; SenderLastSentAt drives least_recently_used. Defaults
|
|
// (weight 1, nil last-sent, position 0) make tag-strategy candidates behave
|
|
// exactly as before.
|
|
SenderWeight int
|
|
RotationPosition int
|
|
SenderLastSentAt *time.Time
|
|
HasSenderMetadata bool
|
|
|
|
// ProviderMatch reflects whether this mailbox's provider matches the
|
|
// recipient ESP under ESP matching. Always true when ESP matching is off or
|
|
// the recipient provider is unknown.
|
|
ProviderMatch bool
|
|
|
|
// Behavior is the mailbox's resolved sending-behaviour profile for this
|
|
// pass. Zero value (Enabled false) means the mailbox has not opted in.
|
|
Behavior behavior.Resolved
|
|
// OpenAt is the earliest instant the mailbox's own calendar allows: its
|
|
// rolled workday and hourly ceiling under a behaviour profile, otherwise
|
|
// the reopening of its 8am-8pm band, and it is only set for a mailbox the
|
|
// pass decided to wait for rather than drop. OpenLoc is the timezone that
|
|
// instant's DAY is counted in. Both nil when the mailbox can send now.
|
|
OpenAt *time.Time
|
|
OpenLoc *time.Location
|
|
}
|
|
|
|
// remainingSendMinutes returns how much sending time is left in the day the
|
|
// candidate is about to send on, which is what the even-distribution step
|
|
// paces the day's remaining emails across.
|
|
//
|
|
// With a behaviour profile the answer comes from the mailbox's own rolled
|
|
// workday in its own timezone, with any part of the lunch break still ahead
|
|
// subtracted — pacing across a window that includes a break the mailbox will
|
|
// not send in would bunch the remainder into the afternoon. Without one it is
|
|
// the campaign's window span for that weekday, exactly as before.
|
|
func remainingSendMinutes(c *AccountCandidate, at time.Time, sw models.ScheduleWindows, campaignTZ *time.Location) (int, bool) {
|
|
if c.Behavior.Enabled {
|
|
loc := c.Behavior.Loc
|
|
plan := c.Behavior.PlanOn(behavior.PlanDateFor(at, loc))
|
|
if !plan.IsWorkingDay {
|
|
return 0, false
|
|
}
|
|
cur := max(behavior.MinuteOfDay(at, loc), plan.WorkStartMinute)
|
|
if cur >= plan.WorkEndMinute {
|
|
return 0, false
|
|
}
|
|
remaining := plan.WorkEndMinute - cur
|
|
if plan.HasLunch() {
|
|
breakStart, breakEnd := *plan.LunchStartMinute, *plan.LunchEndMinute
|
|
if breakEnd > cur {
|
|
remaining -= breakEnd - max(breakStart, cur)
|
|
}
|
|
}
|
|
return max(remaining, 0), true
|
|
}
|
|
|
|
wd := int(at.In(campaignTZ).Weekday())
|
|
dayStart, dayEnd, ok := sw.DaySpan(wd)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
nowLocal := time.Now().In(campaignTZ)
|
|
currentMinutes := nowLocal.Hour()*60 + nowLocal.Minute()
|
|
return dayEnd - max(currentMinutes, dayStart), true
|
|
}
|
|
|
|
// poolRemainingOn is how many cold sends the campaign's eligible mailboxes still
|
|
// have between them on the day `at` falls on — the denominator the even-
|
|
// distribution step paces the day across.
|
|
//
|
|
// Only mailboxes actually landing on that day are counted. A mailbox whose
|
|
// today is spent (or whose hours have closed) has already been walked to a
|
|
// later day, and counting tomorrow's allowance as if it were available now
|
|
// would pace the campaign faster than the mailboxes that can send today can
|
|
// keep up with. A mailbox that opened BEFORE `at` is available whatever day it
|
|
// opened on, which is what keeps the count right when the whole pass has been
|
|
// pushed to tomorrow because every mailbox was at capacity today.
|
|
func poolRemainingOn(pool []AccountCandidate, at time.Time) int {
|
|
total := 0
|
|
for i := range pool {
|
|
c := &pool[i]
|
|
if c.RemainingToday <= 0 {
|
|
continue
|
|
}
|
|
if c.OpenAt != nil && c.OpenLoc != nil &&
|
|
c.OpenAt.After(at) && !sameLocalDay(*c.OpenAt, at, c.OpenLoc) {
|
|
continue
|
|
}
|
|
total += c.RemainingToday
|
|
}
|
|
return total
|
|
}
|
|
|
|
// campaignRampCeiling returns the day's effective ramp ceiling. When ramp is
|
|
// disabled it returns the campaign ceiling unchanged (the caller still min()s
|
|
// against the per-mailbox cap). When enabled it returns the already-advanced
|
|
// level clamped into [start, ceiling]. The scheduler applies this ONLY via
|
|
// min() against the per-mailbox cold cap, so it can only LOWER volume.
|
|
func campaignRampCeiling(enabled bool, start, increment, ceiling, level int) int {
|
|
_ = increment // the increment is applied by AdvanceRampLevel; level is already advanced
|
|
if !enabled {
|
|
return ceiling
|
|
}
|
|
v := level
|
|
if v < start {
|
|
v = start
|
|
}
|
|
if v > ceiling {
|
|
v = ceiling
|
|
}
|
|
return v
|
|
}
|
|
|
|
// senderESP is a mailbox's family for ESP matching: its email_provider, or
|
|
// for an smtp_imap mailbox on Google or Microsoft (an app-password import)
|
|
// that host's family.
|
|
func senderESP(acct models.Email) string {
|
|
if acct.Provider == "smtp_imap" {
|
|
if esp := mailhost.ESPFamily(mailhost.Host(acct.MailHost)); esp == "gmail" || esp == "outlook" {
|
|
return esp
|
|
}
|
|
}
|
|
return acct.Provider
|
|
}
|
|
|
|
// recipientESP is the provider family ESP matching compares against a
|
|
// mailbox's email_provider: "gmail", "outlook", or "" for anything else,
|
|
// which matches every mailbox. A contact the provider sweep has not reached
|
|
// yet is read from its domain alone.
|
|
func recipientESP(c *models.Contact) string {
|
|
esp := c.ESPProvider
|
|
if c.MailHost == "" && esp == "" {
|
|
esp = mailhost.ESPFamily(mailhost.KnownDomain(c.Email))
|
|
}
|
|
if esp == "gmail" || esp == "outlook" {
|
|
return esp
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// computeWeight calculates a scheduling weight for an account based on remaining capacity and warmup age.
|
|
// Accounts with more remaining capacity and older warmup age get higher weight.
|
|
func computeWeight(remaining int, warmupAgeDays int) float64 {
|
|
if remaining <= 0 {
|
|
return 0
|
|
}
|
|
warmupFactor := 1.0 + math.Log2(float64(warmupAgeDays+1))
|
|
return float64(remaining) * warmupFactor
|
|
}
|
|
|
|
// selectAccountWeighted picks an account using weighted random selection.
|
|
// Returns nil if all candidates have zero weight. The per-sender weight (1..100,
|
|
// default 1 for tag-strategy candidates) multiplies the base scheduling weight,
|
|
// so an operator can bias rotation toward specific mailboxes without ever
|
|
// raising any mailbox above its already-clamped per-mailbox cap.
|
|
func selectAccountWeighted(candidates []AccountCandidate) *AccountCandidate {
|
|
var totalWeight float64
|
|
var viable []AccountCandidate
|
|
for _, c := range candidates {
|
|
w := effectiveWeight(c)
|
|
if w > 0 {
|
|
totalWeight += w
|
|
c.Weight = w
|
|
viable = append(viable, c)
|
|
}
|
|
}
|
|
|
|
if len(viable) == 0 {
|
|
return nil
|
|
}
|
|
|
|
r := rand.Float64() * totalWeight
|
|
var cumulative float64
|
|
for i := range viable {
|
|
cumulative += viable[i].Weight
|
|
if r <= cumulative {
|
|
return &viable[i]
|
|
}
|
|
}
|
|
|
|
// Fallback to last viable candidate
|
|
return &viable[len(viable)-1]
|
|
}
|
|
|
|
// effectiveWeight folds the per-sender weight into the base scheduling weight.
|
|
// Tag-strategy candidates carry SenderWeight 0 (no metadata) and so use the
|
|
// base weight unchanged.
|
|
func effectiveWeight(c AccountCandidate) float64 {
|
|
if c.HasSenderMetadata && c.SenderWeight > 0 {
|
|
return c.Weight * float64(c.SenderWeight)
|
|
}
|
|
return c.Weight
|
|
}
|
|
|
|
// selectAccountLeastRecentlyUsed picks the viable candidate (Weight>0) whose
|
|
// sender last_sent_at is oldest; a nil last_sent_at (never used) sorts first.
|
|
// Ties broken by account id for determinism.
|
|
func selectAccountLeastRecentlyUsed(candidates []AccountCandidate) *AccountCandidate {
|
|
var best *AccountCandidate
|
|
for i := range candidates {
|
|
if candidates[i].Weight <= 0 {
|
|
continue
|
|
}
|
|
if best == nil || lruLess(&candidates[i], best) {
|
|
best = &candidates[i]
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func lruLess(a, b *AccountCandidate) bool {
|
|
switch {
|
|
case a.SenderLastSentAt == nil && b.SenderLastSentAt == nil:
|
|
return a.Account.ID.String() < b.Account.ID.String()
|
|
case a.SenderLastSentAt == nil:
|
|
return true
|
|
case b.SenderLastSentAt == nil:
|
|
return false
|
|
case a.SenderLastSentAt.Equal(*b.SenderLastSentAt):
|
|
return a.Account.ID.String() < b.Account.ID.String()
|
|
default:
|
|
return a.SenderLastSentAt.Before(*b.SenderLastSentAt)
|
|
}
|
|
}
|
|
|
|
// selectAccountRoundRobin picks the viable candidate (Weight>0) with the lowest
|
|
// rotation_position; ties broken by account id for determinism.
|
|
func selectAccountRoundRobin(candidates []AccountCandidate) *AccountCandidate {
|
|
var best *AccountCandidate
|
|
for i := range candidates {
|
|
if candidates[i].Weight <= 0 {
|
|
continue
|
|
}
|
|
if best == nil ||
|
|
candidates[i].RotationPosition < best.RotationPosition ||
|
|
(candidates[i].RotationPosition == best.RotationPosition &&
|
|
candidates[i].Account.ID.String() < best.Account.ID.String()) {
|
|
best = &candidates[i]
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// needsRotationFallback reports whether a rotation mode depends on per-sender
|
|
// bookkeeping that only explicitly-picked senders carry. Weighted selection is
|
|
// driven by remaining capacity, so it already spreads without a cursor.
|
|
func needsRotationFallback(rotationMode string) bool {
|
|
return rotationMode == "round_robin" || rotationMode == "least_recently_used"
|
|
}
|
|
|
|
// selectAccountByRotationMode dispatches to the chosen rotation strategy. For
|
|
// explicit-strategy campaigns rotationMode is one of weighted / round_robin /
|
|
// least_recently_used; anything else falls back to weighted.
|
|
func selectAccountByRotationMode(rotationMode string, candidates []AccountCandidate) *AccountCandidate {
|
|
switch rotationMode {
|
|
case "round_robin":
|
|
return selectAccountRoundRobin(candidates)
|
|
case "least_recently_used":
|
|
return selectAccountLeastRecentlyUsed(candidates)
|
|
default:
|
|
return selectAccountWeighted(candidates)
|
|
}
|
|
}
|