SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
12.1 KB · 451 lines go
Raw Blame History
1// Package sched schedules (target, check-family) jobs with a single priority timer wheel.2//3// Guarantees (docs/PROBE-PROTOCOL.md "Rules the agent follows" + SPEC ethics):4//   - per-family intervals: http → schedule.tiers[tier], dns → dns_every, ping/tcp → ping_every,5//     traceroute → traceroute_every; boost multiplies the interval for listed targets until `until`;6//   - ±10 % jitter on every interval, initial offsets spread uniformly over the interval (no start-up burst);7//   - never below MinInterval (10 s) even if the server asks for less (clamped, logged);8//   - a global concurrency limit, at most one in-flight check per hostname, at most one traceroute at a time;9//   - config changes (targets added/removed/edited, intervals) are applied live without restarting.10//11// All state is owned by the Run loop goroutine; Apply/SetBoost communicate through channels, so no locks.12package sched1314import (15	"container/heap"16	"context"17	"log/slog"18	"math/rand/v2"19	"runtime/debug"20	"strconv"21	"sync/atomic"22	"time"2324	"internetpressure.io/probe-agent/internal/protocol"25)2627// Family is a check family.28type Family string2930// Families.31const (32	FamilyHTTP       Family = "http"33	FamilyDNS        Family = "dns"34	FamilyPing       Family = "ping"35	FamilyTCP        Family = "tcp"36	FamilyTraceroute Family = "traceroute"37)3839// Defaults when the server omits a schedule value.40const (41	MinInterval          = 10 * time.Second42	DefaultDNSEvery      = 60 * time.Second43	DefaultPingEvery     = 30 * time.Second44	DefaultTraceEvery    = 15 * time.Minute45	DefaultTierInterval  = 180 * time.Second46	JitterFraction       = 0.1047	busyRetryBase        = 500 * time.Millisecond48	DefaultMaxConcurrent = 849)5051// Job is one schedulable unit.52type Job struct {53	Key      string // target_id + "/" + family54	Target   protocol.Target55	Family   Family56	Interval time.Duration // base interval (clamped, before boost/jitter)57}5859// Runner executes a job. It must return when ctx is done.60type Runner func(ctx context.Context, job Job)6162// Options tune the scheduler.63type Options struct {64	MaxConcurrency int65	MinInterval    time.Duration // default MinInterval; tests lower it66	Logger         *slog.Logger67	Seed           uint64 // 0 → random68	// Capabilities: when false the family is not scheduled at all.69	CanPing       bool70	CanTraceroute bool71	// IntervalScale multiplies every configured interval before the clamp (tests only; 0 → 1).72	IntervalScale float6473}7475type entry struct {76	job     Job77	gen     uint6478	running bool79}8081type item struct {82	key string83	gen uint6484	at  time.Time85}8687type itemHeap []item8889func (h itemHeap) Len() int           { return len(h) }90func (h itemHeap) Less(i, j int) bool { return h[i].at.Before(h[j].at) }91func (h itemHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }92func (h *itemHeap) Push(x any)        { *h = append(*h, x.(item)) }93func (h *itemHeap) Pop() any          { old := *h; n := len(old); it := old[n-1]; *h = old[:n-1]; return it }94func (h itemHeap) peek() (item, bool) {95	if len(h) == 0 {96		return item{}, false97	}98	return h[0], true99}100101type doneMsg struct {102	key      string103	gen      uint64104	host     string105	trace    bool106	panicked bool107}108109// Scheduler is the timer wheel.110type Scheduler struct {111	run  Runner112	opts Options113	rng  *rand.Rand114	log  *slog.Logger115116	applyCh chan *protocol.RemoteConfig117	boostCh chan *protocol.Boost118	doneCh  chan doneMsg119120	// loop-owned121	entries  map[string]*entry122	heap     itemHeap123	hostBusy map[string]int124	inflight int125	trBusy   bool126	boost    *protocol.Boost127	gen      uint64128129	targets atomic.Int32130	jobs    atomic.Int32131	panics  atomic.Uint64132	runs    atomic.Uint64133}134135// New creates a scheduler; call Run to start it.136func New(run Runner, opts Options) *Scheduler {137	if opts.MaxConcurrency <= 0 {138		opts.MaxConcurrency = DefaultMaxConcurrent139	}140	if opts.MinInterval <= 0 {141		opts.MinInterval = MinInterval142	}143	if opts.Logger == nil {144		opts.Logger = slog.Default()145	}146	seed := opts.Seed147	if seed == 0 {148		seed = rand.Uint64()149	}150	return &Scheduler{151		run:      run,152		opts:     opts,153		rng:      rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)),154		log:      opts.Logger,155		applyCh:  make(chan *protocol.RemoteConfig, 1),156		boostCh:  make(chan *protocol.Boost, 1),157		doneCh:   make(chan doneMsg, 64),158		entries:  map[string]*entry{},159		hostBusy: map[string]int{},160	}161}162163// Apply installs a new remote config (targets + schedule + boost). Non-blocking; the latest wins.164func (s *Scheduler) Apply(cfg *protocol.RemoteConfig) {165	for {166		select {167		case s.applyCh <- cfg:168			return169		default:170			select {171			case <-s.applyCh: // drop the stale pending config172			default:173			}174		}175	}176}177178// SetBoost replaces the active boost (nil clears it).179func (s *Scheduler) SetBoost(b *protocol.Boost) {180	select {181	case s.boostCh <- b:182	default:183		select {184		case <-s.boostCh:185		default:186		}187		s.boostCh <- b188	}189}190191// Targets returns the number of distinct scheduled targets.192func (s *Scheduler) Targets() int { return int(s.targets.Load()) }193194// Jobs returns the number of scheduled (target, family) jobs.195func (s *Scheduler) Jobs() int { return int(s.jobs.Load()) }196197// Panics returns the number of recovered check panics.198func (s *Scheduler) Panics() uint64 { return s.panics.Load() }199200// Runs returns the number of completed job executions.201func (s *Scheduler) Runs() uint64 { return s.runs.Load() }202203// Run drives the wheel until ctx is cancelled. In-flight jobs are cancelled through ctx too.204func (s *Scheduler) Run(ctx context.Context) {205	timer := time.NewTimer(time.Hour)206	timer.Stop()207	for {208		// Dispatch everything that is due, then arm the timer for the next item.209		now := time.Now()210		for {211			it, ok := s.heap.peek()212			if !ok || it.at.After(now) {213				break214			}215			heap.Pop(&s.heap)216			s.dispatch(ctx, it, now)217		}218		if it, ok := s.heap.peek(); ok {219			timer.Reset(time.Until(it.at))220		} else {221			timer.Stop()222		}223		select {224		case <-ctx.Done():225			timer.Stop()226			return227		case cfg := <-s.applyCh:228			s.apply(cfg, time.Now())229		case b := <-s.boostCh:230			s.boost = b231		case d := <-s.doneCh:232			s.complete(d, time.Now())233		case <-timer.C:234		}235	}236}237238func (s *Scheduler) dispatch(ctx context.Context, it item, now time.Time) {239	e, ok := s.entries[it.key]240	if !ok || e.gen != it.gen {241		return // removed or replaced242	}243	host := e.job.Target.Hostname244	trace := e.job.Family == FamilyTraceroute245	if e.running || s.hostBusy[host] > 0 || s.inflight >= s.opts.MaxConcurrency || (trace && s.trBusy) {246		// Busy: try again shortly, spread so that many deferred jobs don't collide. The retry window is247		// 0.5–1.5 s in production (MinInterval 10 s) and scales down with MinInterval in tests.248		base := busyRetryBase249		if b := s.opts.MinInterval / 20; b < base {250			base = b251		}252		s.push(it.key, it.gen, now.Add(base+time.Duration(s.rng.Int64N(int64(2*base)))))253		return254	}255	e.running = true256	s.hostBusy[host]++257	s.inflight++258	if trace {259		s.trBusy = true260	}261	job := e.job262	gen := e.gen263	go func() {264		msg := doneMsg{key: job.Key, gen: gen, host: host, trace: trace}265		defer func() {266			if r := recover(); r != nil {267				msg.panicked = true268				s.panics.Add(1)269				s.log.Error("check panicked", "job", job.Key, "panic", r, "stack", string(debug.Stack()))270			}271			s.runs.Add(1)272			select {273			case s.doneCh <- msg:274			case <-ctx.Done():275				// Loop has exited; nothing to release.276			}277		}()278		s.run(ctx, job)279	}()280}281282func (s *Scheduler) complete(d doneMsg, now time.Time) {283	s.inflight--284	if s.hostBusy[d.host] > 1 {285		s.hostBusy[d.host]--286	} else {287		delete(s.hostBusy, d.host)288	}289	if d.trace {290		s.trBusy = false291	}292	e, ok := s.entries[d.key]293	if !ok {294		return // removed while running295	}296	if e.gen != d.gen {297		return // replaced while running: the new generation already has a heap item298	}299	e.running = false300	s.push(d.key, e.gen, now.Add(s.nextDelay(e.job, now)))301}302303func (s *Scheduler) push(key string, gen uint64, at time.Time) {304	heap.Push(&s.heap, item{key: key, gen: gen, at: at})305}306307// nextDelay = interval × boost (if active) clamped to MinInterval, with ±10 % jitter.308func (s *Scheduler) nextDelay(job Job, now time.Time) time.Duration {309	base := job.Interval310	if s.boost != nil && s.boost.Active(job.Target.TargetID, now) {311		base = time.Duration(float64(base) * s.boost.Factor)312	}313	if base < s.opts.MinInterval {314		base = s.opts.MinInterval315	}316	return Jitter(base, s.rng)317}318319// Jitter returns d × U(0.9, 1.1).320func Jitter(d time.Duration, rng *rand.Rand) time.Duration {321	f := 1 + (rng.Float64()*2-1)*JitterFraction322	return time.Duration(float64(d) * f)323}324325// InitialOffset returns U[0, d): uniform spread of first runs over one interval.326func InitialOffset(d time.Duration, rng *rand.Rand) time.Duration {327	if d <= 0 {328		return 0329	}330	return time.Duration(rng.Int64N(int64(d)))331}332333// apply computes the desired job set from cfg and reconciles it with the current entries.334func (s *Scheduler) apply(cfg *protocol.RemoteConfig, now time.Time) {335	desired := map[string]Job{}336	targets := 0337	if cfg != nil && cfg.Probe.Enabled {338		for _, j := range JobsFor(cfg, s.opts) {339			desired[j.Key] = j340		}341		targets = len(cfg.Targets)342	}343	// Remove vanished jobs (heap items are dropped lazily on pop).344	for key := range s.entries {345		if _, keep := desired[key]; !keep {346			delete(s.entries, key)347		}348	}349	added, changed := 0, 0350	for key, job := range desired {351		e, ok := s.entries[key]352		if !ok {353			s.gen++354			s.entries[key] = &entry{job: job, gen: s.gen}355			s.push(key, s.gen, now.Add(InitialOffset(job.Interval, s.rng)))356			added++357			continue358		}359		if targetChanged(e.job.Target, job.Target) {360			// Re-key the generation: the old heap item is discarded on pop, the job restarts spread out.361			s.gen++362			e.job, e.gen = job, s.gen363			s.push(key, s.gen, now.Add(InitialOffset(job.Interval, s.rng)))364			changed++365			continue366		}367		e.job.Interval = job.Interval // takes effect after the next run368	}369	s.targets.Store(int32(targets))370	s.jobs.Store(int32(len(s.entries)))371	if cfg != nil {372		s.log.Info("schedule applied", "config_version", cfg.ConfigVersion, "enabled", cfg.Probe.Enabled,373			"targets", targets, "jobs", len(s.entries), "added", added, "changed", changed)374	}375}376377func targetChanged(a, b protocol.Target) bool {378	if a.Hostname != b.Hostname || a.URL != b.URL || a.Port != b.Port || a.FixedIP() != b.FixedIP() || a.Tier != b.Tier {379		return true380	}381	return false382}383384// JobsFor expands a remote config into jobs (one per target × family), applying defaults, capability filters385// and the MinInterval clamp.386func JobsFor(cfg *protocol.RemoteConfig, opts Options) []Job {387	minI := opts.MinInterval388	if minI <= 0 {389		minI = MinInterval390	}391	log := opts.Logger392	if log == nil {393		log = slog.Default()394	}395	scale := opts.IntervalScale396	if scale <= 0 {397		scale = 1398	}399	sc := cfg.Schedule400	secs := func(v int, def time.Duration) time.Duration {401		if v <= 0 {402			return time.Duration(float64(def) * scale)403		}404		return time.Duration(float64(v) * scale * float64(time.Second))405	}406	clamp := func(d time.Duration, what string) time.Duration {407		if d < minI {408			log.Warn("interval below minimum, clamped", "what", what, "requested", d, "min", minI)409			return minI410		}411		return d412	}413	dnsEvery := clamp(secs(sc.DNSEvery, DefaultDNSEvery), "dns_every")414	pingEvery := clamp(secs(sc.PingEvery, DefaultPingEvery), "ping_every")415	trEvery := clamp(secs(sc.TracerouteEvery, DefaultTraceEvery), "traceroute_every")416417	var jobs []Job418	for _, t := range cfg.Targets {419		if t.TargetID == "" || t.Hostname == "" {420			continue421		}422		tierI := secs(sc.Tiers[strconv.Itoa(t.Tier)], DefaultTierInterval)423		tierI = clamp(tierI, "tier "+strconv.Itoa(t.Tier))424		add := func(f Family, d time.Duration) {425			jobs = append(jobs, Job{Key: t.TargetID + "/" + string(f), Target: t, Family: f, Interval: d})426		}427		for _, c := range t.Checks {428			switch Family(c) {429			case FamilyHTTP:430				add(FamilyHTTP, tierI)431			case FamilyDNS:432				add(FamilyDNS, dnsEvery)433			case FamilyPing:434				if opts.CanPing {435					add(FamilyPing, pingEvery)436				} else {437					add(FamilyTCP, pingEvery) // icmp unavailable → tcp connects on the same cadence438				}439			case FamilyTCP:440				if !t.HasCheck("ping") || opts.CanPing { // avoid a duplicate tcp job when ping already fell back441					add(FamilyTCP, pingEvery)442				}443			}444		}445		if t.Traceroute && opts.CanTraceroute {446			add(FamilyTraceroute, trEvery)447		}448	}449	return jobs450}451