506 lines
14 KiB
Go
506 lines
14 KiB
Go
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.Err[bHeader](errEmpty)
|
||
}
|
||
parts := strings.SplitN(raw, "|", 3)
|
||
if len(parts) != 3 {
|
||
return result.Errf[bHeader]("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.Errf[bFields]("parse id %q: %w", h.id, err)
|
||
}
|
||
if id <= 0 {
|
||
return result.Errf[bFields]("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.Errf[bRecord]("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.Err[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 ────────────────────────────────────────────────
|
||
//
|
||
// Shows 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")
|
||
}
|
||
}
|
||
}
|