supervisor.go

v0.4.0
Doc Versions Source
1
package chronos
2
3
import (
4
	"context"
5
	"fmt"
6
	"log/slog"
7
	"math/rand/v2"
8
	"runtime/debug"
9
	"sort"
10
	"sync"
11
	"sync/atomic"
12
	"time"
13
14
	"go.bigb.es/auxilia/async"
15
	"go.bigb.es/auxilia/culpa"
16
	"go.bigb.es/auxilia/scribe"
17
)
18
19
// jobHandle is the supervisor's per-job bookkeeping: the [Job] itself,
20
// the cancel func for its scheduling loop (cancelled by [Supervisor.Deactivate],
21
// IV13), and a flag tracking whether a run is in flight (for [OverlapSkip]).
22
type jobHandle struct {
23
	job        *Job
24
	loopCancel context.CancelFunc
25
	running    atomic.Bool
26
}
27
28
// Supervisor owns a set of [Job]s and runs each on its own scheduling
29
// loop. It bounds total concurrency with a shared semaphore (IV9), drains
30
// in-flight runs on [Supervisor.Stop] (IV5), and never blocks a loop on a
31
// run, slot acquisition, or timeout (IV3). The zero value is not usable;
32
// construct one with [New].
33
type Supervisor struct {
34
	opts supervisorOpts
35
	sem  *async.Semaphore
36
37
	mu   sync.Mutex
38
	jobs map[string]*jobHandle
39
40
	root   context.Context
41
	cancel context.CancelFunc
42
	wg     sync.WaitGroup
43
44
	started bool
45
	stopped bool
46
	nameSeq atomic.Uint64
47
}
48
49
// New builds a [Supervisor] from the given [Option]s. Defaults: location
50
// [time.Local], logger [slog.Default], the package wall [Clock], no
51
// timeout, unlimited concurrency, and slot-wait 0 (shed when full).
52
func New(opts ...Option) *Supervisor {
53
	cfg := supervisorOpts{
54
		loc:         time.Local,
55
		logger:      slog.Default(),
56
		clock:       defaultClock,
57
		timeout:     0,
58
		maxInflight: 0,
59
		slotWait:    0,
60
		jitter:      func(max time.Duration) time.Duration { return time.Duration(rand.Int64N(int64(max))) },
61
	}
62
	for _, opt := range opts {
63
		opt(&cfg)
64
	}
65
	return &Supervisor{
66
		opts: cfg,
67
		sem:  async.NewSemaphore(cfg.maxInflight),
68
		jobs: map[string]*jobHandle{},
69
	}
70
}
71
72
// Add registers a [Job], surfacing its stashed build error (IV6) and
73
// rejecting duplicate names. If the supervisor is already running, the
74
// job's loop starts immediately (IV12).
75
func (s *Supervisor) Add(j *Job) error { return s.add(j) }
76
77
// Deactivate retires the named job, stopping its future fires without
78
// cancelling any in-flight run (IV13). It reports whether the job
79
// existed and is idempotent.
80
func (s *Supervisor) Deactivate(name string) bool { return s.deactivate(name) }
81
82
// add is the unexported seam behind [Supervisor.Add] and
83
// [ChronosContext.Add] (IV6, IV12).
84
func (s *Supervisor) add(j *Job) error {
85
	s.mu.Lock()
86
	defer s.mu.Unlock()
87
	if s.stopped {
88
		return culpa.New("chronos: supervisor stopped")
89
	}
90
	if j.buildErr != nil {
91
		return j.buildErr
92
	}
93
	name := j.name
94
	if name == "" {
95
		name = fmt.Sprintf("job-%d", s.nameSeq.Add(1))
96
		j.name = name
97
	}
98
	if _, dup := s.jobs[name]; dup {
99
		return culpa.Errorf("chronos: duplicate job name %q", name)
100
	}
101
	h := &jobHandle{job: j}
102
	s.jobs[name] = h
103
	s.opts.logger.Debug("chronos: job added", "job", name)
104
	if s.started {
105
		loopCtx, lc := context.WithCancel(s.root)
106
		h.loopCancel = lc
107
		s.wg.Add(1)
108
		go s.runLoop(h, loopCtx)
109
	}
110
	return nil
111
}
112
113
// deactivate is the unexported seam behind [Supervisor.Deactivate] and
114
// [ChronosContext.Deactivate]/[ChronosContext.DeactivateJob] (IV13).
115
func (s *Supervisor) deactivate(name string) bool {
116
	s.mu.Lock()
117
	h, ok := s.jobs[name]
118
	if !ok {
119
		s.mu.Unlock()
120
		return false
121
	}
122
	delete(s.jobs, name)
123
	if h.loopCancel != nil {
124
		h.loopCancel()
125
	}
126
	s.mu.Unlock()
127
	s.opts.logger.Debug("chronos: job deactivated", "job", name)
128
	return true
129
}
130
131
// removeIfSelf drops the handle from the registry only if it is still the
132
// one registered under its name. Called when a loop exits on a finite
133
// schedule, so it does not clobber a re-Added job of the same name.
134
func (s *Supervisor) removeIfSelf(h *jobHandle) {
135
	s.mu.Lock()
136
	if s.jobs[h.job.name] == h {
137
		delete(s.jobs, h.job.name)
138
	}
139
	s.mu.Unlock()
140
}
141
142
// logger returns the supervisor's logger; it satisfies the supervisor seam.
143
func (s *Supervisor) logger() *slog.Logger { return s.opts.logger }
144
145
// Jobs returns the names of the registered jobs, sorted.
146
func (s *Supervisor) Jobs() []string {
147
	s.mu.Lock()
148
	names := make([]string, 0, len(s.jobs))
149
	for name := range s.jobs {
150
		names = append(names, name)
151
	}
152
	s.mu.Unlock()
153
	sort.Strings(names)
154
	return names
155
}
156
157
// Start begins scheduling every registered job, deriving the run root
158
// from ctx (PC2). It errors if already started.
159
func (s *Supervisor) Start(ctx context.Context) error {
160
	s.mu.Lock()
161
	defer s.mu.Unlock()
162
	if s.started {
163
		return culpa.New("chronos: supervisor already started")
164
	}
165
	s.started = true
166
	s.root, s.cancel = context.WithCancel(ctx)
167
	for _, h := range s.jobs {
168
		loopCtx, lc := context.WithCancel(s.root)
169
		h.loopCancel = lc
170
		s.wg.Add(1)
171
		go s.runLoop(h, loopCtx)
172
	}
173
	return nil
174
}
175
176
// Stop cancels every loop and run, then waits for in-flight runs to drain,
177
// bounded by ctx (IV5, PC2). It is idempotent and returns ctx.Err() if the
178
// drain outlasts ctx.
179
func (s *Supervisor) Stop(ctx context.Context) error {
180
	s.mu.Lock()
181
	if !s.started || s.stopped {
182
		s.mu.Unlock()
183
		return nil
184
	}
185
	s.stopped = true
186
	s.cancel()
187
	s.mu.Unlock()
188
189
	done := make(chan struct{})
190
	go func() {
191
		s.wg.Wait()
192
		close(done)
193
	}()
194
	select {
195
	case <-done:
196
		return nil
197
	case <-ctx.Done():
198
		return ctx.Err()
199
	}
200
}
201
202
// runLoop schedules and dispatches fires for a single job until its loop
203
// context is cancelled or its schedule is exhausted. It dispatches each
204
// fire on its own goroutine so the loop never blocks (IV3, AS3).
205
// GPC2: exceeds ~10 lines but is inherently one cohesive scheduling loop.
206
func (s *Supervisor) runLoop(h *jobHandle, loopCtx context.Context) {
207
	defer s.wg.Done()
208
	prev := s.opts.clock.Now().In(s.opts.loc)
209
	for {
210
		next := h.job.sched.Next(prev)
211
		if next.IsZero() {
212
			h.loopCancel() // schedule exhausted — release the loop context (IV5)
213
			s.removeIfSelf(h)
214
			return
215
		}
216
		if !next.After(prev) {
217
			// Defensive IV1 guard: a Schedule that fails to advance would
218
			// busy-loop. Stop the job loudly rather than spin.
219
			s.opts.logger.Error("chronos: schedule did not advance; stopping job",
220
				"job", h.job.name, "prev", prev, "next", next)
221
			h.loopCancel()
222
			s.removeIfSelf(h)
223
			return
224
		}
225
		delay := next.Sub(s.opts.clock.Now())
226
		if h.job.delta > 0 {
227
			delay += s.opts.jitter(h.job.delta)
228
		}
229
		if delay < 0 {
230
			delay = 0
231
		}
232
		timer := s.opts.clock.NewTimer(delay)
233
		select {
234
		case <-loopCtx.Done():
235
			timer.Stop()
236
			return
237
		case <-timer.C():
238
		}
239
		s.wg.Add(1)
240
		go s.fire(h, next)
241
		prev = next // anchor on the scheduled (not jittered) time, so no drift
242
	}
243
}
244
245
// fire executes one run of a job: it enforces the overlap policy, acquires
246
// a global slot per the resolved slot-wait, applies the resolved timeout,
247
// runs the function, and logs the outcome. Every acquired resource is
248
// released on every path, including panic (IV7, IV9).
249
// GPC2: exceeds ~10 lines but is inherently one cohesive run pipeline.
250
func (s *Supervisor) fire(h *jobHandle, scheduled time.Time) {
251
	defer s.wg.Done()
252
	log := s.opts.logger.With("job", h.job.name, "fire", scheduled)
253
254
	if h.job.overlap == OverlapSkip {
255
		if !h.running.CompareAndSwap(false, true) {
256
			log.Warn("chronos: overlap skip")
257
			return
258
		}
259
		defer h.running.Store(false)
260
	}
261
262
	// Acquire a global slot, interruptible by root cancellation (Stop) (IV9, IV10).
263
	wait := h.job.slotWait
264
	if !h.job.slotWaitSet {
265
		wait = s.opts.slotWait
266
	}
267
	var acquired bool
268
	switch {
269
	case wait == 0:
270
		acquired = s.sem.TryAcquire()
271
	case wait < 0:
272
		acquired = s.sem.Acquire(s.root) == nil
273
	default:
274
		actx, c := context.WithTimeout(s.root, wait)
275
		acquired = s.sem.Acquire(actx) == nil
276
		c()
277
	}
278
	if !acquired {
279
		log.Warn("chronos: cap skip")
280
		return
281
	}
282
	defer s.sem.Release()
283
284
	// The timeout clock starts only after the slot is acquired (IV8).
285
	to := h.job.timeout
286
	if !h.job.timeoutSet {
287
		to = s.opts.timeout
288
	}
289
	var runCtx context.Context
290
	var cancel context.CancelFunc
291
	if to > 0 {
292
		runCtx, cancel = context.WithTimeout(s.root, to)
293
	} else {
294
		runCtx, cancel = context.WithCancel(s.root)
295
	}
296
	defer cancel()
297
298
	start := s.opts.clock.Now()
299
	defer func() {
300
		if r := recover(); r != nil {
301
			log.Error("chronos: job panic", "panic", r, "stack", string(debug.Stack())) // IV7
302
		}
303
	}()
304
305
	err := h.job.fn(&ChronosContext{Context: runCtx, sup: s, name: h.job.name})
306
	switch {
307
	case runCtx.Err() == context.DeadlineExceeded:
308
		// Build a non-nil base even when the run returned nil, then tag it (IV8).
309
		base := culpa.Wrapf(err, "job %q timed out", h.job.name)
310
		if base == nil {
311
			base = culpa.Errorf("job %q timed out", h.job.name)
312
		}
313
		log.Warn("chronos: job timeout", scribe.Err(culpa.WithTimeout(base)))
314
	case err != nil:
315
		log.Error("chronos: job error", scribe.Err(culpa.Wrap(err, "job failed"))) // PC4
316
	default:
317
		log.Debug("chronos: job complete", "dur", s.opts.clock.Now().Sub(start))
318
	}
319
}
320

Source Files