feat(uploads): a bounded queue, in total and per user (review F6)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 16:47:49 +02:00
co-authored by Claude Sonnet 5.5
parent a1d6754ae9
commit f9ad85cac4
+27
View File
@@ -17,6 +17,11 @@ import (
// ErrStopped is returned by Submit once the worker has stopped. // ErrStopped is returned by Submit once the worker has stopped.
var ErrStopped = errors.New("uploads: worker stopped") var ErrStopped = errors.New("uploads: worker stopped")
// ErrQueueFull is returned by Submit when the queue, or one user's share of it, is at
// its cap. Every queued job holds up to the upload cap on disk until its turn, so an
// unbounded queue is a disk-filling lever (ADR-035 review).
var ErrQueueFull = errors.New("uploads: queue full")
// Converter turns the uploaded file into the 16 kHz mono PCM WAV whisper wants. // Converter turns the uploaded file into the 16 kHz mono PCM WAV whisper wants.
type Converter interface { type Converter interface {
ToWAV(ctx context.Context, src, dst string) error ToWAV(ctx context.Context, src, dst string) error
@@ -54,6 +59,12 @@ func WithHeartbeat(beat, still time.Duration) WorkerOption {
return func(w *Worker) { w.beatEvery, w.stillEvery = beat, still } return func(w *Worker) { w.beatEvery, w.stillEvery = beat, still }
} }
// WithQueueLimits bounds how many jobs may WAIT: in total, and per user. A job being
// processed is not waiting. The defaults are 20 and 3 (one instance, one job at a time).
func WithQueueLimits(total, perUser int) WorkerOption {
return func(w *Worker) { w.maxQueued, w.maxPerUser = total, perUser }
}
// WithMaxDuration makes the worker measure the converted audio and fail a job whose real // WithMaxDuration makes the worker measure the converted audio and fail a job whose real
// length exceeds the limit, or falls far short of what the file claimed. Zero (the // length exceeds the limit, or falls far short of what the file claimed. Zero (the
// default) measures nothing. The limit is first checked against the file's header, which // default) measures nothing. The limit is first checked against the file's header, which
@@ -64,6 +75,8 @@ func WithMaxDuration(d time.Duration) WorkerOption { return func(w *Worker) { w.
func WithLogger(l *slog.Logger) WorkerOption { return func(w *Worker) { w.log = l } } func WithLogger(l *slog.Logger) WorkerOption { return func(w *Worker) { w.log = l } }
const ( const (
defaultMaxQueued = 20
defaultMaxPerUser = 3
defaultBeatEvery = 10 * time.Second defaultBeatEvery = 10 * time.Second
defaultStillEvery = 5 * time.Minute defaultStillEvery = 5 * time.Minute
) )
@@ -95,6 +108,7 @@ type Worker struct {
log *slog.Logger log *slog.Logger
beatEvery, stillEvery time.Duration beatEvery, stillEvery time.Duration
maxDuration time.Duration maxDuration time.Duration
maxQueued, maxPerUser int
mu sync.Mutex mu sync.Mutex
queue []Job queue []Job
@@ -108,6 +122,7 @@ func NewWorker(s WorkerStore, c Converter, t Transcriber, tmpDir string, timeout
w := &Worker{ w := &Worker{
store: s, conv: c, tr: t, tmpDir: tmpDir, timeout: timeout, wake: make(chan struct{}, 1), store: s, conv: c, tr: t, tmpDir: tmpDir, timeout: timeout, wake: make(chan struct{}, 1),
beatEvery: defaultBeatEvery, stillEvery: defaultStillEvery, beatEvery: defaultBeatEvery, stillEvery: defaultStillEvery,
maxQueued: defaultMaxQueued, maxPerUser: defaultMaxPerUser,
} }
for _, o := range opts { for _, o := range opts {
o(w) o(w)
@@ -122,6 +137,18 @@ func (w *Worker) Submit(j Job) error {
if w.stopped { if w.stopped {
return ErrStopped return ErrStopped
} }
if len(w.queue) >= w.maxQueued {
return ErrQueueFull
}
mine := 0
for _, q := range w.queue {
if q.UserID == j.UserID {
mine++
}
}
if mine >= w.maxPerUser {
return ErrQueueFull
}
w.queue = append(w.queue, j) w.queue = append(w.queue, j)
select { select {
case w.wake <- struct{}{}: case w.wake <- struct{}{}: