Compare commits
10
Commits
d44db88787
...
3e69c00a64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e69c00a64 | ||
|
|
253aa567aa | ||
|
|
5644dc3fc8 | ||
|
|
7df1de6628 | ||
|
|
7e920102f6 | ||
|
|
480c7227b5 | ||
|
|
0eb3be9631 | ||
|
|
e8c5ac50bf | ||
|
|
6101dd4e2f | ||
|
|
089444c19f |
@@ -1447,6 +1447,16 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
|
|||||||
re-uploads. The single-replica assumption gains a second dependent (the semaphore is
|
re-uploads. The single-replica assumption gains a second dependent (the semaphore is
|
||||||
process-local) and is recorded here.
|
process-local) and is recorded here.
|
||||||
|
|
||||||
|
*Amended 2026-10-03 (#50, #47).* The process now handles SIGTERM (what Kubernetes sends on
|
||||||
|
every deploy; only SIGINT was handled, so shutdown code never ran). On shutdown the running job
|
||||||
|
is recorded `interrupted` at once and the process waits, bounded to 10 s each, for in-flight
|
||||||
|
requests and for that write; the start-up sweep stays the backstop for a kill. **Known
|
||||||
|
limitation, deliberately not built: a deploy still loses a job in flight** (finishing it needs
|
||||||
|
a drain of up to the job time, a ~330 s termination grace and `strategy: Recreate`, i.e.
|
||||||
|
downtime on every deploy; a 90-minute file takes ~4.5 min and a deploy lands on a running job
|
||||||
|
perhaps one time in five at current volume). Jobs still queued at shutdown stay `queued` until
|
||||||
|
the next start's sweep. The user re-uploads, with an honest message.
|
||||||
|
|
||||||
8. **Chat reuses `chat.Service`; it is not forked.** The uploaded-transcript chat handlers
|
8. **Chat reuses `chat.Service`; it is not forked.** The uploaded-transcript chat handlers
|
||||||
look the row up through the RLS-scoped store, so a non-owner gets 404 by the same mechanism
|
look the row up through the RLS-scoped store, so a non-owner gets 404 by the same mechanism
|
||||||
as ADR-027's summary gate, **before any model is called**. ADR-027's decisions (ephemeral,
|
as ADR-027's summary gate, **before any model is called**. ADR-027's decisions (ephemeral,
|
||||||
|
|||||||
+15
-14
@@ -17,9 +17,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := shutdownContext(context.Background())
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
@@ -215,9 +215,11 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
var workerDone chan struct{} // nil without uploads: nothing to wait for at shutdown
|
||||||
wireUploads(app, cfg, intake)
|
wireUploads(app, cfg, intake)
|
||||||
if intake != nil {
|
if intake != nil {
|
||||||
go worker.Run(ctx)
|
workerDone = make(chan struct{})
|
||||||
|
go func() { worker.Run(ctx); close(workerDone) }()
|
||||||
log.Info("audio upload enabled", "model", cfg.TranscribeModel, "fallback", cfg.TranscribeFallbackModel,
|
log.Info("audio upload enabled", "model", cfg.TranscribeModel, "fallback", cfg.TranscribeFallbackModel,
|
||||||
"max_mb", cfg.UploadMaxBytes>>20, "max_minutes", int(cfg.UploadMaxDuration.Minutes()))
|
"max_mb", cfg.UploadMaxBytes>>20, "max_minutes", int(cfg.UploadMaxDuration.Minutes()))
|
||||||
log.Warn("audio upload assumes a SINGLE replica: the transcription queue is process-local (ADR-033, ADR-018)")
|
log.Warn("audio upload assumes a SINGLE replica: the transcription queue is process-local (ADR-033, ADR-018)")
|
||||||
@@ -350,20 +352,19 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown on signal: stop accepting, drain in-flight requests.
|
// Graceful shutdown on signal: stop accepting, drain in-flight requests, and let the
|
||||||
go func() {
|
// worker record the job it was running as interrupted (#50).
|
||||||
<-ctx.Done()
|
var extra []*http.Server
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
_ = srv.Shutdown(shutdownCtx)
|
|
||||||
if metricsSrv != nil {
|
if metricsSrv != nil {
|
||||||
_ = metricsSrv.Shutdown(shutdownCtx)
|
extra = append(extra, metricsSrv)
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("tcp", srv.Addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serve: listen: %w", err)
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
log.Info("serving web ui", "addr", cfg.HTTPAddr, "user", cfg.UserID)
|
log.Info("serving web ui", "addr", cfg.HTTPAddr, "user", cfg.UserID)
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := serveHTTP(ctx, srv, ln, extra, workerDone, shutdownGrace); err != nil {
|
||||||
return fmt.Errorf("serve: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shutdownGrace bounds each wait at shutdown (in-flight requests, then the worker). Two of
|
||||||
|
// them stay inside Kubernetes' default 30 s termination grace.
|
||||||
|
const shutdownGrace = 10 * time.Second
|
||||||
|
|
||||||
|
// shutdownContext is cancelled by SIGINT (a terminal) or SIGTERM (what Kubernetes sends
|
||||||
|
// on every deploy). Handling only SIGINT meant a deploy killed the process before any
|
||||||
|
// shutdown code ran (#50).
|
||||||
|
func shutdownContext(parent context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
return signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveHTTP serves srv on ln until it fails or ctx ends, then shuts every server down and
|
||||||
|
// waits for in-flight requests and for workerDone (nil: no worker) to finish, each for at
|
||||||
|
// most grace. It must not return earlier: Serve returns http.ErrServerClosed as soon as
|
||||||
|
// Shutdown STARTS, and returning from main kills the drain and the worker's last write.
|
||||||
|
func serveHTTP(ctx context.Context, srv *http.Server, ln net.Listener, extra []*http.Server, workerDone <-chan struct{}, grace time.Duration) error {
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() { errc <- srv.Serve(ln) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errc:
|
||||||
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return fmt.Errorf("serve: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), grace)
|
||||||
|
defer cancel()
|
||||||
|
for _, s := range append([]*http.Server{srv}, extra...) {
|
||||||
|
_ = s.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
if workerDone != nil {
|
||||||
|
select {
|
||||||
|
case <-workerDone:
|
||||||
|
case <-time.After(grace):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kubernetes stops a pod with SIGTERM; only SIGINT was handled, so a deploy killed the
|
||||||
|
// process without running any shutdown code (#50).
|
||||||
|
func TestShutdownContext_IsCancelledBySIGTERMAndSIGINT(t *testing.T) {
|
||||||
|
for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGINT} {
|
||||||
|
t.Run(sig.String(), func(t *testing.T) {
|
||||||
|
ctx, stop := shutdownContext(context.Background())
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if err := syscall.Kill(os.Getpid(), sig); err != nil {
|
||||||
|
t.Fatalf("send %v: %v", sig, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("context not cancelled by %v", sig)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveHTTP must not return while a request is in flight or the worker is still recording
|
||||||
|
// its job: ListenAndServe returns ErrServerClosed the moment Shutdown starts, so returning
|
||||||
|
// from it let the process exit before either finished (#50).
|
||||||
|
func TestServeHTTP_ReturnsOnlyAfterTheInFlightRequestAndTheWorkerHaveFinished(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
entered := make(chan struct{})
|
||||||
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
srv.Start()
|
||||||
|
defer srv.Close()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
workerDone := make(chan struct{})
|
||||||
|
returned := make(chan error, 1)
|
||||||
|
go func() { returned <- serveHTTP(ctx, srv.Config, srv.Listener, nil, workerDone, 5*time.Second) }()
|
||||||
|
|
||||||
|
go func() { _, _ = http.Get(srv.URL) }()
|
||||||
|
<-entered
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
assertPending := func(why string) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case err := <-returned:
|
||||||
|
t.Fatalf("serveHTTP returned (%v) %s", err, why)
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertPending("while a request was still in flight")
|
||||||
|
close(release)
|
||||||
|
assertPending("before the worker finished")
|
||||||
|
close(workerDone)
|
||||||
|
select {
|
||||||
|
case err := <-returned:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("serveHTTP: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("serveHTTP did not return once the request and the worker had finished")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeHTTP_GivesUpOnAWorkerThatNeverFinishesAfterTheGrace(t *testing.T) {
|
||||||
|
srv := httptest.NewUnstartedServer(http.NotFoundHandler())
|
||||||
|
srv.Start()
|
||||||
|
defer srv.Close()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
returned := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
returned <- serveHTTP(ctx, srv.Config, srv.Listener, nil, make(chan struct{}), 200*time.Millisecond)
|
||||||
|
}()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-returned:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("a stuck worker must not hold the process past the grace period")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -19,6 +20,51 @@ import (
|
|||||||
type FFprobe struct {
|
type FFprobe struct {
|
||||||
// Bin is the ffprobe executable ("ffprobe" resolves via PATH).
|
// Bin is the ffprobe executable ("ffprobe" resolves via PATH).
|
||||||
Bin string
|
Bin string
|
||||||
|
// MaxOutput bounds how much of ffprobe's stdout is read; zero means DefaultMaxOutput.
|
||||||
|
MaxOutput int
|
||||||
|
// Timeout bounds one probe; zero means DefaultProbeTimeout. A probe is milliseconds of
|
||||||
|
// work on a real file, so one that runs this long is the FILE's doing (#49).
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultProbeTimeout is the probe deadline when FFprobe.Timeout is unset.
|
||||||
|
const DefaultProbeTimeout = 45 * time.Second
|
||||||
|
|
||||||
|
// DefaultMaxOutput is far above the narrowed probe output of a legitimate file (a few
|
||||||
|
// hundred bytes) and far below what a hostile one can make ffprobe print.
|
||||||
|
const DefaultMaxOutput = 1 << 20
|
||||||
|
|
||||||
|
// maxStderr is how much of ffprobe's stderr is kept for the error message.
|
||||||
|
const maxStderr = 4096
|
||||||
|
|
||||||
|
// errOutputCap is the write error that stops ffprobe once it prints past the cap.
|
||||||
|
var errOutputCap = errors.New("audio: ffprobe output exceeds the cap")
|
||||||
|
|
||||||
|
// capWriter collects up to max bytes and then refuses further writes, which closes the
|
||||||
|
// pipe and ends the process instead of buffering whatever it prints.
|
||||||
|
type capWriter struct {
|
||||||
|
buf bytes.Buffer
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *capWriter) Write(p []byte) (int, error) {
|
||||||
|
if w.buf.Len()+len(p) > w.max {
|
||||||
|
return 0, errOutputCap
|
||||||
|
}
|
||||||
|
return w.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// headWriter keeps the first max bytes and silently drops the rest.
|
||||||
|
type headWriter struct {
|
||||||
|
buf bytes.Buffer
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *headWriter) Write(p []byte) (int, error) {
|
||||||
|
if room := w.max - w.buf.Len(); room > 0 {
|
||||||
|
w.buf.Write(p[:min(room, len(p))])
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowedDemuxers are the input formats an upload may be. The input is
|
// AllowedDemuxers are the input formats an upload may be. The input is
|
||||||
@@ -62,20 +108,37 @@ func (f *FFprobe) Probe(ctx context.Context, path string) (uploads.Probe, error)
|
|||||||
}
|
}
|
||||||
args := append([]string{"-v", "error"}, inputRestrictions...)
|
args := append([]string{"-v", "error"}, inputRestrictions...)
|
||||||
args = append(args, "-print_format", "json", "-show_entries", probeEntries, path)
|
args = append(args, "-print_format", "json", "-show_entries", probeEntries, path)
|
||||||
cmd := exec.CommandContext(ctx, bin, args...)
|
timeout := f.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = DefaultProbeTimeout
|
||||||
|
}
|
||||||
|
probeCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(probeCtx, bin, args...)
|
||||||
cmd.WaitDelay = killGrace
|
cmd.WaitDelay = killGrace
|
||||||
out, err := cmd.Output()
|
maxOut := f.MaxOutput
|
||||||
if err != nil {
|
if maxOut <= 0 {
|
||||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
maxOut = DefaultMaxOutput
|
||||||
|
}
|
||||||
|
stdout, stderr := &capWriter{max: maxOut}, &headWriter{max: maxStderr}
|
||||||
|
cmd.Stdout, cmd.Stderr = stdout, stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil { // the caller gave up: not a verdict on the file
|
||||||
return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr)
|
return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr)
|
||||||
}
|
}
|
||||||
|
if probeCtx.Err() != nil { // our own deadline passed while the caller was still waiting
|
||||||
|
return uploads.Probe{}, fmt.Errorf("%w: ffprobe did not finish within %s", uploads.ErrUnreadable, timeout)
|
||||||
|
}
|
||||||
|
if errors.Is(err, errOutputCap) {
|
||||||
|
return uploads.Probe{}, fmt.Errorf("%w: ffprobe printed more than %d bytes", uploads.ErrUnreadable, maxOut)
|
||||||
|
}
|
||||||
var exit *exec.ExitError
|
var exit *exec.ExitError
|
||||||
if errors.As(err, &exit) {
|
if errors.As(err, &exit) {
|
||||||
return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, exit.Stderr)
|
return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, stderr.buf.String())
|
||||||
}
|
}
|
||||||
return uploads.Probe{}, fmt.Errorf("audio: run ffprobe: %w", err)
|
return uploads.Probe{}, fmt.Errorf("audio: run ffprobe: %w", err)
|
||||||
}
|
}
|
||||||
p, err := ParseProbe(out)
|
p, err := ParseProbe(stdout.buf.Bytes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uploads.Probe{}, fmt.Errorf("audio: ffprobe output: %w", err)
|
return uploads.Probe{}, fmt.Errorf("audio: ffprobe output: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,3 +234,28 @@ func TestFFprobeProbe_OutputWithinTheCapIsParsed(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, got.HasAudio)
|
assert.True(t, got.HasAudio)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFFprobeProbe_AHungProbeTimesOutOnItsOwnDeadlineAsUnreadableNotAsOurFault(t *testing.T) {
|
||||||
|
// Accept runs the probe on the request context, which has no deadline of its own;
|
||||||
|
// a probe that never returns would pin a request (#49). `exec` so the sleep is the
|
||||||
|
// process itself and not a child holding the pipe open.
|
||||||
|
bin := stub(t, `exec sleep 30`)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := (&audio.FFprobe{Bin: bin, Timeout: 200 * time.Millisecond}).Probe(context.Background(), "/x")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, uploads.ErrUnreadable)
|
||||||
|
assert.Less(t, time.Since(start), 3*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFFprobeProbe_ParentCancellationIsStillNotUnreadableWhenATimeoutIsSet(t *testing.T) {
|
||||||
|
bin := stub(t, `exec sleep 30`)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := (&audio.FFprobe{Bin: bin, Timeout: time.Minute}).Probe(ctx, "/x")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.False(t, errors.Is(err, uploads.ErrUnreadable), "the caller giving up is not a verdict on the file")
|
||||||
|
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
@@ -51,6 +52,53 @@ type Intake struct {
|
|||||||
sub Submitter
|
sub Submitter
|
||||||
limits Limits
|
limits Limits
|
||||||
tmpDir string
|
tmpDir string
|
||||||
|
|
||||||
|
// MaxReceives and MaxReceivesPerUser bound how many request bodies Accept reads at
|
||||||
|
// once; zero means the defaults. Without them N parallel POSTs each spool up to
|
||||||
|
// MaxBytes while the queue still looks empty, and the spool volume fills (#47).
|
||||||
|
MaxReceives int
|
||||||
|
MaxReceivesPerUser int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
receives int
|
||||||
|
byUser map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMaxReceives = 4
|
||||||
|
defaultMaxReceivesPerUser = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// acquireReceive reserves a receive slot for userID, or reports there is none. Every
|
||||||
|
// successful call must be paired with releaseReceive.
|
||||||
|
func (in *Intake) acquireReceive(userID string) bool {
|
||||||
|
maxAll, maxUser := in.MaxReceives, in.MaxReceivesPerUser
|
||||||
|
if maxAll <= 0 {
|
||||||
|
maxAll = defaultMaxReceives
|
||||||
|
}
|
||||||
|
if maxUser <= 0 {
|
||||||
|
maxUser = defaultMaxReceivesPerUser
|
||||||
|
}
|
||||||
|
in.mu.Lock()
|
||||||
|
defer in.mu.Unlock()
|
||||||
|
if in.receives >= maxAll || in.byUser[userID] >= maxUser {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if in.byUser == nil {
|
||||||
|
in.byUser = map[string]int{}
|
||||||
|
}
|
||||||
|
in.receives++
|
||||||
|
in.byUser[userID]++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (in *Intake) releaseReceive(userID string) {
|
||||||
|
in.mu.Lock()
|
||||||
|
defer in.mu.Unlock()
|
||||||
|
in.receives--
|
||||||
|
if in.byUser[userID]--; in.byUser[userID] <= 0 {
|
||||||
|
delete(in.byUser, userID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIntake wires an Intake. tmpDir is where uploads are spooled; it must exist.
|
// NewIntake wires an Intake. tmpDir is where uploads are spooled; it must exist.
|
||||||
@@ -94,6 +142,11 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reade
|
|||||||
if rc, ok := in.sub.(interface{ HasRoom(userID string) bool }); ok && !rc.HasRoom(userID) {
|
if rc, ok := in.sub.(interface{ HasRoom(userID string) bool }); ok && !rc.HasRoom(userID) {
|
||||||
return "", &RejectError{Reason: ReasonBusy, Message: busyMessage}
|
return "", &RejectError{Reason: ReasonBusy, Message: busyMessage}
|
||||||
}
|
}
|
||||||
|
// HasRoom cannot see bodies still being received; bound those separately.
|
||||||
|
if !in.acquireReceive(userID) {
|
||||||
|
return "", &RejectError{Reason: ReasonBusy, Message: busyMessage}
|
||||||
|
}
|
||||||
|
defer in.releaseReceive(userID)
|
||||||
f, err := os.CreateTemp(in.tmpDir, "upload-*")
|
f, err := os.CreateTemp(in.tmpDir, "upload-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("uploads: spool: %w", err)
|
return "", fmt.Errorf("uploads: spool: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package uploads_test
|
||||||
|
|
||||||
|
// Observes how many request bodies Accept will read at once (#47). HasRoom and the
|
||||||
|
// per-user queue cap act before and after the receive; N parallel POSTs all pass
|
||||||
|
// HasRoom while the queue is still empty, so the receive itself is bounded.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/tapir/internal/uploads"
|
||||||
|
)
|
||||||
|
|
||||||
|
// slowReceive starts an Accept whose body stays open until the returned writer is closed,
|
||||||
|
// and waits until it holds a spool file, which proves it is inside the receive.
|
||||||
|
type slowReceive struct {
|
||||||
|
w *io.PipeWriter
|
||||||
|
done chan error
|
||||||
|
finished bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func startSlowReceive(t *testing.T, r *rig, in *uploads.Intake, user string, wantFiles int) *slowReceive {
|
||||||
|
t.Helper()
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
s := &slowReceive{w: pw, done: make(chan error, 1)}
|
||||||
|
go func() {
|
||||||
|
_, err := in.Accept(context.Background(), user, "t", pr)
|
||||||
|
s.done <- err
|
||||||
|
}()
|
||||||
|
require.Eventually(t, func() bool { return len(r.tmpFiles(t)) == wantFiles }, 2*time.Second, 5*time.Millisecond,
|
||||||
|
"the receive for %s should have started", user)
|
||||||
|
// An abandoned receive ends in a read error, so it never reaches the rig's fakes, which
|
||||||
|
// are not safe for concurrent use. A receive that finish() already ended is a no-op.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if s.finished {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = pw.CloseWithError(errors.New("test over"))
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *slowReceive) finish(t *testing.T) error {
|
||||||
|
t.Helper()
|
||||||
|
_, _ = s.w.Write([]byte("data"))
|
||||||
|
require.NoError(t, s.w.Close())
|
||||||
|
s.finished = true
|
||||||
|
select {
|
||||||
|
case err := <-s.done:
|
||||||
|
return err
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Accept did not return after its body ended")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireBusy(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
var rej *uploads.RejectError
|
||||||
|
require.True(t, errors.As(err, &rej), "want a busy rejection, got %v", err)
|
||||||
|
assert.Equal(t, uploads.ReasonBusy, rej.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccept_APerUserLimitOnConcurrentReceivesRefusesTheNextBodyBeforeSpoolingIt(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
in := uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: time.Hour}, r.tmp)
|
||||||
|
in.MaxReceivesPerUser = 2
|
||||||
|
in.MaxReceives = 10
|
||||||
|
startSlowReceive(t, r, in, "user-1", 1)
|
||||||
|
startSlowReceive(t, r, in, "user-1", 2)
|
||||||
|
|
||||||
|
_, err := in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
|
||||||
|
|
||||||
|
requireBusy(t, err)
|
||||||
|
assert.Len(t, r.tmpFiles(t), 2, "the refused body was never spooled")
|
||||||
|
// Another user is not blocked by user-1's two receives.
|
||||||
|
other := startSlowReceive(t, r, in, "user-2", 3)
|
||||||
|
require.NoError(t, other.finish(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccept_AnOverallLimitOnConcurrentReceivesRefusesTheNextUser(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
in := uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: time.Hour}, r.tmp)
|
||||||
|
in.MaxReceivesPerUser = 5
|
||||||
|
in.MaxReceives = 3
|
||||||
|
for i, u := range []string{"a", "b", "c"} {
|
||||||
|
startSlowReceive(t, r, in, u, i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := in.Accept(context.Background(), "d", "t", strings.NewReader("data"))
|
||||||
|
|
||||||
|
requireBusy(t, err)
|
||||||
|
assert.Len(t, r.tmpFiles(t), 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccept_AFinishedReceiveFreesItsSlot(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
in := uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: time.Hour}, r.tmp)
|
||||||
|
in.MaxReceivesPerUser = 1
|
||||||
|
in.MaxReceives = 5
|
||||||
|
first := startSlowReceive(t, r, in, "user-1", 1)
|
||||||
|
require.NoError(t, first.finish(t))
|
||||||
|
|
||||||
|
_, err := in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
|
||||||
|
|
||||||
|
require.NoError(t, err, "the slot is released when Accept returns")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccept_AFailedReceiveFreesItsSlot(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
in := uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: time.Hour}, r.tmp)
|
||||||
|
in.MaxReceivesPerUser = 1
|
||||||
|
in.MaxReceives = 5
|
||||||
|
_, err := in.Accept(context.Background(), "user-1", "t", &brokenBody{n: 1})
|
||||||
|
require.ErrorIs(t, err, uploads.ErrClientRead)
|
||||||
|
|
||||||
|
_, err = in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
|
||||||
|
|
||||||
|
require.NoError(t, err, "a dropped client must not leak a slot")
|
||||||
|
}
|
||||||
@@ -176,9 +176,10 @@ func (w *Worker) Submit(j Job) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run processes queued jobs until ctx ends, then deletes the audio of anything
|
// Run processes queued jobs until ctx ends, then deletes the audio of anything
|
||||||
// still queued and refuses new jobs. The rows of those jobs stay queued or
|
// still queued and refuses new jobs. The job running at shutdown is recorded interrupted
|
||||||
// transcribing and are failed as interrupted by the start-up sweep
|
// by Process; the rows of jobs still queued stay queued and are failed as interrupted by
|
||||||
// (store.FailInterruptedUploads): the audio is not retained, so they cannot resume.
|
// the start-up sweep (store.FailInterruptedUploads): the audio is not retained, so they
|
||||||
|
// cannot resume.
|
||||||
func (w *Worker) Run(ctx context.Context) {
|
func (w *Worker) Run(ctx context.Context) {
|
||||||
defer w.stop()
|
defer w.stop()
|
||||||
for {
|
for {
|
||||||
@@ -227,7 +228,7 @@ func (w *Worker) stop() {
|
|||||||
// Process runs one job: row -> transcribing, convert, transcribe, store. The
|
// Process runs one job: row -> transcribing, convert, transcribe, store. The
|
||||||
// uploaded file and the converted WAV are deleted on every path — the audio is
|
// uploaded file and the converted WAV are deleted on every path — the audio is
|
||||||
// never retained (ADR-033). A failure is recorded with a TYPED kind; a shutdown
|
// never retained (ADR-033). A failure is recorded with a TYPED kind; a shutdown
|
||||||
// mid-job is not a failure and leaves the row for the start-up sweep.
|
// mid-job is recorded as interrupted at once (see fail).
|
||||||
//
|
//
|
||||||
// Every phase is reported through one progress.Tracker, which saves it to the row for
|
// Every phase is reported through one progress.Tracker, which saves it to the row for
|
||||||
// the page and logs it, and a heartbeat stamps the row on its own while the job runs
|
// the page and logs it, and a heartbeat stamps the row on its own while the job runs
|
||||||
@@ -269,7 +270,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
|
|||||||
defer func() { stopBeat(); <-hbDone }() // no heartbeat outlives its job
|
defer func() { stopBeat(); <-hbDone }() // no heartbeat outlives its job
|
||||||
|
|
||||||
// abort records where the job stopped. A shutdown (the parent context ended) is not
|
// abort records where the job stopped. A shutdown (the parent context ended) is not
|
||||||
// a failure: the row is left for the start-up sweep, so the step stays as it was.
|
// a failure of the step: it stays as it was, and fail records the row as interrupted.
|
||||||
abort := func(kind domain.UploadErrorKind) {
|
abort := func(kind domain.UploadErrorKind) {
|
||||||
if ctx.Err() == nil {
|
if ctx.Err() == nil {
|
||||||
tr.FailRunning()
|
tr.FailRunning()
|
||||||
@@ -332,11 +333,19 @@ func (w *Worker) Process(ctx context.Context, job Job) {
|
|||||||
"segments", len(t.Segments), "elapsed", time.Since(start).Round(time.Second))
|
"segments", len(t.Segments), "elapsed", time.Since(start).Round(time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
// fail records a transcription failure — unless the PARENT context ended, which
|
// interruptedWriteTimeout bounds the one write a dying process makes to record its job.
|
||||||
// is a shutdown, not a failure: the row is left for the start-up sweep.
|
const interruptedWriteTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// fail records a transcription failure. When the PARENT context ended the process is
|
||||||
|
// shutting down: the job is recorded interrupted at once, on a short context of its own,
|
||||||
|
// because the process may exit before any other pod's start-up sweep would do it (#50).
|
||||||
|
// The sweep remains the backstop for a process that is killed outright.
|
||||||
func (w *Worker) fail(ctx context.Context, job Job, log *slog.Logger, kind domain.UploadErrorKind) {
|
func (w *Worker) fail(ctx context.Context, job Job, log *slog.Logger, kind domain.UploadErrorKind) {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
kind = domain.UploadErrInterrupted
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(context.WithoutCancel(ctx), interruptedWriteTimeout)
|
||||||
|
defer cancel()
|
||||||
}
|
}
|
||||||
if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, kind); err != nil {
|
if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, kind); err != nil {
|
||||||
log.Error("upload: record failure", "kind", errkind.Of(err))
|
log.Error("upload: record failure", "kind", errkind.Of(err))
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ func (s *wStore) CompleteUpload(_ context.Context, _, id string, t domain.Transc
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *wStore) FailUpload(_ context.Context, _, id string, k domain.UploadErrorKind) error {
|
func (s *wStore) FailUpload(ctx context.Context, _, id string, k domain.UploadErrorKind) error {
|
||||||
|
if err := ctx.Err(); err != nil { // like the real store: a cancelled context cannot write
|
||||||
|
return err
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.failed[id] = k
|
s.failed[id] = k
|
||||||
@@ -242,7 +245,9 @@ func TestWorkerProcess_FailuresAreTypedAndStillDeleteTheAudio(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkerProcess_ShutdownMidJobLeavesTheRowForTheStartupSweep(t *testing.T) {
|
func TestWorkerProcess_ShutdownMidJobRecordsTheRowInterruptedAtOnce(t *testing.T) {
|
||||||
|
// SIGTERM on a deploy used to leave the row "transcribing" until the NEXT pod's
|
||||||
|
// start-up sweep; the process may exit before any sweep runs (#50).
|
||||||
r := newWRig(t, time.Minute)
|
r := newWRig(t, time.Minute)
|
||||||
r.tr.block = make(chan struct{})
|
r.tr.block = make(chan struct{})
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -255,8 +260,8 @@ func TestWorkerProcess_ShutdownMidJobLeavesTheRowForTheStartupSweep(t *testing.T
|
|||||||
<-done
|
<-done
|
||||||
|
|
||||||
_, f := r.st.snapshot()
|
_, f := r.st.snapshot()
|
||||||
assert.Empty(t, f, "a shutdown is not a transcription failure: the row stays for FailInterruptedUploads to mark interrupted")
|
assert.Equal(t, domain.UploadErrInterrupted, f["a"], "the row is recorded interrupted by the dying process, not left for a sweep")
|
||||||
assert.Empty(t, r.leftovers(t), "but the audio is still deleted")
|
assert.Empty(t, r.leftovers(t), "and the audio is still deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkerProcess_AJobThatOutlivesItsTimeoutIsFailedNotStuck(t *testing.T) {
|
func TestWorkerProcess_AJobThatOutlivesItsTimeoutIsFailedNotStuck(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user