diff --git a/internal/adapters/transcribe/timeout_test.go b/internal/adapters/transcribe/timeout_test.go new file mode 100644 index 0000000..fed4489 --- /dev/null +++ b/internal/adapters/transcribe/timeout_test.go @@ -0,0 +1,104 @@ +package transcribe_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.d-ma.be/mathias/tapir/internal/adapters/transcribe" + "git.d-ma.be/mathias/tapir/internal/domain" + "git.d-ma.be/mathias/tapir/internal/errkind" + "git.d-ma.be/mathias/tapir/internal/reconcile" +) + +// A wedged engine (iguana after a Metal OOM, infra#273) does not answer at all. With only +// the job's deadline, the primary used up the whole job before any fallback could run, and +// the chain then refused the fallback because the job context had expired. Each engine call +// gets its own deadline (adversarial review F4). + +type hangingBackend struct{ name string } + +func (h hangingBackend) Name() string { return h.name } +func (hangingBackend) Transcribe(ctx context.Context, _ string) (domain.Transcription, error) { + <-ctx.Done() + return domain.Transcription{}, ctx.Err() +} + +func TestWithTimeout_AHungBackendFailsAtItsOwnDeadlineWhileTheJobIsStillAlive(t *testing.T) { + job, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + b := transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, 40*time.Millisecond) + + start := time.Now() + _, err := b.Transcribe(job, "/a.wav") + + require.Error(t, err) + assert.Equal(t, "timeout", errkind.Of(err)) + assert.Less(t, time.Since(start), 2*time.Second, "it must give up at its own deadline, not wait for the job's") + assert.NoError(t, job.Err(), "the job context is untouched, which is what lets a fallback run") + assert.Equal(t, "iguana/kb-whisper", b.Name(), "the wrapper is invisible in the recorded endpoint") +} + +func TestWithTimeout_ASuccessPassesStraightThrough(t *testing.T) { + calls := &[]string{} + inner := &orderedBackend{name: "iguana/kb-whisper", text: "hej", calls: calls} + + got, err := transcribe.WithTimeout(inner, time.Minute).Transcribe(context.Background(), "/a.wav") + + require.NoError(t, err) + assert.Equal(t, "hej", got.Text) +} + +func TestWithTimeout_ZeroMeansNoDeadlineOfItsOwn(t *testing.T) { + inner := &orderedBackend{name: "x", text: "hej", calls: &[]string{}} + + assert.Same(t, inner, transcribe.WithTimeout(inner, 0), "a zero timeout must not wrap or change anything") +} + +func TestChain_AHungPrimaryReachesTheFallbackWhenItHasItsOwnDeadline(t *testing.T) { + calls := &[]string{} + fb := &orderedBackend{name: "berget/kb-whisper", text: "fallback text", calls: calls} + c := transcribe.NewChain(transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, 40*time.Millisecond), fb) + job, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + got, err := c.Transcribe(job, "/a.wav") + + require.NoError(t, err, "a wedged primary is exactly what the fallback exists for") + assert.Equal(t, "berget/kb-whisper", got.Endpoint) +} + +func TestChain_AJobThatEndedWhileThePrimaryHungStillNeverReachesTheFallback(t *testing.T) { + calls := &[]string{} + fb := &orderedBackend{name: "berget/kb-whisper", text: "fallback text", calls: calls} + c := transcribe.NewChain(transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, time.Minute), fb) + job, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond) + defer cancel() + + _, err := c.Transcribe(job, "/a.wav") + + require.Error(t, err) + assert.Empty(t, *calls, "the job's own deadline or a cancel must still stop the audio from reaching a cloud endpoint") +} + +func TestEnsemble_AHungEngineIsASoftFailureAndTheOtherEnginesTextIsUsed(t *testing.T) { + calls := &[]string{} + alt := &orderedBackend{name: "iguana/kb-whisper", text: "text from the other engine", calls: calls} + e := transcribe.NewEnsemble( + transcribe.WithTimeout(hangingBackend{name: "iguana/pianissimo"}, 40*time.Millisecond), alt, nil, + &verdictAdj{v: reconcile.VerdictAlt}, ensembleOpts) + job, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + got, err := e.Transcribe(job, "/a.wav") + + require.NoError(t, err) + assert.Equal(t, "text from the other engine", got.Text) + assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status) + assert.Contains(t, got.Reconciliation.Reason, "timeout") + assert.False(t, errors.Is(err, context.DeadlineExceeded)) +}