Failing tests for two limits that do not exist yet. Config: max_concurrent_processing and upstream_connections, their defaults, and valid and invalid values from the file and the environment. Image processor: never more images at once than its limit, waiting and then failing with ErrTooManyImages when no slot frees, and freeing its slot on every error. Fetcher: connections to all hosts counted together, apart from the per-host limit, and freed on errors. Both image routes answer 503 when either wait gives up. TestEnvironmentSetsEveryKey sets the two new variables, as it compares the whole config. The tests do not compile until the limits exist. Model: opus-5-5
303 lines
7.3 KiB
Go
303 lines
7.3 KiB
Go
package imageprocessor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/iotest"
|
|
"time"
|
|
)
|
|
|
|
// errTestReadFailed is the error the unreadable test input returns.
|
|
var errTestReadFailed = errors.New("test input cannot be read")
|
|
|
|
// readingCounter counts the Process calls reading their input at the same
|
|
// time and remembers the most there ever were.
|
|
type readingCounter struct {
|
|
mu sync.Mutex
|
|
reading int
|
|
most int
|
|
}
|
|
|
|
func (c *readingCounter) start() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.reading++
|
|
c.most = max(c.most, c.reading)
|
|
}
|
|
|
|
func (c *readingCounter) stop() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.reading--
|
|
}
|
|
|
|
func (c *readingCounter) mostReading() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
return c.most
|
|
}
|
|
|
|
// gatedReader is a Process input. Its first Read counts the call in,
|
|
// reports it on entered and blocks until gate is closed; it counts the call
|
|
// out when it returns io.EOF. Process reads its input only while it holds a
|
|
// processing slot, so the count never goes above MaxConcurrentProcessing.
|
|
type gatedReader struct {
|
|
data *bytes.Reader
|
|
gate <-chan struct{}
|
|
entered chan<- struct{}
|
|
counter *readingCounter
|
|
started bool
|
|
}
|
|
|
|
func (r *gatedReader) Read(p []byte) (int, error) {
|
|
if !r.started {
|
|
r.started = true
|
|
r.counter.start()
|
|
|
|
r.entered <- struct{}{}
|
|
|
|
<-r.gate
|
|
}
|
|
|
|
n, err := r.data.Read(p)
|
|
if errors.Is(err, io.EOF) {
|
|
r.counter.stop()
|
|
}
|
|
|
|
return n, err
|
|
}
|
|
|
|
// smallJPEGRequest asks for a 5x5 JPEG.
|
|
func smallJPEGRequest() *Request {
|
|
return &Request{
|
|
Size: Size{Width: 5, Height: 5},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: FitCover,
|
|
}
|
|
}
|
|
|
|
// processInBackground runs Process on reader in a new goroutine and sends
|
|
// its error on results.
|
|
func processInBackground(
|
|
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
|
) {
|
|
go func() {
|
|
result, err := proc.Process(context.Background(), reader, smallJPEGRequest())
|
|
if err == nil {
|
|
_ = result.Content.Close()
|
|
}
|
|
|
|
results <- err
|
|
}()
|
|
}
|
|
|
|
// waitForEntries fails the test unless count Process calls report on
|
|
// entered within a few seconds.
|
|
func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
|
|
t.Helper()
|
|
|
|
for range count {
|
|
select {
|
|
case <-entered:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Process calls did not start reading their input")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewDefaultsMaxConcurrentProcessingToCPUs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, limit := range []int{0, -1} {
|
|
proc := New(Params{MaxConcurrentProcessing: limit})
|
|
if got := cap(proc.processingSemaphore); got != runtime.GOMAXPROCS(0) {
|
|
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, one per CPU",
|
|
limit, got, runtime.GOMAXPROCS(0))
|
|
}
|
|
}
|
|
|
|
proc := New(Params{MaxConcurrentProcessing: 3})
|
|
if got := cap(proc.processingSemaphore); got != 3 {
|
|
t.Errorf("MaxConcurrentProcessing 3: %d slots, want 3", got)
|
|
}
|
|
}
|
|
|
|
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
|
// than MaxConcurrentProcessing allows and holds the first ones inside
|
|
// Process until the test lets them go. No more than the limit may be
|
|
// working at once, and the calls held back must wait for a slot and then
|
|
// succeed.
|
|
func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
limit = 2
|
|
calls = 6
|
|
)
|
|
|
|
proc := New(Params{MaxConcurrentProcessing: limit})
|
|
input := createTestJPEG(t, 50, 50)
|
|
|
|
counter := &readingCounter{}
|
|
gate := make(chan struct{})
|
|
entered := make(chan struct{}, calls)
|
|
results := make(chan error, calls)
|
|
|
|
openGate := sync.OnceFunc(func() { close(gate) })
|
|
t.Cleanup(openGate)
|
|
|
|
for range calls {
|
|
processInBackground(proc, &gatedReader{
|
|
data: bytes.NewReader(input), gate: gate, entered: entered,
|
|
counter: counter,
|
|
}, results)
|
|
}
|
|
|
|
waitForEntries(t, entered, limit)
|
|
|
|
// A call beyond the limit would start reading its input now.
|
|
select {
|
|
case <-entered:
|
|
t.Fatalf("a Process call started while %d were already working", limit)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
openGate()
|
|
|
|
for range calls {
|
|
err := <-results
|
|
if err != nil {
|
|
t.Errorf("Process() error = %v, want nil once a slot is free", err)
|
|
}
|
|
}
|
|
|
|
if most := counter.mostReading(); most > limit {
|
|
t.Errorf("%d Process calls worked at once, want at most %d", most, limit)
|
|
}
|
|
}
|
|
|
|
// TestProcessWaitsThenFailsWhenNoSlotFrees holds the only slot and checks
|
|
// that another call waits the whole wait timeout, then fails with
|
|
// ErrTooManyImages instead of processing anyway.
|
|
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
proc := New(Params{MaxConcurrentProcessing: 1})
|
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
|
|
|
input := createTestJPEG(t, 10, 10)
|
|
|
|
gate := make(chan struct{})
|
|
entered := make(chan struct{}, 1)
|
|
held := make(chan error, 1)
|
|
|
|
openGate := sync.OnceFunc(func() { close(gate) })
|
|
t.Cleanup(openGate)
|
|
|
|
processInBackground(proc, &gatedReader{
|
|
data: bytes.NewReader(input), gate: gate, entered: entered,
|
|
counter: &readingCounter{},
|
|
}, held)
|
|
waitForEntries(t, entered, 1)
|
|
|
|
start := time.Now()
|
|
|
|
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
|
smallJPEGRequest())
|
|
if !errors.Is(err, ErrTooManyImages) {
|
|
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
|
}
|
|
|
|
if waited := time.Since(start); waited < proc.processingWaitTimeout {
|
|
t.Errorf("Process() failed after %v, before waiting %v",
|
|
waited, proc.processingWaitTimeout)
|
|
}
|
|
|
|
openGate()
|
|
|
|
err = <-held
|
|
if err != nil {
|
|
t.Errorf("Process() holding the slot: error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
// TestProcessReleasesSlotOnError checks that Process gives its slot back
|
|
// when it fails, whether it fails early or late: with one slot, the slot
|
|
// must be free after the failure and the next call must succeed.
|
|
func TestProcessReleasesSlotOnError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
valid := createTestJPEG(t, 10, 10)
|
|
|
|
unsupported := smallJPEGRequest()
|
|
unsupported.Format = "bmp"
|
|
|
|
cases := []struct {
|
|
name string
|
|
input io.Reader
|
|
req *Request
|
|
// want is the error Process must return; nil means any error.
|
|
want error
|
|
}{
|
|
{
|
|
name: "input cannot be read",
|
|
input: iotest.ErrReader(errTestReadFailed),
|
|
req: smallJPEGRequest(),
|
|
want: errTestReadFailed,
|
|
},
|
|
{
|
|
name: "input over the byte limit",
|
|
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
|
|
req: smallJPEGRequest(),
|
|
want: ErrInputDataTooLarge,
|
|
},
|
|
{
|
|
name: "input not an image",
|
|
input: strings.NewReader("not an image"),
|
|
req: smallJPEGRequest(),
|
|
},
|
|
{
|
|
name: "output format not supported",
|
|
input: bytes.NewReader(valid),
|
|
req: unsupported,
|
|
want: ErrUnsupportedOutputFormat,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
|
|
|
_, err := proc.Process(context.Background(), tc.input, tc.req)
|
|
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
|
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
|
}
|
|
|
|
if held := len(proc.processingSemaphore); held != 0 {
|
|
t.Fatalf("slot still held after the error: %d held", held)
|
|
}
|
|
|
|
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
|
smallJPEGRequest())
|
|
if err != nil {
|
|
t.Fatalf("Process() after the error = %v, want nil", err)
|
|
}
|
|
|
|
_ = result.Content.Close()
|
|
})
|
|
}
|
|
}
|