-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsupervisor.go
More file actions
334 lines (283 loc) · 7.81 KB
/
Copy pathsupervisor.go
File metadata and controls
334 lines (283 loc) · 7.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
package backstage
import (
"context"
"log/slog"
"sync"
"time"
)
const defaultDrainTimeout = 30 * time.Second
// Supervisor manages a set of named queues and scheduled tasks. Start it with
// [Supervisor.Serve], which blocks until the context is cancelled and then
// drains all in-flight workers before returning.
type Supervisor struct {
name string
log *slog.Logger
drainTimeout time.Duration
mu sync.Mutex
queues map[string]*queue
tasks []*scheduledTask
}
// New creates a new Supervisor. Options are applied in order; the last
// [WithLogger] call wins. If no logger is set, [slog.Default] is used.
func New(name string, opts ...Option) *Supervisor {
s := &Supervisor{
name: name,
drainTimeout: defaultDrainTimeout,
queues: make(map[string]*queue),
}
for _, opt := range opts {
opt(s)
}
// Set default logger if WithLogger was not called.
if s.log == nil {
s.log = newLogger(name, nil)
}
return s
}
// RegisterQueue registers a named worker pool. Must be called before [Supervisor.Serve].
// When cfg.Store is nil, an in-memory buffered channel of depth cfg.Buffer is used.
func (s *Supervisor) RegisterQueue(name string, cfg QueueConfig) {
if cfg.Workers < 1 {
cfg.Workers = 1
}
store := cfg.Store
if store == nil {
buf := cfg.Buffer
if buf < 1 {
buf = 32
}
store = newMemStore(buf)
}
s.mu.Lock()
defer s.mu.Unlock()
q := newQueue(name, cfg.Workers, store, s.log)
s.queues[name] = q
s.log.Info("queue registered",
slog.String("queue", name),
slog.Int("workers", cfg.Workers),
)
}
// Dispatch enqueues a job onto the named queue without blocking.
// Returns [ErrQueueNotFound] if the queue has not been registered.
// Returns [ErrQueueFull] if the buffer is full and the job cannot be accepted.
func (s *Supervisor) Dispatch(queueName string, job Job) error {
s.mu.Lock()
q, ok := s.queues[queueName]
s.mu.Unlock()
if !ok {
s.log.Error("dispatch to unknown queue",
slog.String("queue", queueName),
slog.String("job", job.Name),
)
return ErrQueueNotFound
}
if err := q.dispatch(job); err != nil {
s.log.Warn("job dispatch failed",
slog.String("queue", queueName),
slog.String("job", job.Name),
slog.Int("queue_depth", q.depth()),
slog.Any("error", err),
)
return err
}
return nil
}
// Schedule registers a task that runs on the given schedule directly in its
// own goroutine (not dispatched to any queue).
func (s *Supervisor) Schedule(schedule Schedule, job Job) {
s.addTask(&scheduledTask{job: job, schedule: schedule})
}
// ScheduleOnQueue registers a task that runs on the given schedule and is
// dispatched onto the named queue when it fires.
func (s *Supervisor) ScheduleOnQueue(queueName string, schedule Schedule, job Job) {
s.addTask(&scheduledTask{job: job, schedule: schedule, queue: queueName})
}
func (s *Supervisor) addTask(t *scheduledTask) {
s.mu.Lock()
defer s.mu.Unlock()
t.nextRun = t.schedule.Next(time.Now())
s.tasks = append(s.tasks, t)
s.log.Info("task scheduled",
slog.String("task", t.job.Name),
slog.String("queue", t.queue),
slog.Time("next_at", t.nextRun),
)
}
// Serve starts all registered queues and the scheduler loop, then blocks until
// ctx is cancelled. On cancellation it waits up to DrainTimeout for in-flight
// workers to finish before returning nil.
func (s *Supervisor) Serve(ctx context.Context) error {
s.mu.Lock()
queueNames := make([]string, 0, len(s.queues))
for name := range s.queues {
queueNames = append(queueNames, name)
}
taskNames := make([]string, 0, len(s.tasks))
for _, t := range s.tasks {
taskNames = append(taskNames, t.job.Name)
}
s.mu.Unlock()
s.log.Info("supervisor starting",
slog.Any("queues", queueNames),
slog.Any("tasks", taskNames),
)
// workerCtx is cancelled on shutdown to signal all workers to stop.
workerCtx, cancelWorkers := context.WithCancel(ctx)
defer cancelWorkers()
// wg tracks all running worker goroutines across all queues.
var wg sync.WaitGroup
s.mu.Lock()
for _, q := range s.queues {
q.start(workerCtx, &wg)
}
s.mu.Unlock()
go s.schedulerLoop(workerCtx)
// Block until the parent context is cancelled.
<-ctx.Done()
s.log.Info("supervisor shutting down",
slog.String("reason", ctx.Err().Error()),
slog.Duration("drain_timeout", s.drainTimeout),
)
cancelWorkers()
// Wait for all workers to finish, bounded by DrainTimeout.
drained := make(chan struct{})
go func() {
wg.Wait()
close(drained)
}()
select {
case <-drained:
s.log.Info("supervisor stopped: all workers finished")
case <-time.After(s.drainTimeout):
s.log.Warn("supervisor stopped: drain timeout exceeded",
slog.Duration("drain_timeout", s.drainTimeout),
)
}
return nil
}
// --------------------------------------------------------------------------
// Scheduler loop
// --------------------------------------------------------------------------
func (s *Supervisor) schedulerLoop(ctx context.Context) {
s.log.Debug("scheduler loop started")
defer s.log.Debug("scheduler loop stopped")
for {
s.mu.Lock()
next := s.earliestFire()
s.mu.Unlock()
var timer *time.Timer
if next.IsZero() {
// No tasks registered yet; check back in an hour.
timer = time.NewTimer(time.Hour)
} else {
d := time.Until(next)
if d < 0 {
d = 0
}
timer = time.NewTimer(d)
s.log.Debug("scheduler waiting",
slog.Time("next_fire", next),
slog.Duration("in", d),
)
}
select {
case <-ctx.Done():
timer.Stop()
return
case now := <-timer.C:
s.fireDue(ctx, now)
}
}
}
// earliestFire returns the soonest nextRun across all tasks.
// Caller must hold s.mu.
func (s *Supervisor) earliestFire() time.Time {
var earliest time.Time
for _, t := range s.tasks {
if earliest.IsZero() || t.nextRun.Before(earliest) {
earliest = t.nextRun
}
}
return earliest
}
// fireDue runs all tasks whose nextRun is at or before now, then reschedules them.
func (s *Supervisor) fireDue(ctx context.Context, now time.Time) {
s.mu.Lock()
var due []*scheduledTask
for _, t := range s.tasks {
if !t.nextRun.After(now) {
due = append(due, t)
t.nextRun = t.schedule.Next(now)
}
}
s.mu.Unlock()
for _, t := range due {
t := t
s.log.Debug("task fired",
slog.String("task", t.job.Name),
slog.String("queue", t.queue),
slog.Time("fired_at", now),
slog.Time("next_at", t.nextRun),
)
if t.queue != "" {
if err := s.Dispatch(t.queue, t.job); err != nil {
s.log.Warn("scheduled task dispatch failed",
slog.String("task", t.job.Name),
slog.String("queue", t.queue),
slog.Any("error", err),
)
}
continue
}
// Run directly in a goroutine — not bound to any queue.
go s.runDirect(ctx, t.job)
}
}
// runDirect executes a job in its own goroutine, retrying up to job.Retries
// times on failure. A panic aborts the job immediately without retrying.
func (s *Supervisor) runDirect(ctx context.Context, job Job) {
log := s.log
if job.ID != "" {
log = s.log.With(slog.String("job_id", job.ID))
}
start := time.Now()
log.Debug("direct task started", slog.String("task", job.Name))
defer func() {
if r := recover(); r != nil {
log.Error("direct task panicked",
slog.String("task", job.Name),
slog.Any("panic", r),
)
}
}()
var err error
for attempt := 0; attempt <= job.Retries; attempt++ {
if attempt > 0 {
if !sleepRetry(ctx, job.RetryDelay) {
return
}
log.Debug("direct task retrying",
slog.String("task", job.Name),
slog.Int("attempt", attempt+1),
slog.Int("of", job.Retries+1),
)
}
err = job.Run(ctx)
if err == nil {
break
}
}
dur := time.Since(start)
if err != nil {
log.Warn("direct task finished with error",
slog.String("task", job.Name),
slog.Duration("duration", dur),
slog.Any("error", err),
)
return
}
log.Debug("direct task finished",
slog.String("task", job.Name),
slog.Duration("duration", dur),
)
}