feat(updater): FIFO queue with single worker and per-job results
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/discovery"
|
||||
)
|
||||
|
||||
// Status describes the outcome of executing a single Job.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusUpdated Status = "updated"
|
||||
StatusFailed Status = "failed"
|
||||
StatusRefused Status = "refused"
|
||||
StatusTimeout Status = "timeout"
|
||||
)
|
||||
|
||||
// Result captures a per-Job outcome to be surfaced in the HTTP response.
|
||||
type Result struct {
|
||||
Job discovery.Job
|
||||
Status Status
|
||||
Error string
|
||||
DurationMs int64
|
||||
}
|
||||
|
||||
// Queue serialises Job execution through a single worker so we never
|
||||
// run two `docker compose` commands against the same stack concurrently.
|
||||
// It also satisfies the "single global FIFO worker" design choice in
|
||||
// spec section 5.7.
|
||||
type Queue struct {
|
||||
exec Executor
|
||||
ch chan submission
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type submission struct {
|
||||
ctx context.Context
|
||||
jobs []discovery.Job
|
||||
results chan []Result
|
||||
}
|
||||
|
||||
// NewQueue constructs a queue bound to the given executor.
|
||||
func NewQueue(exec Executor) *Queue {
|
||||
return &Queue{
|
||||
exec: exec,
|
||||
ch: make(chan submission, 16),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the single background worker. Call Stop to terminate.
|
||||
func (q *Queue) Start(ctx context.Context) {
|
||||
go q.run(ctx)
|
||||
}
|
||||
|
||||
// Stop signals the worker to exit and waits for it to finish.
|
||||
func (q *Queue) Stop() {
|
||||
close(q.stop)
|
||||
<-q.done
|
||||
}
|
||||
|
||||
// Submit blocks until all jobs in the batch have been processed by the
|
||||
// worker, then returns per-job results in the same order. The ctx
|
||||
// timeout (if any) applies to each individual Job's execution and is
|
||||
// surfaced as StatusTimeout.
|
||||
func (q *Queue) Submit(ctx context.Context, jobs []discovery.Job) []Result {
|
||||
resCh := make(chan []Result, 1)
|
||||
q.ch <- submission{ctx: ctx, jobs: jobs, results: resCh}
|
||||
return <-resCh
|
||||
}
|
||||
|
||||
func (q *Queue) run(_ context.Context) {
|
||||
defer close(q.done)
|
||||
for {
|
||||
select {
|
||||
case <-q.stop:
|
||||
return
|
||||
case s := <-q.ch:
|
||||
results := make([]Result, len(s.jobs))
|
||||
for i, j := range s.jobs {
|
||||
results[i] = q.runOne(s.ctx, j)
|
||||
}
|
||||
s.results <- results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Queue) runOne(ctx context.Context, job discovery.Job) Result {
|
||||
start := time.Now()
|
||||
r := Result{Job: job}
|
||||
|
||||
if job.Refused {
|
||||
r.Status = StatusRefused
|
||||
r.Error = job.RefusedReason
|
||||
r.DurationMs = time.Since(start).Milliseconds()
|
||||
return r
|
||||
}
|
||||
|
||||
err := q.exec.Execute(ctx, job)
|
||||
r.DurationMs = time.Since(start).Milliseconds()
|
||||
switch {
|
||||
case err == nil:
|
||||
r.Status = StatusUpdated
|
||||
case errorsIsContextDeadline(err) || ctxDeadlineExceeded(ctx):
|
||||
r.Status = StatusTimeout
|
||||
r.Error = err.Error()
|
||||
default:
|
||||
r.Status = StatusFailed
|
||||
r.Error = err.Error()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func errorsIsContextDeadline(err error) bool {
|
||||
return err == context.DeadlineExceeded
|
||||
}
|
||||
|
||||
func ctxDeadlineExceeded(ctx context.Context) bool {
|
||||
return ctx.Err() == context.DeadlineExceeded
|
||||
}
|
||||
Reference in New Issue
Block a user