feat: run bounded health scheduling ticks
This commit is contained in:
parent
0c3fe2c1a1
commit
c9fa32bd41
128
internal/controller/health/scheduler_runner.go
Normal file
128
internal/controller/health/scheduler_runner.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidSchedulerRunner = errors.New("invalid health scheduler runner")
|
||||||
|
ErrInvalidDueSource = errors.New("invalid health due source response")
|
||||||
|
ErrInvalidTaskSink = errors.New("invalid health task sink response")
|
||||||
|
)
|
||||||
|
|
||||||
|
// DueSource is implemented by the Controller's future Redis due-index. Both
|
||||||
|
// calls are bounded and stay outside the Gateway request path.
|
||||||
|
type DueSource interface {
|
||||||
|
InFlight(context.Context, time.Time) (int, error)
|
||||||
|
DueCandidates(context.Context, time.Time, int) ([]Candidate, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskSink atomically offers an already bounded batch to the shared leased
|
||||||
|
// task store. It must leave unaccepted candidates eligible for a later tick.
|
||||||
|
type TaskSink interface {
|
||||||
|
Offer(context.Context, []PlannedTask) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SchedulerRunnerOptions struct {
|
||||||
|
PollInterval time.Duration
|
||||||
|
BatchSize int
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchedulerRunner performs one bounded tick at a time. Run has one ticker
|
||||||
|
// loop, and Tick performs no concurrent fan-out.
|
||||||
|
type SchedulerRunner struct {
|
||||||
|
planner *Planner
|
||||||
|
source DueSource
|
||||||
|
sink TaskSink
|
||||||
|
options SchedulerRunnerOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSchedulerRunner(
|
||||||
|
planner *Planner,
|
||||||
|
source DueSource,
|
||||||
|
sink TaskSink,
|
||||||
|
options SchedulerRunnerOptions,
|
||||||
|
) (*SchedulerRunner, error) {
|
||||||
|
if planner == nil || nilInterface(source) || nilInterface(sink) || options.PollInterval <= 0 ||
|
||||||
|
options.BatchSize <= 0 || options.Now == nil {
|
||||||
|
return nil, ErrInvalidSchedulerRunner
|
||||||
|
}
|
||||||
|
return &SchedulerRunner{planner: planner, source: source, sink: sink, options: options}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TickResult struct {
|
||||||
|
Planned int
|
||||||
|
Offered int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (runner *SchedulerRunner) Tick(ctx context.Context) (TickResult, error) {
|
||||||
|
if ctx == nil || runner == nil || runner.planner == nil || nilInterface(runner.source) ||
|
||||||
|
nilInterface(runner.sink) || runner.options.Now == nil {
|
||||||
|
return TickResult{}, ErrInvalidSchedulerRunner
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return TickResult{}, err
|
||||||
|
}
|
||||||
|
now := runner.options.Now()
|
||||||
|
if now.IsZero() {
|
||||||
|
return TickResult{}, ErrInvalidSchedulerRunner
|
||||||
|
}
|
||||||
|
inFlight, err := runner.source.InFlight(ctx, now.UTC())
|
||||||
|
if err != nil {
|
||||||
|
return TickResult{}, err
|
||||||
|
}
|
||||||
|
if inFlight < 0 {
|
||||||
|
return TickResult{}, ErrInvalidDueSource
|
||||||
|
}
|
||||||
|
if inFlight >= runner.planner.policy.MaxInFlight {
|
||||||
|
return TickResult{}, nil
|
||||||
|
}
|
||||||
|
candidates, err := runner.source.DueCandidates(ctx, now.UTC(), runner.options.BatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return TickResult{}, err
|
||||||
|
}
|
||||||
|
if len(candidates) > runner.options.BatchSize {
|
||||||
|
return TickResult{}, ErrInvalidDueSource
|
||||||
|
}
|
||||||
|
plans, err := runner.planner.Plan(now.UTC(), inFlight, runner.options.BatchSize, candidates)
|
||||||
|
if err != nil {
|
||||||
|
return TickResult{}, err
|
||||||
|
}
|
||||||
|
result := TickResult{Planned: len(plans)}
|
||||||
|
if len(plans) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
offered, err := runner.sink.Offer(ctx, plans)
|
||||||
|
if err != nil {
|
||||||
|
return TickResult{}, err
|
||||||
|
}
|
||||||
|
if offered < 0 || offered > len(plans) {
|
||||||
|
return TickResult{}, ErrInvalidTaskSink
|
||||||
|
}
|
||||||
|
result.Offered = offered
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (runner *SchedulerRunner) Run(ctx context.Context) error {
|
||||||
|
if ctx == nil || runner == nil {
|
||||||
|
return ErrInvalidSchedulerRunner
|
||||||
|
}
|
||||||
|
if _, err := runner.Tick(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(runner.options.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
if _, err := runner.Tick(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
internal/controller/health/scheduler_runner_test.go
Normal file
105
internal/controller/health/scheduler_runner_test.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
package health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
healthDomain "proxy-pool/internal/domain/health"
|
||||||
|
proxyDomain "proxy-pool/internal/domain/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSchedulerRunnerUsesBoundedDueBatchAndSharedInFlightCount(t *testing.T) {
|
||||||
|
planner, err := NewPlanner(SchedulePolicy{
|
||||||
|
Interval: time.Minute, MaxInFlight: 3, Timeout: time.Second, MaxAttempts: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPlanner(): %v", err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 7, 31, 18, 0, 0, 0, time.UTC)
|
||||||
|
source := &dueSourceStub{inFlight: 1, candidates: []Candidate{
|
||||||
|
{ProxyID: "fetched", State: proxyDomain.StateFetched, Level: healthDomain.LevelBasic, DueAt: now},
|
||||||
|
{ProxyID: "available", State: proxyDomain.StateAvailable, Level: healthDomain.LevelBasic, DueAt: now},
|
||||||
|
}}
|
||||||
|
sink := &taskSinkStub{}
|
||||||
|
runner, err := NewSchedulerRunner(planner, source, sink, SchedulerRunnerOptions{
|
||||||
|
PollInterval: time.Second, BatchSize: 2, Now: func() time.Time { return now },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSchedulerRunner(): %v", err)
|
||||||
|
}
|
||||||
|
result, err := runner.Tick(context.Background())
|
||||||
|
if err != nil || result.Planned != 2 || result.Offered != 2 || source.limit != 2 || len(sink.tasks) != 2 ||
|
||||||
|
sink.tasks[0].Priority != PriorityFetched {
|
||||||
|
t.Fatalf("Tick() = (%+v, %v); source=%+v sink=%+v", result, err, source, sink.tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchedulerRunnerSkipsDueReadAtSharedCapacity(t *testing.T) {
|
||||||
|
planner, err := NewPlanner(SchedulePolicy{
|
||||||
|
Interval: time.Minute, MaxInFlight: 1, Timeout: time.Second, MaxAttempts: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPlanner(): %v", err)
|
||||||
|
}
|
||||||
|
source := &dueSourceStub{inFlight: 1}
|
||||||
|
runner, err := NewSchedulerRunner(planner, source, &taskSinkStub{}, SchedulerRunnerOptions{
|
||||||
|
PollInterval: time.Second, BatchSize: 1, Now: time.Now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSchedulerRunner(): %v", err)
|
||||||
|
}
|
||||||
|
result, err := runner.Tick(context.Background())
|
||||||
|
if err != nil || result != (TickResult{}) || source.limit != 0 {
|
||||||
|
t.Fatalf("Tick(at capacity) = (%+v, %v); source=%+v", result, err, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchedulerRunnerRejectsOversizedSourceAndSinkResponses(t *testing.T) {
|
||||||
|
planner, err := NewPlanner(SchedulePolicy{
|
||||||
|
Interval: time.Minute, MaxInFlight: 2, Timeout: time.Second, MaxAttempts: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPlanner(): %v", err)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
runner, err := NewSchedulerRunner(planner, &dueSourceStub{candidates: []Candidate{
|
||||||
|
{ProxyID: "one", State: proxyDomain.StateFetched, Level: healthDomain.LevelBasic, DueAt: now},
|
||||||
|
{ProxyID: "two", State: proxyDomain.StateFetched, Level: healthDomain.LevelBasic, DueAt: now},
|
||||||
|
}}, &taskSinkStub{}, SchedulerRunnerOptions{PollInterval: time.Second, BatchSize: 1, Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSchedulerRunner(): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := runner.Tick(context.Background()); !errors.Is(err, ErrInvalidDueSource) {
|
||||||
|
t.Fatalf("Tick(oversized source) error = %v, want ErrInvalidDueSource", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type dueSourceStub struct {
|
||||||
|
inFlight int
|
||||||
|
candidates []Candidate
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *dueSourceStub) InFlight(context.Context, time.Time) (int, error) {
|
||||||
|
return source.inFlight, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *dueSourceStub) DueCandidates(_ context.Context, _ time.Time, limit int) ([]Candidate, error) {
|
||||||
|
source.limit = limit
|
||||||
|
return source.candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskSinkStub struct {
|
||||||
|
tasks []PlannedTask
|
||||||
|
offer int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sink *taskSinkStub) Offer(_ context.Context, tasks []PlannedTask) (int, error) {
|
||||||
|
sink.tasks = append([]PlannedTask(nil), tasks...)
|
||||||
|
if sink.offer != 0 {
|
||||||
|
return sink.offer, nil
|
||||||
|
}
|
||||||
|
return len(tasks), nil
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user