proxy-pool/internal/controller/health/task_broker.go
youfak 3421ad5e14
Some checks are pending
ci / proto (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
ci / race (push) Waiting to run
ci / integration (push) Waiting to run
feat: add redis basic health task broker
2026-07-31 21:48:59 +08:00

436 lines
13 KiB
Go

package health
import (
"context"
"crypto/rand"
"encoding/hex"
"reflect"
"sort"
"strings"
"sync"
"time"
healthDomain "proxy-pool/internal/domain/health"
proxyDomain "proxy-pool/internal/domain/proxy"
)
var (
ErrInvalidTaskBroker = healthDomain.ErrInvalidTaskBroker
ErrInvalidTaskClaim = healthDomain.ErrInvalidTaskClaim
ErrInvalidLeasedTask = healthDomain.ErrInvalidLeasedTask
ErrTaskNotFound = healthDomain.ErrTaskNotFound
ErrTaskLeaseExpired = healthDomain.ErrTaskLeaseExpired
ErrTaskLeaseNotOwned = healthDomain.ErrTaskLeaseNotOwned
ErrTaskObservation = healthDomain.ErrTaskObservation
)
const defaultMaxTasksPerClaim = 128
type TaskMaterial = healthDomain.TaskMaterial
type TaskMaterialResolver = healthDomain.TaskMaterialResolver
type TaskMaterialResolverFunc = healthDomain.TaskMaterialResolverFunc
type TaskClaim = healthDomain.TaskClaim
type LeasedTask = healthDomain.LeasedTask
type TaskBroker = healthDomain.TaskBroker
type MemoryTaskBrokerOptions struct {
LeaseTTL time.Duration
MaxPerClaim int
Now func() time.Time
Material TaskMaterialResolver
}
// MemoryTaskBroker is a deterministic contract implementation for unit tests
// and single-process development. Production Controller instances use Redis so
// leases and limits remain shared across replicas.
type MemoryTaskBroker struct {
mu sync.Mutex
options MemoryTaskBrokerOptions
tasks map[string]brokerTask
leases map[string]taskLease
finished map[string]taskCompletion
}
type brokerTask struct {
plan PlannedTask
}
type taskLease struct {
checkerID string
instanceID string
token string
expiresAt time.Time
}
type taskCompletion struct {
checkerID string
token string
expiresAt time.Time
}
func NewMemoryTaskBroker(options MemoryTaskBrokerOptions) (*MemoryTaskBroker, error) {
if options.LeaseTTL <= 0 || options.Now == nil || nilTaskMaterialResolver(options.Material) {
return nil, ErrInvalidTaskBroker
}
if options.MaxPerClaim == 0 {
options.MaxPerClaim = defaultMaxTasksPerClaim
}
if options.MaxPerClaim <= 0 {
return nil, ErrInvalidTaskBroker
}
return &MemoryTaskBroker{
options: options, tasks: make(map[string]brokerTask), leases: make(map[string]taskLease),
finished: make(map[string]taskCompletion),
}, nil
}
func (broker *MemoryTaskBroker) Offer(ctx context.Context, plans []PlannedTask) (int, error) {
if ctx == nil || broker == nil || broker.options.Now == nil {
return 0, ErrInvalidTaskBroker
}
if err := ctx.Err(); err != nil {
return 0, err
}
if len(plans) == 0 {
return 0, nil
}
now := broker.options.Now().UTC()
if now.IsZero() {
return 0, ErrInvalidTaskBroker
}
for _, plan := range plans {
if err := validatePlannedTask(plan, now); err != nil {
return 0, err
}
}
broker.mu.Lock()
defer broker.mu.Unlock()
broker.reapLocked(now)
offered := 0
for _, plan := range plans {
taskID := deterministicTaskID(plan)
if _, exists := broker.tasks[taskID]; exists {
continue
}
broker.tasks[taskID] = brokerTask{plan: plan}
offered++
}
return offered, nil
}
func (broker *MemoryTaskBroker) Claim(ctx context.Context, claim TaskClaim) ([]LeasedTask, error) {
if ctx == nil || broker == nil || broker.options.Now == nil || nilTaskMaterialResolver(broker.options.Material) {
return nil, ErrInvalidTaskBroker
}
if err := ctx.Err(); err != nil {
return nil, err
}
levels, err := normalizeTaskClaim(claim, broker.options.MaxPerClaim)
if err != nil {
return nil, err
}
now := broker.options.Now().UTC()
if now.IsZero() {
return nil, ErrInvalidTaskBroker
}
broker.mu.Lock()
defer broker.mu.Unlock()
broker.reapLocked(now)
capacity := claim.MaxInFlight - broker.activeForCheckerLocked(claim.CheckerID)
if capacity <= 0 {
return []LeasedTask{}, nil
}
if capacity > broker.options.MaxPerClaim {
capacity = broker.options.MaxPerClaim
}
taskIDs := broker.claimableTaskIDsLocked(levels)
if len(taskIDs) > capacity {
taskIDs = taskIDs[:capacity]
}
if len(taskIDs) == 0 {
return []LeasedTask{}, nil
}
materials := make([]TaskMaterial, len(taskIDs))
tokens := make([]string, len(taskIDs))
for index, taskID := range taskIDs {
material, resolveErr := broker.options.Material.ResolveCheckTask(ctx, broker.tasks[taskID].plan.Candidate)
if resolveErr != nil {
return nil, resolveErr
}
if validateTaskMaterial(material) != nil {
return nil, ErrInvalidLeasedTask
}
materials[index] = material
leaseToken, tokenErr := newLeaseToken()
if tokenErr != nil {
return nil, tokenErr
}
tokens[index] = leaseToken
}
result := make([]LeasedTask, len(taskIDs))
for index, taskID := range taskIDs {
plan := broker.tasks[taskID].plan
leaseUntil := now.Add(broker.options.LeaseTTL)
if plan.Deadline.Before(leaseUntil) {
leaseUntil = plan.Deadline
}
leaseToken := tokens[index]
broker.leases[taskID] = taskLease{checkerID: claim.CheckerID, instanceID: claim.InstanceID, token: leaseToken, expiresAt: leaseUntil}
result[index] = leasedTask(taskID, leaseToken, plan, materials[index])
}
return result, nil
}
func (broker *MemoryTaskBroker) AuthorizeObservation(
ctx context.Context,
checkerID string,
leaseToken string,
observation healthDomain.Observation,
now time.Time,
) error {
if ctx == nil || broker == nil || !validTaskIdentifier(checkerID) || !validTaskIdentifier(leaseToken) || now.IsZero() {
return ErrInvalidTaskBroker
}
if err := ctx.Err(); err != nil {
return err
}
normalized, err := healthDomain.NormalizeObservation(observation)
if err != nil {
return err
}
broker.mu.Lock()
defer broker.mu.Unlock()
return broker.authorizeObservationLocked(checkerID, leaseToken, normalized, now.UTC())
}
func (broker *MemoryTaskBroker) CompleteObservation(
ctx context.Context,
checkerID string,
leaseToken string,
observation healthDomain.Observation,
now time.Time,
) error {
if ctx == nil || broker == nil || !validTaskIdentifier(checkerID) || !validTaskIdentifier(leaseToken) || now.IsZero() {
return ErrInvalidTaskBroker
}
if err := ctx.Err(); err != nil {
return err
}
normalized, err := healthDomain.NormalizeObservation(observation)
if err != nil {
return err
}
now = now.UTC()
broker.mu.Lock()
defer broker.mu.Unlock()
if err := broker.authorizeObservationLocked(checkerID, leaseToken, normalized, now); err != nil {
return err
}
if _, complete := broker.finished[normalized.TaskID]; complete {
return nil
}
task := broker.tasks[normalized.TaskID]
delete(broker.leases, normalized.TaskID)
broker.finished[normalized.TaskID] = taskCompletion{checkerID: checkerID, token: leaseToken, expiresAt: task.plan.Deadline}
return nil
}
func (broker *MemoryTaskBroker) authorizeObservationLocked(
checkerID string,
leaseToken string,
observation healthDomain.Observation,
now time.Time,
) error {
broker.reapLocked(now)
task, exists := broker.tasks[observation.TaskID]
if !exists {
return ErrTaskNotFound
}
if !taskMatchesObservation(task.plan, observation) {
return ErrTaskObservation
}
if finished, complete := broker.finished[observation.TaskID]; complete {
if finished.checkerID != checkerID || finished.token != leaseToken {
return ErrTaskLeaseNotOwned
}
return nil
}
lease, leased := broker.leases[observation.TaskID]
if !leased || !lease.expiresAt.After(now) {
return ErrTaskLeaseExpired
}
if lease.checkerID != checkerID || lease.token != leaseToken {
return ErrTaskLeaseNotOwned
}
return nil
}
func (broker *MemoryTaskBroker) reapLocked(now time.Time) {
for taskID, task := range broker.tasks {
if !task.plan.Deadline.After(now) {
delete(broker.tasks, taskID)
delete(broker.leases, taskID)
delete(broker.finished, taskID)
}
}
for taskID, lease := range broker.leases {
if !lease.expiresAt.After(now) {
delete(broker.leases, taskID)
}
}
for taskID, complete := range broker.finished {
if !complete.expiresAt.After(now) {
delete(broker.finished, taskID)
}
}
}
func (broker *MemoryTaskBroker) activeForCheckerLocked(checkerID string) int {
active := 0
for _, lease := range broker.leases {
if lease.checkerID == checkerID {
active++
}
}
return active
}
func (broker *MemoryTaskBroker) claimableTaskIDsLocked(levels map[healthDomain.Level]struct{}) []string {
result := make([]string, 0, len(broker.tasks))
for taskID, task := range broker.tasks {
if _, leased := broker.leases[taskID]; leased {
continue
}
if _, complete := broker.finished[taskID]; complete {
continue
}
if _, supported := levels[task.plan.Candidate.Level]; supported {
result = append(result, taskID)
}
}
sort.Slice(result, func(left, right int) bool {
leftTask, rightTask := broker.tasks[result[left]].plan, broker.tasks[result[right]].plan
if leftTask.Priority != rightTask.Priority {
return leftTask.Priority < rightTask.Priority
}
if !leftTask.Candidate.DueAt.Equal(rightTask.Candidate.DueAt) {
return leftTask.Candidate.DueAt.Before(rightTask.Candidate.DueAt)
}
return result[left] < result[right]
})
return result
}
func normalizeTaskClaim(claim TaskClaim, maximum int) (map[healthDomain.Level]struct{}, error) {
if !validTaskIdentifier(claim.CheckerID) || !validTaskIdentifier(claim.InstanceID) || claim.MaxInFlight <= 0 ||
claim.MaxInFlight > maximum || len(claim.SupportedLevels) == 0 {
return nil, ErrInvalidTaskClaim
}
levels := make(map[healthDomain.Level]struct{}, len(claim.SupportedLevels))
for _, level := range claim.SupportedLevels {
switch level {
case healthDomain.LevelBasic, healthDomain.LevelEgress, healthDomain.LevelTarget:
default:
return nil, ErrInvalidTaskClaim
}
if _, duplicate := levels[level]; duplicate {
return nil, ErrInvalidTaskClaim
}
levels[level] = struct{}{}
}
return levels, nil
}
func validatePlannedTask(task PlannedTask, now time.Time) error {
if task.Deadline.IsZero() || !task.Deadline.After(now) || task.Attempts <= 0 || !validTaskIdentifier(task.Candidate.ProxyID) ||
task.Candidate.DueAt.IsZero() {
return ErrInvalidLeasedTask
}
switch task.Candidate.State {
case proxyDomain.StateFetched, proxyDomain.StateSuspect, proxyDomain.StateUnhealthy, proxyDomain.StateAvailable:
default:
return ErrInvalidLeasedTask
}
switch task.Candidate.Level {
case healthDomain.LevelBasic, healthDomain.LevelEgress:
if task.Candidate.RoutingName != "" || task.Candidate.TargetURL != "" {
return ErrInvalidLeasedTask
}
case healthDomain.LevelTarget:
if _, err := healthDomain.NormalizeTargetProfile(healthDomain.TargetProfile{
RoutingName: task.Candidate.RoutingName, TargetURL: task.Candidate.TargetURL,
}); err != nil {
return ErrInvalidLeasedTask
}
default:
return ErrInvalidLeasedTask
}
return nil
}
func validateTaskMaterial(material TaskMaterial) error {
if strings.TrimSpace(material.Host) != material.Host || material.Host == "" || material.Port == 0 ||
(material.SecretRef == "") != (material.CredentialVersion == "") {
return ErrInvalidLeasedTask
}
switch material.Protocol {
case proxyDomain.SchemeHTTP, proxyDomain.SchemeHTTPS, proxyDomain.SchemeSOCKS5:
return nil
default:
return ErrInvalidLeasedTask
}
}
func deterministicTaskID(task PlannedTask) string {
return healthDomain.TaskIDFor(task)
}
func leasedTask(taskID, leaseToken string, plan PlannedTask, material TaskMaterial) LeasedTask {
return LeasedTask{
TaskID: taskID, LeaseToken: leaseToken, ProxyID: plan.Candidate.ProxyID, Protocol: material.Protocol, Host: material.Host, Port: material.Port,
SecretRef: material.SecretRef, CredentialVersion: material.CredentialVersion, Username: material.Username, Password: material.Password,
Level: plan.Candidate.Level, RoutingName: plan.Candidate.RoutingName, TargetURL: plan.Candidate.TargetURL,
Deadline: plan.Deadline.UTC(), Attempts: plan.Attempts,
}
}
func newLeaseToken() (string, error) {
var entropy [24]byte
if _, err := rand.Read(entropy[:]); err != nil {
return "", err
}
return "lease_" + hex.EncodeToString(entropy[:]), nil
}
func taskMatchesObservation(task PlannedTask, observation healthDomain.Observation) bool {
return task.Candidate.ProxyID == observation.ProxyID && task.Candidate.Level == observation.Level &&
task.Candidate.RoutingName == observation.RoutingName && task.Candidate.TargetURL == observation.TargetURL
}
func validTaskIdentifier(value string) bool {
if value == "" || len(value) > 256 || strings.TrimSpace(value) != value {
return false
}
for _, character := range value {
if character <= ' ' || character == '\x7f' {
return false
}
}
return true
}
func nilTaskMaterialResolver(value TaskMaterialResolver) bool {
if value == nil {
return true
}
reflected := reflect.ValueOf(value)
switch reflected.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return reflected.IsNil()
default:
return false
}
}
var _ TaskBroker = (*MemoryTaskBroker)(nil)