caddy/modules/caddyhttp/encode/encode_harness_test.go
Sam Ottenhoff 997d3f6b0a
encode: add standard benchmark and conformance harness (#7804)
This is a shared encode_test harness with HTML/JSON/JS/CSS payloads taken from caddyserver.com

Benchmarks:
- BenchmarkStandardEncodingPayloads: raw encoder NewEncoder/Write/Close path
- BenchmarkEncodeHandlerCorpus: full Encode.ServeHTTP middleware path
- Grid: 4 payloads × gzip levels 1/5/9 × zstd fastest/default/best
- Each subtest runs with 4 parallel workers; compare runs on MB/s and allocs/op

Conformance tests:
- Encoder contract: Reset, Flush, Close, and pool-style Reset-after-Close reuse
- Corpus HTTP encoding: Content-Encoding, Vary, ETag suffix, header stripping
- Response semantics: minimum_length, 304, HEAD, range, WebSocket bypass,
  If-None-Match rewrite, Cache-Control no-transform, content-type matcher rejection
2026-06-11 17:55:18 -06:00

224 lines
6.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package encode_test provides the standard encode benchmark and conformance suite
// for Caddy's gzip and zstd encoder modules.
//
// Run encoder-level benchmarks (direct NewEncoder calls):
//
// go test -bench=BenchmarkStandardEncodingPayloads -benchmem ./modules/caddyhttp/encode/
//
// Run middleware-level benchmarks (Encode.ServeHTTP, writer pools, responseWriter):
//
// go test -bench=BenchmarkEncodeHandlerCorpus -benchmem ./modules/caddyhttp/encode/
//
// Benchmark subtest names:
//
// payload-{html|json|js|css}/encoder-{gzip|zstd}/compress-level-{N|fastest|...}
//
// Each subtest uses 4 parallel workers (benchmarkParallelism in encode_bench_test.go).
// Go may append -{GOMAXPROCS} to the printed benchmark name; ignore it when comparing runs.
//
// Grid: 4 payloads × 6 compress levels (gzip 1/5/9, zstd fastest/default/best) = 24 subtests
// per benchmark function (48 total with encoder + handler).
//
// Run conformance tests (Reset/Flush/Close, Vary, ETag, 304/HEAD/range/WebSocket, minimum_length):
//
// go test -run='TestStandardEncoderContract|TestEncodeCorpusResponse|TestEncodeResponseSemantics' ./modules/caddyhttp/encode/
//
// Conformance also covers Cache-Control no-transform, content-type matcher rejection,
// and encoder Reset-after-Close reuse (pool pattern).
package encode_test
import (
"bytes"
stdgzip "compress/gzip"
"context"
"fmt"
"io"
"os"
"testing"
"github.com/klauspost/compress/zstd"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
caddygzip "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/gzip"
caddyzstd "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/zstd"
)
// benchmarkCorpus is a fixed payload used by both benchmarks and conformance tests.
type benchmarkCorpus struct {
name string
data []byte
contentType string
}
var (
benchmarkGzipLevels = []int{1, 5, 9}
benchmarkZstdLevels = []string{"fastest", "default", "best"}
)
type encoderCase struct {
name string // conformance subtest label, e.g. gzip-level-5
encoder string // gzip or zstd
level string // gzip numeric level or zstd level name
encoding encode.Encoding
decompress func([]byte) ([]byte, error)
contentType string
}
func benchmarkCorpora(tb testing.TB) []benchmarkCorpus {
tb.Helper()
return []benchmarkCorpus{
{name: "html", data: readBenchmarkPayload(tb, "testdata/caddy_home.html"), contentType: "text/html; charset=utf-8"},
{name: "json", data: readBenchmarkPayload(tb, "testdata/caddy_config_http_servers.json"), contentType: "application/json"},
{name: "js", data: readBenchmarkPayload(tb, "testdata/caddy_asciinema_player.js"), contentType: "application/javascript"},
{name: "css", data: readBenchmarkPayload(tb, "testdata/caddy_asciinema_player.css"), contentType: "text/css"},
}
}
func readBenchmarkPayload(tb testing.TB, filename string) []byte {
tb.Helper()
data, err := os.ReadFile(filename)
if err != nil {
tb.Fatalf("reading benchmark payload %s: %v", filename, err)
}
return data
}
// conformanceLargeBody returns a payload large enough to exceed default minimum_length.
func conformanceLargeBody() []byte {
data, err := os.ReadFile("testdata/caddy_home.html")
if err != nil {
panic("conformanceLargeBody: " + err.Error())
}
return data
}
func standardEncoderCases(t testing.TB) []encoderCase {
t.Helper()
return provisionEncoderCases(t, []int{5}, []string{"default"})
}
func benchmarkEncoderCases(t testing.TB) []encoderCase {
t.Helper()
return provisionEncoderCases(t, benchmarkGzipLevels, benchmarkZstdLevels)
}
func provisionEncoderCases(t testing.TB, gzipLevels []int, zstdLevels []string) []encoderCase {
t.Helper()
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
t.Cleanup(cancel)
var cases []encoderCase
for _, level := range gzipLevels {
gzipEncoding := &caddygzip.Gzip{Level: level}
if err := gzipEncoding.Provision(ctx); err != nil {
t.Fatalf("gzip level %d Provision() error = %v", level, err)
}
cases = append(cases, encoderCase{
name: fmt.Sprintf("gzip-level-%d", level),
encoder: "gzip",
level: fmt.Sprintf("%d", level),
encoding: gzipEncoding,
decompress: decompressGzip,
contentType: "text/plain",
})
}
for _, level := range zstdLevels {
zstdEncoding := &caddyzstd.Zstd{Level: level}
if err := zstdEncoding.Provision(ctx); err != nil {
t.Fatalf("zstd level %q Provision() error = %v", level, err)
}
cases = append(cases, encoderCase{
name: "zstd-level-" + level,
encoder: "zstd",
level: level,
encoding: zstdEncoding,
decompress: decompressZstd,
contentType: "text/plain",
})
}
return cases
}
func newEncodeHandler(tb testing.TB, encCase encoderCase, minLength int) *encode.Encode {
tb.Helper()
encodingName := encCase.encoding.AcceptEncoding()
enc := &encode.Encode{
EncodingsRaw: caddy.ModuleMap{
encodingName: caddyconfig.JSON(encCase.encoding, nil),
},
Prefer: []string{encodingName},
MinLength: minLength,
}
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
tb.Cleanup(cancel)
if err := enc.Provision(ctx); err != nil {
tb.Fatalf("Provision() error = %v", err)
}
if err := enc.Validate(); err != nil {
tb.Fatalf("Validate() error = %v", err)
}
return enc
}
func assertDecompresses(t *testing.T, encCase encoderCase, compressed, original []byte) {
t.Helper()
decompressed, err := encCase.decompress(compressed)
if err != nil {
t.Fatalf("decompress %s: %v", encCase.name, err)
}
if !bytes.Equal(decompressed, original) {
t.Fatalf("decompressed len = %d, want len = %d", len(decompressed), len(original))
}
}
// encodeAndVerifyRoundTrip exercises Write → Flush → Write → Close and verifies
// the compressed stream round-trips to original.
func encodeAndVerifyRoundTrip(t *testing.T, encCase encoderCase, encoder encode.Encoder, original []byte) {
t.Helper()
var compressed bytes.Buffer
encoder.Reset(&compressed)
if _, err := encoder.Write(original[:len(original)/2]); err != nil {
t.Fatalf("Write() error = %v", err)
}
if err := encoder.Flush(); err != nil {
t.Fatalf("Flush() error = %v", err)
}
if compressed.Len() == 0 {
t.Fatal("Flush() wrote no compressed bytes")
}
if _, err := encoder.Write(original[len(original)/2:]); err != nil {
t.Fatalf("Write() error = %v", err)
}
if err := encoder.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
assertDecompresses(t, encCase, compressed.Bytes(), original)
}
func decompressGzip(compressed []byte) ([]byte, error) {
reader, err := stdgzip.NewReader(bytes.NewReader(compressed))
if err != nil {
return nil, err
}
defer reader.Close()
return io.ReadAll(reader)
}
func decompressZstd(compressed []byte) ([]byte, error) {
decoder, err := zstd.NewReader(nil)
if err != nil {
return nil, err
}
defer decoder.Close()
return decoder.DecodeAll(compressed, nil)
}