mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 08:01:23 +00:00
New method on WorkerAssignmentService picks the least-loaded shared
worker whose risk_pool matches the mailbox's risk band. Three-step
fallback chain:
1. Exact match: same pool, same tier
2. Fall back to clean pool of the same tier when no matching-pool
worker is available (better to land risky mailboxes on clean
workers than refuse; the rebalancer will move them later)
3. Last resort: any worker of the right tier (preserves legacy
behavior for installations that haven't provisioned risky/
quarantine pools)
Existing SelectSharedWorker is unchanged so call sites that don't
know about risk bands keep working. The next commit (background
rebalancer) is the first consumer of the new method.
426 lines
13 KiB
Go
426 lines
13 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
var (
|
|
ErrNoAvailableWorkers = errors.New("no available workers")
|
|
ErrNoDedicatedWorkers = errors.New("no dedicated workers available")
|
|
ErrOrgAlreadyAssigned = errors.New("organization already has a dedicated worker assigned")
|
|
)
|
|
|
|
type WorkerAssignmentService interface {
|
|
// AssignWorkerToEmail assigns an appropriate worker to an email account
|
|
// based on the organization's subscription status (free tier vs paid)
|
|
AssignWorkerToEmail(ctx context.Context, emailAccountID, orgID uuid.UUID) (*uuid.UUID, error)
|
|
|
|
// SelectSharedWorker selects the least loaded shared worker for the given tier
|
|
SelectSharedWorker(ctx context.Context, freeTier bool) (*models.Worker, error)
|
|
|
|
// SelectSharedWorkerForBand selects the least-loaded shared worker whose
|
|
// risk_pool matches the mailbox's risk band. Falls back to the clean
|
|
// pool if no worker exists in the target pool, and to any worker of the
|
|
// tier as a last resort.
|
|
SelectSharedWorkerForBand(ctx context.Context, freeTier bool, band models.EmailRiskBand) (*models.Worker, error)
|
|
|
|
// Dedicated worker management
|
|
AssignDedicatedWorker(ctx context.Context, orgID, subscriptionID uuid.UUID) error
|
|
ReleaseDedicatedWorker(ctx context.Context, orgID uuid.UUID) error
|
|
GetDedicatedWorker(ctx context.Context, orgID uuid.UUID) (*models.Worker, error)
|
|
|
|
// Migration operations
|
|
MigrateOrgToPremiumWorkers(ctx context.Context, orgID uuid.UUID) error
|
|
MigrateOrgToFreeWorkers(ctx context.Context, orgID uuid.UUID) error
|
|
MigrateOrgToDedicated(ctx context.Context, orgID uuid.UUID, subscriptionID uuid.UUID) error
|
|
MigrateOrgToShared(ctx context.Context, orgID uuid.UUID) error
|
|
MigrateEmailsFromWorker(ctx context.Context, workerID uuid.UUID, targetFreeTier bool) error
|
|
}
|
|
|
|
type workerAssignmentService struct {
|
|
workerRepo repository.WorkerRepository
|
|
subRepo repository.SubscriptionRepository
|
|
planRepo repository.PlanRepository
|
|
}
|
|
|
|
func NewAssignmentService(
|
|
workerRepo repository.WorkerRepository,
|
|
subRepo repository.SubscriptionRepository,
|
|
planRepo repository.PlanRepository,
|
|
) WorkerAssignmentService {
|
|
return &workerAssignmentService{
|
|
workerRepo: workerRepo,
|
|
subRepo: subRepo,
|
|
planRepo: planRepo,
|
|
}
|
|
}
|
|
|
|
// AssignWorkerToEmail assigns an appropriate worker to an email account
|
|
func (s *workerAssignmentService) AssignWorkerToEmail(ctx context.Context, emailAccountID, orgID uuid.UUID) (*uuid.UUID, error) {
|
|
// 1. Check organization's subscription status
|
|
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 2. Determine if free tier or paid
|
|
isPaidOrg := sub != nil && sub.HasPaidSubscription()
|
|
|
|
// 3. Check if paid org has dedicated worker plan
|
|
if isPaidOrg {
|
|
plan, err := s.planRepo.GetByID(ctx, sub.PlanID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if plan != nil && plan.DedicatedWorkers > 0 {
|
|
// Check if org has a dedicated worker
|
|
dedicatedWorker, err := s.workerRepo.GetDedicatedWorkerByUserID(ctx, orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if dedicatedWorker != nil {
|
|
// Assign to dedicated worker
|
|
if err := s.workerRepo.UpdateEmailAccountWorker(ctx, emailAccountID, dedicatedWorker.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.workerRepo.IncrementAccountCount(ctx, dedicatedWorker.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return &dedicatedWorker.ID, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Assign to shared worker (strict tier separation)
|
|
freeTier := !isPaidOrg // Free trial = free workers, Paid = premium workers
|
|
worker, err := s.SelectSharedWorker(ctx, freeTier)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 5. Update database
|
|
if err := s.workerRepo.UpdateEmailAccountWorker(ctx, emailAccountID, worker.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 6. Update worker account count
|
|
if err := s.workerRepo.IncrementAccountCount(ctx, worker.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 7. Update warmup pool type
|
|
poolType := "free"
|
|
if !freeTier {
|
|
poolType = "premium"
|
|
}
|
|
if err := s.workerRepo.UpdateEmailAccountWarmupPoolType(ctx, emailAccountID, poolType); err != nil {
|
|
// Log but don't fail
|
|
}
|
|
|
|
return &worker.ID, nil
|
|
}
|
|
|
|
// SelectSharedWorker selects the least loaded shared worker for the given tier
|
|
func (s *workerAssignmentService) SelectSharedWorker(ctx context.Context, freeTier bool) (*models.Worker, error) {
|
|
// Get all workers for the tier, sorted by account_count ASC
|
|
workers, err := s.workerRepo.GetSharedWorkersByTier(ctx, freeTier)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(workers) == 0 {
|
|
return nil, ErrNoAvailableWorkers
|
|
}
|
|
|
|
// Return the least loaded worker (first one since sorted ASC)
|
|
return &workers[0], nil
|
|
}
|
|
|
|
// SelectSharedWorkerForBand picks the least-loaded shared worker whose
|
|
// risk_pool matches band.MatchingRiskPool(). Fallback chain:
|
|
//
|
|
// 1. Worker in the matching pool of the right tier
|
|
// 2. Worker in the clean pool of the right tier (better to land risky
|
|
// mailboxes on clean workers than to refuse, but log + audit this
|
|
// since it dilutes the clean pool — operator should provision a
|
|
// risky/quarantine worker)
|
|
// 3. Any worker of the right tier (existing SelectSharedWorker behavior)
|
|
//
|
|
// Step 3 maintains backwards compatibility with installations that don't
|
|
// run risk pools yet — they leave everything in risk_pool='clean' and
|
|
// behavior is unchanged.
|
|
func (s *workerAssignmentService) SelectSharedWorkerForBand(ctx context.Context, freeTier bool, band models.EmailRiskBand) (*models.Worker, error) {
|
|
target := band.MatchingRiskPool()
|
|
|
|
workers, err := s.workerRepo.GetSharedWorkersByTierAndPool(ctx, freeTier, target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(workers) > 0 {
|
|
return &workers[0], nil
|
|
}
|
|
|
|
// Step 2: fall back to the clean pool. Only kicks in for risky/quarantine
|
|
// bands when no matching-pool worker exists.
|
|
if target != models.WorkerRiskPoolClean {
|
|
workers, err = s.workerRepo.GetSharedWorkersByTierAndPool(ctx, freeTier, models.WorkerRiskPoolClean)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(workers) > 0 {
|
|
return &workers[0], nil
|
|
}
|
|
}
|
|
|
|
// Step 3: last-resort, any tier worker. Same as legacy SelectSharedWorker.
|
|
return s.SelectSharedWorker(ctx, freeTier)
|
|
}
|
|
|
|
// AssignDedicatedWorker assigns a dedicated worker to an organization
|
|
func (s *workerAssignmentService) AssignDedicatedWorker(ctx context.Context, orgID, subscriptionID uuid.UUID) error {
|
|
// Use atomic insert with conflict check to prevent race conditions.
|
|
// Two concurrent requests could both pass the "check if exists" step and
|
|
// both attempt to insert, causing duplicate assignments.
|
|
worker, err := s.workerRepo.GetAvailableDedicatedWorker(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if worker == nil {
|
|
return ErrNoDedicatedWorkers
|
|
}
|
|
|
|
assignment := &models.DedicatedWorkerAssignment{
|
|
ID: uuid.New(),
|
|
WorkerID: worker.ID,
|
|
UserID: orgID,
|
|
SubscriptionID: subscriptionID,
|
|
AssignedAt: time.Now(),
|
|
}
|
|
|
|
created, err := s.workerRepo.CreateDedicatedAssignmentIfNotExists(ctx, assignment)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !created {
|
|
return ErrOrgAlreadyAssigned
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReleaseDedicatedWorker releases a dedicated worker assignment
|
|
func (s *workerAssignmentService) ReleaseDedicatedWorker(ctx context.Context, orgID uuid.UUID) error {
|
|
return s.workerRepo.ReleaseDedicatedAssignment(ctx, orgID)
|
|
}
|
|
|
|
// GetDedicatedWorker gets the dedicated worker for an organization
|
|
func (s *workerAssignmentService) GetDedicatedWorker(ctx context.Context, orgID uuid.UUID) (*models.Worker, error) {
|
|
return s.workerRepo.GetDedicatedWorkerByUserID(ctx, orgID)
|
|
}
|
|
|
|
// MigrateOrgToPremiumWorkers migrates all org's emails from free to premium workers
|
|
// Called when a trial org subscribes to a paid plan
|
|
func (s *workerAssignmentService) MigrateOrgToPremiumWorkers(ctx context.Context, orgID uuid.UUID) error {
|
|
accountIDs, err := s.workerRepo.GetEmailAccountsByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, accountID := range accountIDs {
|
|
info, err := s.workerRepo.GetEmailAccountWorkerInfo(ctx, accountID)
|
|
if err != nil || info == nil {
|
|
continue
|
|
}
|
|
|
|
// Skip if no worker assigned or already on premium
|
|
if info.WorkerID == nil || (info.FreeTier != nil && !*info.FreeTier) {
|
|
continue
|
|
}
|
|
|
|
// Select new premium worker
|
|
newWorker, err := s.SelectSharedWorker(ctx, false)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Migrate
|
|
if err := s.migrateEmailToWorker(ctx, accountID, *info.WorkerID, newWorker.ID); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Update warmup pool type
|
|
s.workerRepo.UpdateEmailAccountWarmupPoolType(ctx, accountID, "premium")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MigrateOrgToFreeWorkers migrates all org's emails to free tier workers
|
|
// Called when a paid subscription is cancelled/expired
|
|
func (s *workerAssignmentService) MigrateOrgToFreeWorkers(ctx context.Context, orgID uuid.UUID) error {
|
|
accountIDs, err := s.workerRepo.GetEmailAccountsByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, accountID := range accountIDs {
|
|
info, err := s.workerRepo.GetEmailAccountWorkerInfo(ctx, accountID)
|
|
if err != nil || info == nil {
|
|
continue
|
|
}
|
|
|
|
// Skip if no worker assigned or already on free tier
|
|
if info.WorkerID == nil || (info.FreeTier != nil && *info.FreeTier) {
|
|
continue
|
|
}
|
|
|
|
// Select new free tier worker
|
|
newWorker, err := s.SelectSharedWorker(ctx, true)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Migrate
|
|
if err := s.migrateEmailToWorker(ctx, accountID, *info.WorkerID, newWorker.ID); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Update warmup pool type
|
|
s.workerRepo.UpdateEmailAccountWarmupPoolType(ctx, accountID, "free")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MigrateOrgToDedicated migrates org's emails to their dedicated worker
|
|
func (s *workerAssignmentService) MigrateOrgToDedicated(ctx context.Context, orgID uuid.UUID, subscriptionID uuid.UUID) error {
|
|
// First, assign a dedicated worker to the org
|
|
if err := s.AssignDedicatedWorker(ctx, orgID, subscriptionID); err != nil {
|
|
if !errors.Is(err, ErrOrgAlreadyAssigned) {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Get the dedicated worker
|
|
dedicatedWorker, err := s.workerRepo.GetDedicatedWorkerByUserID(ctx, orgID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if dedicatedWorker == nil {
|
|
return ErrNoDedicatedWorkers
|
|
}
|
|
|
|
accountIDs, err := s.workerRepo.GetEmailAccountsByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, accountID := range accountIDs {
|
|
info, err := s.workerRepo.GetEmailAccountWorkerInfo(ctx, accountID)
|
|
if err != nil || info == nil {
|
|
continue
|
|
}
|
|
|
|
// Skip if no worker assigned or already on dedicated worker
|
|
if info.WorkerID == nil || *info.WorkerID == dedicatedWorker.ID {
|
|
continue
|
|
}
|
|
|
|
// Migrate to dedicated worker
|
|
if err := s.migrateEmailToWorker(ctx, accountID, *info.WorkerID, dedicatedWorker.ID); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Update warmup pool type
|
|
s.workerRepo.UpdateEmailAccountWarmupPoolType(ctx, accountID, "premium")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MigrateOrgToShared migrates org's emails from dedicated to shared workers
|
|
func (s *workerAssignmentService) MigrateOrgToShared(ctx context.Context, orgID uuid.UUID) error {
|
|
accountIDs, err := s.workerRepo.GetEmailAccountsByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, accountID := range accountIDs {
|
|
info, err := s.workerRepo.GetEmailAccountWorkerInfo(ctx, accountID)
|
|
if err != nil || info == nil {
|
|
continue
|
|
}
|
|
|
|
if info.WorkerID == nil {
|
|
continue
|
|
}
|
|
|
|
// Select new shared premium worker
|
|
newWorker, err := s.SelectSharedWorker(ctx, false)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Migrate
|
|
if err := s.migrateEmailToWorker(ctx, accountID, *info.WorkerID, newWorker.ID); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Release the dedicated worker
|
|
if err := s.ReleaseDedicatedWorker(ctx, orgID); err != nil {
|
|
// Log but don't fail
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MigrateEmailsFromWorker migrates all emails from a worker to other workers
|
|
func (s *workerAssignmentService) MigrateEmailsFromWorker(ctx context.Context, workerID uuid.UUID, targetFreeTier bool) error {
|
|
// Get all email accounts on this worker
|
|
accountIDs, err := s.workerRepo.GetEmailAccountsByWorkerID(ctx, workerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, accountID := range accountIDs {
|
|
// Select new worker
|
|
newWorker, err := s.SelectSharedWorker(ctx, targetFreeTier)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Migrate
|
|
if err := s.migrateEmailToWorker(ctx, accountID, workerID, newWorker.ID); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrateEmailToWorker handles the actual migration of an email account
|
|
func (s *workerAssignmentService) migrateEmailToWorker(ctx context.Context, emailAccountID, oldWorkerID, newWorkerID uuid.UUID) error {
|
|
// 1. Update database
|
|
if err := s.workerRepo.UpdateEmailAccountWorker(ctx, emailAccountID, newWorkerID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 2. Decrement old worker count
|
|
if err := s.workerRepo.DecrementAccountCount(ctx, oldWorkerID); err != nil {
|
|
// Log but don't fail
|
|
}
|
|
|
|
// 3. Increment new worker count
|
|
if err := s.workerRepo.IncrementAccountCount(ctx, newWorkerID); err != nil {
|
|
// Log but don't fail
|
|
}
|
|
|
|
return nil
|
|
}
|