package pool import ( "errors" "math" "sync" "time" "proxy-pool/internal/domain/upstream" ) var ErrInvalidReconcilePolicy = errors.New("invalid pool reconcile policy") type ReconcilePolicy struct { MinimumAvailableSlots int64 TargetAvailableSlots int64 ExpectedPerFetch int ExpectedSlotsPerFetch int64 SafetyMargin time.Duration } type FetchNotifier interface { Notify() } type ReconcileDecision struct { AvailableSlots int64 PendingExpected int FetchedTotal int64 FetchAllowance int EffectiveSlots int64 Triggered bool } type Reconciler struct { policy ReconcilePolicy budget *FetchBudget notifier FetchNotifier mu sync.Mutex refilling bool slotsPerProxy int64 } func NewReconciler(policy ReconcilePolicy, budget *FetchBudget, notifier FetchNotifier) (*Reconciler, error) { if policy.MinimumAvailableSlots <= 0 || policy.TargetAvailableSlots <= policy.MinimumAvailableSlots || policy.ExpectedPerFetch <= 0 || policy.ExpectedSlotsPerFetch <= 0 || policy.SafetyMargin < 0 || budget == nil || notifier == nil { return nil, ErrInvalidReconcilePolicy } if budget.expected != policy.ExpectedPerFetch { return nil, ErrInvalidReconcilePolicy } if policy.ExpectedSlotsPerFetch%int64(policy.ExpectedPerFetch) != 0 { return nil, ErrInvalidReconcilePolicy } return &Reconciler{ policy: policy, budget: budget, notifier: notifier, slotsPerProxy: policy.ExpectedSlotsPerFetch / int64(policy.ExpectedPerFetch), }, nil } // Reconcile centralizes the cold-path decision. The notifier may coalesce many // calls; Provider Reconciler atomically reserves the budget before doing I/O. func (r *Reconciler) Reconcile(now time.Time, inventory upstream.Inventory) ReconcileDecision { return r.reconcileSnapshot( InventorySnapshot{AvailableSlots: inventory.AvailableSlots(now, r.policy.SafetyMargin)}, false, ) } func (r *Reconciler) ReconcileSnapshot(inventory InventorySnapshot) ReconcileDecision { return r.reconcileSnapshot(inventory, true) } func (r *Reconciler) reconcileSnapshot(inventory InventorySnapshot, synchronizeManaged bool) ReconcileDecision { usage := r.budget.Snapshot() if synchronizeManaged && inventory.Managed >= 0 && usage.PendingExpected == 0 { _ = r.budget.SynchronizeManaged(inventory.Managed) usage = r.budget.Snapshot() } pendingSlots := saturatingMultiply(int64(usage.PendingExpected), r.slotsPerProxy) decision := ReconcileDecision{ AvailableSlots: inventory.AvailableSlots, PendingExpected: usage.PendingExpected, FetchedTotal: usage.FetchedTotal, FetchAllowance: r.budget.FetchAllowance(), EffectiveSlots: saturatingAdd(inventory.AvailableSlots, pendingSlots), } r.mu.Lock() if r.refilling { if usage.PendingExpected == 0 && decision.AvailableSlots >= r.policy.TargetAvailableSlots { r.refilling = false } } else if decision.AvailableSlots < r.policy.MinimumAvailableSlots { r.refilling = true } trigger := r.refilling && decision.EffectiveSlots < r.policy.TargetAvailableSlots && decision.FetchAllowance >= r.policy.ExpectedPerFetch r.mu.Unlock() if !trigger { return decision } r.notifier.Notify() decision.Triggered = true return decision } func saturatingMultiply(left, right int64) int64 { if left <= 0 || right <= 0 { return 0 } if left > math.MaxInt64/right { return math.MaxInt64 } return left * right } func saturatingAdd(left, right int64) int64 { if left >= math.MaxInt64-right { return math.MaxInt64 } return left + right }