template/pkg/result/bench_test.go
djmil e204e43e6e use panics as a default error reporting mechanism
- runtime.Goexit() has too much performance overhead, and should be used only under special conditions
- introduce build tags
2026-04-23 21:05:18 +00:00

507 lines
14 KiB
Go
Raw 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 result_test
// Comparison benchmark: canonical Go error handling vs the result package.
//
// Scenario: a "process record" pipeline with five steps and varying call-stack
// depths. Four inputs exercise the happy path and failure at depths 3, 5, 10.
//
// Run (default — panic build):
//
// go test -bench=. -benchmem ./pkg/result/
//
// Run (Goexit build — stray-recover-safe):
//
// go test -bench=. -benchmem -tags result_goexit ./pkg/result/
//
// What to look for:
// - HappyPath: result matches canonical — goroutineID is off the happy path;
// the defer closure no longer escapes to heap.
// - FailDepthN (default panic): result pays panic + recover + runtime.Callers;
// ~1.4 µs overhead vs canonical.
// - FailDepthN (-tags result_goexit): pays goroutineID + sync.Map + Goexit;
// ~5.5 µs — 4× slower but immune to stray recover() calls.
// - NoStack variants: CaptureStack=false removes runtime.Callers overhead.
import (
"errors"
"fmt"
"strconv"
"strings"
"testing"
"gitea.djmil.dev/go/template/pkg/result"
)
// ── Shared types ───────────────────────────────────────────────────────────────
type bHeader struct{ raw, id, name, val string }
type bFields struct {
id int
name, val string
}
type bRecord struct {
id int
name string
score float64
}
type bOutput struct{ line string }
// mockRsrc simulates a resource requiring cleanup (e.g. a file, DB conn).
type mockRsrc struct{ closed bool }
func (m *mockRsrc) close() { m.closed = true }
// ── Inputs ─────────────────────────────────────────────────────────────────────
const (
happy = "42|Alice|100" // all steps succeed
fail3 = "" // parseHeader fails at depth 3: empty input
fail5 = "0|Alice|100" // validateFields fails at depth 5: id ≤ 0
fail10 = "42|Alice|-9" // enrichRecord fails at depth 10: negative score
)
var (
errEmpty = errors.New("empty input")
errNegScore = errors.New("negative score")
)
// ── Canonical: standard (value, error) error handling ──────────────────────────
//
// Each step returns (T, error); every call site does: if err != nil { return }.
// The entry point uses goRun, a thin generic helper that mirrors result.Go
// (spawns one goroutine, blocks, returns (T, error)) so both sides pay the
// same goroutine-spawn cost and the benchmark isolates the error-propagation
// mechanism only.
// //go:noinline on intermediate frames prevents the compiler from collapsing
// the declared stack depth, keeping the benchmark representative.
// goRun is the canonical counterpart of result.Go: spawns a goroutine, blocks
// until fn returns, and hands back (T, error). Both entry points are now
// structurally identical — the only measured difference is how errors travel
// through the call stack.
func goRun[T any](fn func() (T, error)) (T, error) {
type ret struct {
v T
err error
}
ch := make(chan ret, 1)
go func() {
v, err := fn()
ch <- ret{v, err}
}()
r := <-ch
return r.v, r.err
}
// parseHeader — 3 levels deep; fails on fail3 input.
//
//go:noinline
func c_parseHeader(raw string) (bHeader, error) { return c_parseHeader2(raw) }
//go:noinline
func c_parseHeader2(raw string) (bHeader, error) { return c_parseHeader3(raw) }
func c_parseHeader3(raw string) (bHeader, error) {
if raw == "" {
return bHeader{}, errEmpty
}
parts := strings.SplitN(raw, "|", 3)
if len(parts) != 3 {
return bHeader{}, fmt.Errorf("malformed record: %q", raw)
}
return bHeader{raw: raw, id: parts[0], name: parts[1], val: parts[2]}, nil
}
// validateFields — 5 levels deep; fails on fail5 input.
//
//go:noinline
func c_validate(h bHeader) (bFields, error) { return c_validate2(h) }
//go:noinline
func c_validate2(h bHeader) (bFields, error) { return c_validate3(h) }
//go:noinline
func c_validate3(h bHeader) (bFields, error) { return c_validate4(h) }
//go:noinline
func c_validate4(h bHeader) (bFields, error) { return c_validate5(h) }
func c_validate5(h bHeader) (bFields, error) {
id, err := strconv.Atoi(h.id)
if err != nil {
return bFields{}, fmt.Errorf("parse id %q: %w", h.id, err)
}
if id <= 0 {
return bFields{}, fmt.Errorf("id %d: must be > 0", id)
}
return bFields{id: id, name: h.name, val: h.val}, nil
}
// transformData — 5 levels deep; does not fail on any benchmark input.
//
//go:noinline
func c_transform(f bFields) (bRecord, error) { return c_transform2(f) }
//go:noinline
func c_transform2(f bFields) (bRecord, error) { return c_transform3(f) }
//go:noinline
func c_transform3(f bFields) (bRecord, error) { return c_transform4(f) }
//go:noinline
func c_transform4(f bFields) (bRecord, error) { return c_transform5(f) }
func c_transform5(f bFields) (bRecord, error) {
v, err := strconv.ParseFloat(f.val, 64)
if err != nil {
return bRecord{}, fmt.Errorf("parse value %q: %w", f.val, err)
}
return bRecord{id: f.id, name: f.name, score: v * 1.5}, nil
}
// enrichRecord — 10 levels deep; 1 defer at level 1; fails on fail10 input.
//
//go:noinline
func c_enrich(r bRecord) (bRecord, error) {
res := &mockRsrc{}
defer res.close()
return c_enrich2(r)
}
//go:noinline
func c_enrich2(r bRecord) (bRecord, error) { return c_enrich3(r) }
//go:noinline
func c_enrich3(r bRecord) (bRecord, error) { return c_enrich4(r) }
//go:noinline
func c_enrich4(r bRecord) (bRecord, error) { return c_enrich5(r) }
//go:noinline
func c_enrich5(r bRecord) (bRecord, error) { return c_enrich6(r) }
//go:noinline
func c_enrich6(r bRecord) (bRecord, error) { return c_enrich7(r) }
//go:noinline
func c_enrich7(r bRecord) (bRecord, error) { return c_enrich8(r) }
//go:noinline
func c_enrich8(r bRecord) (bRecord, error) { return c_enrich9(r) }
//go:noinline
func c_enrich9(r bRecord) (bRecord, error) { return c_enrich10(r) }
func c_enrich10(r bRecord) (bRecord, error) {
if r.score < 0 {
return bRecord{}, errNegScore
}
return bRecord{id: r.id, name: r.name, score: r.score + 10.0}, nil
}
// formatOutput — 3 levels deep; does not fail on any benchmark input.
//
//go:noinline
func c_format(r bRecord) (bOutput, error) { return c_format2(r) }
//go:noinline
func c_format2(r bRecord) (bOutput, error) { return c_format3(r) }
func c_format3(r bRecord) (bOutput, error) {
return bOutput{line: fmt.Sprintf("%d|%s|%.2f", r.id, r.name, r.score)}, nil
}
// canonicalProcess is the pipeline entry point using canonical error handling.
func canonicalProcess(raw string) (bOutput, error) {
return goRun(func() (bOutput, error) {
h, err := c_parseHeader(raw)
if err != nil {
return bOutput{}, fmt.Errorf("parse header: %w", err)
}
f, err := c_validate(h)
if err != nil {
return bOutput{}, fmt.Errorf("validate fields: %w", err)
}
r, err := c_transform(f)
if err != nil {
return bOutput{}, fmt.Errorf("transform: %w", err)
}
r, err = c_enrich(r)
if err != nil {
return bOutput{}, fmt.Errorf("enrich: %w", err)
}
out, err := c_format(r)
if err != nil {
return bOutput{}, fmt.Errorf("format: %w", err)
}
return out, nil
})
}
// ── Result: happy-path-oriented error handling ─────────────────────────────────
//
// Each step returns result.Expect[T]; the call site chains .Expect("ctx").
// The entry point wraps everything in result.Go, which spawns one goroutine
// and collects any Expect failure as a normal Go error.
// parseHeader — 3 levels deep; fails on fail3 input.
//
//go:noinline
func r_parseHeader(raw string) result.Expect[bHeader] { return r_parseHeader2(raw) }
//go:noinline
func r_parseHeader2(raw string) result.Expect[bHeader] { return r_parseHeader3(raw) }
func r_parseHeader3(raw string) result.Expect[bHeader] {
if raw == "" {
return result.Fail[bHeader](errEmpty)
}
parts := strings.SplitN(raw, "|", 3)
if len(parts) != 3 {
return result.Fail[bHeader](fmt.Errorf("malformed record: %q", raw))
}
return result.Ok(bHeader{raw: raw, id: parts[0], name: parts[1], val: parts[2]})
}
// validateFields — 5 levels deep; fails on fail5 input.
//
//go:noinline
func r_validate(h bHeader) result.Expect[bFields] { return r_validate2(h) }
//go:noinline
func r_validate2(h bHeader) result.Expect[bFields] { return r_validate3(h) }
//go:noinline
func r_validate3(h bHeader) result.Expect[bFields] { return r_validate4(h) }
//go:noinline
func r_validate4(h bHeader) result.Expect[bFields] { return r_validate5(h) }
func r_validate5(h bHeader) result.Expect[bFields] {
id, err := strconv.Atoi(h.id)
if err != nil {
return result.Fail[bFields](fmt.Errorf("parse id %q: %w", h.id, err))
}
if id <= 0 {
return result.Fail[bFields](fmt.Errorf("id %d: must be > 0", id))
}
return result.Ok(bFields{id: id, name: h.name, val: h.val})
}
// transformData — 5 levels deep; does not fail on any benchmark input.
//
//go:noinline
func r_transform(f bFields) result.Expect[bRecord] { return r_transform2(f) }
//go:noinline
func r_transform2(f bFields) result.Expect[bRecord] { return r_transform3(f) }
//go:noinline
func r_transform3(f bFields) result.Expect[bRecord] { return r_transform4(f) }
//go:noinline
func r_transform4(f bFields) result.Expect[bRecord] { return r_transform5(f) }
func r_transform5(f bFields) result.Expect[bRecord] {
v, err := strconv.ParseFloat(f.val, 64)
if err != nil {
return result.Fail[bRecord](fmt.Errorf("parse value %q: %w", f.val, err))
}
return result.Ok(bRecord{id: f.id, name: f.name, score: v * 1.5})
}
// enrichRecord — 10 levels deep; 1 defer at level 1; fails on fail10 input.
//
//go:noinline
func r_enrich(r bRecord) result.Expect[bRecord] {
res := &mockRsrc{}
defer res.close()
return r_enrich2(r)
}
//go:noinline
func r_enrich2(r bRecord) result.Expect[bRecord] { return r_enrich3(r) }
//go:noinline
func r_enrich3(r bRecord) result.Expect[bRecord] { return r_enrich4(r) }
//go:noinline
func r_enrich4(r bRecord) result.Expect[bRecord] { return r_enrich5(r) }
//go:noinline
func r_enrich5(r bRecord) result.Expect[bRecord] { return r_enrich6(r) }
//go:noinline
func r_enrich6(r bRecord) result.Expect[bRecord] { return r_enrich7(r) }
//go:noinline
func r_enrich7(r bRecord) result.Expect[bRecord] { return r_enrich8(r) }
//go:noinline
func r_enrich8(r bRecord) result.Expect[bRecord] { return r_enrich9(r) }
//go:noinline
func r_enrich9(r bRecord) result.Expect[bRecord] { return r_enrich10(r) }
func r_enrich10(r bRecord) result.Expect[bRecord] {
if r.score < 0 {
return result.Fail[bRecord](errNegScore)
}
return result.Ok(bRecord{id: r.id, name: r.name, score: r.score + 10.0})
}
// formatOutput — 3 levels deep; does not fail on any benchmark input.
//
//go:noinline
func r_format(r bRecord) result.Expect[bOutput] { return r_format2(r) }
//go:noinline
func r_format2(r bRecord) result.Expect[bOutput] { return r_format3(r) }
func r_format3(r bRecord) result.Expect[bOutput] {
return result.Ok(bOutput{line: fmt.Sprintf("%d|%s|%.2f", r.id, r.name, r.score)})
}
// resultProcess is the pipeline entry point using result-package error handling.
func resultProcess(raw string) (bOutput, error) {
return result.Go(func() bOutput {
h := r_parseHeader(raw).Expect("parse header")
f := r_validate(h).Expect("validate fields")
r := r_transform(f).Expect("transform")
r = r_enrich(r).Expect("enrich")
return r_format(r).Expect("format")
}).Unwrap()
}
// ── Benchmarks ─────────────────────────────────────────────────────────────────
var sinkOutput bOutput // prevents the compiler from eliminating pipeline work
func BenchmarkCanonical_HappyPath(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
out, err := canonicalProcess(happy)
if err != nil {
b.Fatal(err)
}
sinkOutput = out
}
}
func BenchmarkResult_HappyPath(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
out, err := resultProcess(happy)
if err != nil {
b.Fatal(err)
}
sinkOutput = out
}
}
// ── CaptureStack=false variants ────────────────────────────────────────────────
//
// These show the floor: goroutine spawn + Goexit mechanism with no stack
// capture. Set result.CaptureStack=false at startup to reach this level in
// production. The happy-path cost is unchanged (CaptureStack is only
// consulted on error paths).
func BenchmarkResult_NoStack_HappyPath(b *testing.B) {
result.CaptureStack = false
defer func() { result.CaptureStack = true }()
b.ReportAllocs()
for b.Loop() {
out, err := resultProcess(happy)
if err != nil {
b.Fatal(err)
}
sinkOutput = out
}
}
func BenchmarkCanonical_FailDepth3(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
_, err := canonicalProcess(fail3)
if err == nil {
b.Fatal("expected error")
}
}
}
func BenchmarkResult_FailDepth3(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
_, err := resultProcess(fail3)
if err == nil {
b.Fatal("expected error")
}
}
}
func BenchmarkResult_NoStack_FailDepth3(b *testing.B) {
result.CaptureStack = false
defer func() { result.CaptureStack = true }()
b.ReportAllocs()
for b.Loop() {
_, err := resultProcess(fail3)
if err == nil {
b.Fatal("expected error")
}
}
}
func BenchmarkCanonical_FailDepth5(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
_, err := canonicalProcess(fail5)
if err == nil {
b.Fatal("expected error")
}
}
}
func BenchmarkResult_FailDepth5(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
_, err := resultProcess(fail5)
if err == nil {
b.Fatal("expected error")
}
}
}
func BenchmarkResult_NoStack_FailDepth5(b *testing.B) {
result.CaptureStack = false
defer func() { result.CaptureStack = true }()
b.ReportAllocs()
for b.Loop() {
_, err := resultProcess(fail5)
if err == nil {
b.Fatal("expected error")
}
}
}
func BenchmarkCanonical_FailDepth10(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
_, err := canonicalProcess(fail10)
if err == nil {
b.Fatal("expected error")
}
}
}
func BenchmarkResult_FailDepth10(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
_, err := resultProcess(fail10)
if err == nil {
b.Fatal("expected error")
}
}
}
func BenchmarkResult_NoStack_FailDepth10(b *testing.B) {
result.CaptureStack = false
defer func() { result.CaptureStack = true }()
b.ReportAllocs()
for b.Loop() {
_, err := resultProcess(fail10)
if err == nil {
b.Fatal("expected error")
}
}
}