mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
187 lines
6.7 KiB
Go
187 lines
6.7 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"math/rand"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/behavior"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// Sending-behaviour placement. These helpers are the only place the schedulers
|
|
// touch the behaviour engine, and every one of them is a no-op when the engine
|
|
// is unwired or the mailbox has its profile switched off — a mailbox that has
|
|
// not opted in schedules exactly as it did before this feature existed.
|
|
//
|
|
// The gates compose in one direction only: behaviour can DELAY a send or LOWER
|
|
// a budget, never bring one forward or raise one. That keeps the mailbox-first
|
|
// safety invariant intact, so a human-behaviour profile can never be used to
|
|
// push a mailbox past its cold cap.
|
|
|
|
// placementAttempts bounds the hour-by-hour walk in placeWithinBehavior. A
|
|
// working day is at most 24 hours of buckets and NextOpen already skips whole
|
|
// non-working days, so this only ever runs out on a pathological profile.
|
|
const placementAttempts = 48
|
|
|
|
func (s *schedulerService) behaviorFor(ctx context.Context, account *models.Email) behavior.Resolved {
|
|
if s.behaviorSvc == nil || account == nil {
|
|
return behavior.Resolved{}
|
|
}
|
|
return s.behaviorSvc.Resolve(ctx, account)
|
|
}
|
|
|
|
func (s *schedulerService) behaviorForAll(ctx context.Context, accounts []models.Email) map[uuid.UUID]behavior.Resolved {
|
|
if s.behaviorSvc == nil {
|
|
return map[uuid.UUID]behavior.Resolved{}
|
|
}
|
|
return s.behaviorSvc.ResolveMany(ctx, accounts)
|
|
}
|
|
|
|
// behaviorWindow snaps a time into the mailbox's rolled workday: past the
|
|
// start of the day, out of the lunch break, and onto the next working weekday
|
|
// when the day is over. It applies no volume gate, so it is what warmup uses —
|
|
// warmup has its own ramp and should follow the persona's hours without
|
|
// competing for the cold-send budget.
|
|
//
|
|
// Returns the input unchanged when behaviour is off, and (zero, false) only
|
|
// when the profile has no working days at all.
|
|
func behaviorWindow(r behavior.Resolved, desired time.Time) (time.Time, bool) {
|
|
if !r.Enabled {
|
|
return desired, true
|
|
}
|
|
at, _, ok := r.NextOpen(desired)
|
|
if !ok {
|
|
return time.Time{}, false
|
|
}
|
|
return at, true
|
|
}
|
|
|
|
// placeWithinBehavior is behaviorWindow plus the volume gates: it walks forward
|
|
// until it finds an instant that is inside the workday AND still has room in
|
|
// both the day's rolled cold budget and that clock hour's ceiling.
|
|
//
|
|
// The hourly ceiling is what stops a day's whole allowance landing in one
|
|
// burst; without it a mailbox with a 40/day plan and a 90s floor could empty
|
|
// the day before 11am and then sit silent, which is a more obvious pattern
|
|
// than sending too much.
|
|
func (s *schedulerService) placeWithinBehavior(ctx context.Context, r behavior.Resolved, desired time.Time) (time.Time, bool) {
|
|
if !r.Enabled || s.behaviorSvc == nil {
|
|
return desired, true
|
|
}
|
|
|
|
at := desired
|
|
for i := 0; i < placementAttempts; i++ {
|
|
open, _, ok := r.NextOpen(at)
|
|
if !ok {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
if s.behaviorSvc.RemainingToday(ctx, r, open) <= 0 {
|
|
// This local day is spent. Restart the search at the first instant
|
|
// of the next one rather than the next hour, so a full day costs
|
|
// one iteration instead of twenty-four.
|
|
at = behavior.PlanDateFor(open, r.Loc).AddDate(0, 0, 1)
|
|
continue
|
|
}
|
|
|
|
if s.behaviorSvc.RemainingThisHour(ctx, r, open) > 0 {
|
|
return open, true
|
|
}
|
|
|
|
_, hourEnd := behavior.HourWindow(open, r.Loc)
|
|
at = hourEnd
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// behaviorGap returns the spacing to leave after this mailbox's previous send,
|
|
// in seconds. With behaviour enabled the gap is drawn fresh from the profile's
|
|
// range for every send, so consecutive intervals differ instead of forming an
|
|
// arithmetic sequence; the configured min_wait_time is the fallback for every
|
|
// mailbox that has not opted in.
|
|
func (s *schedulerService) behaviorGap(r behavior.Resolved, at time.Time, fallbackSeconds int) int {
|
|
if !r.Enabled {
|
|
return fallbackSeconds
|
|
}
|
|
plan := r.PlanOn(behavior.PlanDateFor(at, r.Loc))
|
|
gap := behavior.DrawGap(plan, rand.Float64)
|
|
secs := int(gap / time.Second)
|
|
if secs < 1 {
|
|
return fallbackSeconds
|
|
}
|
|
return secs
|
|
}
|
|
|
|
// notBefore keeps a scheduled slot in the future.
|
|
//
|
|
// Every scheduler adds SYMMETRIC jitter (±20 minutes for campaigns) to a
|
|
// candidate that may already sit only seconds from now, so the negative half
|
|
// can push the slot into the past. A past scheduled_at fires the moment it is
|
|
// enqueued, which skips the spacing the send was placed with, and one more than
|
|
// 15 minutes stale is cancelled outright by the overdue sweep.
|
|
//
|
|
// The floor is now plus a few seconds of spread, so a batch of clamped tasks
|
|
// does not all land on the same instant.
|
|
func notBefore(candidate time.Time) time.Time {
|
|
now := time.Now()
|
|
if candidate.After(now) {
|
|
return candidate
|
|
}
|
|
return now.Add(time.Duration(5+rand.Intn(55)) * time.Second)
|
|
}
|
|
|
|
// sameLocalDay reports whether two instants fall on the same calendar day in a
|
|
// mailbox's own timezone. Budgets are per LOCAL day, so this — not a UTC date
|
|
// comparison — is what decides whether a send is spending today's allowance.
|
|
func sameLocalDay(a, b time.Time, loc *time.Location) bool {
|
|
if loc == nil {
|
|
loc = time.UTC
|
|
}
|
|
ay, am, ad := a.In(loc).Date()
|
|
by, bm, bd := b.In(loc).Date()
|
|
return ay == by && am == bm && ad == bd
|
|
}
|
|
|
|
// intersectWindows walks a candidate time forward until it satisfies BOTH the
|
|
// campaign's sending windows (in the campaign timezone) and the mailbox's
|
|
// rolled workday (in the mailbox timezone), which are two different calendars
|
|
// whenever a campaign sends from mailboxes in other regions.
|
|
//
|
|
// Each pass only ever moves the candidate later, so the walk is monotonic and
|
|
// terminates; the bound is there for the case where the two calendars never
|
|
// overlap at all, in which case the campaign window wins and the behaviour
|
|
// engine simply does not delay the send further.
|
|
func (s *schedulerService) intersectWindows(
|
|
ctx context.Context,
|
|
r behavior.Resolved,
|
|
t time.Time,
|
|
sw models.ScheduleWindows,
|
|
tz *time.Location,
|
|
) time.Time {
|
|
if !r.Enabled || s.behaviorSvc == nil {
|
|
return nextScheduleSlot(t, sw, tz)
|
|
}
|
|
for i := 0; i < 8; i++ {
|
|
inCampaign := nextScheduleSlot(t, sw, tz)
|
|
placed, ok := s.placeWithinBehavior(ctx, r, inCampaign)
|
|
if !ok || !placed.After(inCampaign) {
|
|
return inCampaign
|
|
}
|
|
t = placed
|
|
}
|
|
return t
|
|
}
|
|
|
|
// behaviorDailyCap folds the day's rolled cold budget into an existing
|
|
// remaining-capacity number. min() only: the plan can lower a mailbox's
|
|
// remaining sends for the day, never raise them above its cold cap.
|
|
func (s *schedulerService) behaviorDailyCap(ctx context.Context, r behavior.Resolved, remaining int, at time.Time) int {
|
|
if !r.Enabled || s.behaviorSvc == nil {
|
|
return remaining
|
|
}
|
|
return min(remaining, s.behaviorSvc.RemainingToday(ctx, r, at))
|
|
}
|