slides
This commit is contained in:
parent
e204e43e6e
commit
c737e7d6be
9
.devcontainer/devcontainer-lock.json
Normal file
9
.devcontainer/devcontainer-lock.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||||
|
"version": "2.5.7",
|
||||||
|
"resolved": "ghcr.io/devcontainers/features/common-utils@sha256:dbf431d6b42d55cde50fa1df75c7f7c3999a90cde6d73f7a7071174b3c3d0cc4",
|
||||||
|
"integrity": "sha256:dbf431d6b42d55cde50fa1df75c7f7c3999a90cde6d73f7a7071174b3c3d0cc4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -60,7 +60,6 @@
|
|||||||
"forwardPorts": [8080],
|
"forwardPorts": [8080],
|
||||||
|
|
||||||
"remoteEnv": {
|
"remoteEnv": {
|
||||||
"CONFIG_PATH": "${containerWorkspaceFolder}/config/dev.yaml",
|
|
||||||
"GOPRIVATE": "gitea.djmil.dev"
|
"GOPRIVATE": "gitea.djmil.dev"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
179
docs/result-slides.md
Normal file
179
docs/result-slides.md
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
---
|
||||||
|
theme: gaia
|
||||||
|
highlightTheme: monokai
|
||||||
|
margin: 0
|
||||||
|
html: true
|
||||||
|
marp: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# `result`
|
||||||
|
## Happy-Path Error Handling in Go
|
||||||
|
|
||||||
|
> Focus on success. Handle failure at the boundary.
|
||||||
|
|
||||||
|
<!---Hi everyone.
|
||||||
|
*This* is a 10-minute walkthrough of the result package — one type, a handful of functions, and a pattern that changes how we write application-layer error handling. No frameworks, no dependencies.--->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
```go
|
||||||
|
func process(raw string) (Output, error) {
|
||||||
|
h, err := parseHeader(raw)
|
||||||
|
if err != nil { return Output{}, err }
|
||||||
|
|
||||||
|
f, err := validate(h)
|
||||||
|
if err != nil { return Output{}, err }
|
||||||
|
|
||||||
|
r, err := transform(f)
|
||||||
|
if err != nil { return Output{}, err }
|
||||||
|
|
||||||
|
return format(r)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Half the function is error plumbing
|
||||||
|
- The actual logic is buried under boilerplate
|
||||||
|
|
||||||
|
<!---Standard idiomatic Go — nothing wrong with it. But notice: a 4-step function needs 4 error checks. Every layer handles an error it didn't cause and can't fix. As the call stack grows deeper, this pattern multiplies.--->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Idea
|
||||||
|
|
||||||
|
```go
|
||||||
|
func process(raw string) error {
|
||||||
|
return result.Run(func() {
|
||||||
|
h := parseHeader(raw).Expect("parse header")
|
||||||
|
f := validate(h).Expect("validate")
|
||||||
|
r := transform(f).Expect("transform")
|
||||||
|
_ = format(r).Expect("format")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Expect[T]` holds a value *or* an error — like a typed Result type
|
||||||
|
- `.Expect("context")` unwraps on success, exits on failure
|
||||||
|
|
||||||
|
<!--- Same behavior, same error messages, same semantics — the code now reads as a happy-path story. One error boundary at the bottom instead of four spread through the function. The caller gets a normal Go error; nothing unusual leaks out.--->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[".Expect() sees error"] --> B["panic"]
|
||||||
|
B --> C["Run's deferred\nrecover()"]
|
||||||
|
C --> D["normal Go error"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `result.Run` spawns one goroutine and defers recovery
|
||||||
|
- Failure exits via `panic` — caught and returned as a plain `error`
|
||||||
|
|
||||||
|
<!---The mechanism is small. result.Run spawns a goroutine and registers a deferred recover. When .Expect() encounters an error it panics with a typed sentinel value. The deferred recovery catches that specific type and returns it as a plain Go error. Genuine runtime panics — nil dereferences, index out of bounds — are re-panicked and still crash the program as they should.--->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The One Rule
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ✅ pkg/ library — returns Expect[T], never calls .Expect()
|
||||||
|
func Parse(raw string) result.Expect[Header] {
|
||||||
|
if raw == "" {
|
||||||
|
return result.Fail[Header](errors.New("empty input"))
|
||||||
|
}
|
||||||
|
return result.Ok(Header{raw})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ application code — calls .Expect() inside Run
|
||||||
|
h := parser.Parse(raw).Expect("parse header")
|
||||||
|
```
|
||||||
|
|
||||||
|
- Libraries **return** `Expect[T]` — let the caller decide
|
||||||
|
- Application code **chains** `.Expect()` inside `Run`
|
||||||
|
|
||||||
|
<!---This is the contract that makes the package composable. Library code is passive: it wraps a result and hands control back. Calling .Expect() inside a library would exit someone else's goroutine without their consent. Only application code, protected by a Run boundary, calls .Expect(). Think of it as: pkg/ produces, app/ consumes.--->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defers Work Normally
|
||||||
|
|
||||||
|
```go
|
||||||
|
result.Run(func() {
|
||||||
|
conn := pool.Acquire()
|
||||||
|
defer conn.Release() // ✅ always fires — success or failure
|
||||||
|
|
||||||
|
score := compute(conn).Expect("compute score")
|
||||||
|
_ = save(score).Expect("save result")
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- Failure travels as a *value* — functions return normally
|
||||||
|
- Deferred cleanup fires before `.Expect()` triggers the exit
|
||||||
|
|
||||||
|
<!---A common concern. Defers are completely safe because failure is just a value. When compute() returns a Fail, it returns normally through the call stack — the defer fires. Only when .Expect() is called on that Fail result does the panic happen. By that point all defers up the stack have already run. No resource leaks.--->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging: Stack Traces
|
||||||
|
|
||||||
|
```
|
||||||
|
result.StackTrace(err):
|
||||||
|
|
||||||
|
main.processRequest.func1
|
||||||
|
/app/handler.go:42
|
||||||
|
main.run.func1
|
||||||
|
/app/main.go:17
|
||||||
|
```
|
||||||
|
|
||||||
|
- Trace captured at the `.Expect()` call site — pinpoints the failure
|
||||||
|
- `result.CaptureStack = false` at startup for production builds
|
||||||
|
|
||||||
|
<!---When something fails in production you want to know where. result captures a stack trace at the .Expect() call site, not deep inside an stdlib function. In production you can disable this with a single line before main starts; errors still propagate correctly, you just lose the trace. The benchmark shows this saves about 870 ns and 280 B per failure.--->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two Build Modes
|
||||||
|
|
||||||
|
| | Default | `-tags result_goexit` |
|
||||||
|
|--|--|--|
|
||||||
|
| Exit mechanism | `panic` | `runtime.Goexit` |
|
||||||
|
| Error path cost | ~1.4 µs | ~5.5 µs |
|
||||||
|
| Stray `recover()` safe | ⚠ re-panic unknowns | ✅ always safe |
|
||||||
|
|
||||||
|
- **Panic** (default): 4× faster; standard Go `recover()` idiom applies
|
||||||
|
- **Goexit** (opt-in): immune to accidental error swallowing by any middleware
|
||||||
|
|
||||||
|
<!---Go gives two ways to exit a goroutine mid-execution. Panic is faster but can be caught by a bare recover() somewhere up the stack — which would silently swallow the failure. Goexit cannot be intercepted by recover(). We default to panic because standard Go practice is to always check the type in recover() and re-panic anything unrecognised. Use the goexit build if your codebase has aggressive recover() usage in middleware or testing wrappers.--->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
| Scenario | Canonical Go | `result` | `result` (no trace) |
|
||||||
|
|--|--|--|--|
|
||||||
|
| Happy path | 1 324 ns | 1 046 ns | 1 027 ns |
|
||||||
|
| Error at depth 3 | 820 ns | 1 441 ns | 1 149 ns |
|
||||||
|
| Error at depth 10 | 1 257 ns | 1 576 ns | 1 113 ns |
|
||||||
|
|
||||||
|
*arm64, both sides spawn one goroutine per boundary*
|
||||||
|
|
||||||
|
- **Happy path**: result matches or beats canonical
|
||||||
|
- **Error path**: +300–600 ns — rounds to zero next to any I/O
|
||||||
|
|
||||||
|
<!---Both sides pay the same goroutine-spawn cost, so the comparison isolates only the error-propagation mechanism. Happy path is free. On the error path, result_nostack at depth 10 actually beats canonical — the panic exit skips the normal return sequence through the closure. In any real workload that touches a network, database, or disk, neither overhead is measurable.--->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Write functions for the happy path; handle errors once at the boundary
|
||||||
|
- `pkg/` returns `Expect[T]` — application code calls `.Expect()` inside `Run`
|
||||||
|
- Defers, concurrent patterns, and stack traces all work as expected
|
||||||
|
- Tune with `CaptureStack` and build tags if the profile demands it
|
||||||
|
|
||||||
|
> **Best fit: application pipelines with 3+ sequential fallible steps.**
|
||||||
|
|
||||||
|
<!---The one-line pitch for the CTO: result is not a replacement for Go error handling — it's a pattern for the specific case where you have a deep pipeline of sequential operations and the error-threading boilerplate is obscuring what the code actually does. Use standard Go for library APIs. Use result for application orchestration code.--->
|
||||||
@ -391,10 +391,9 @@ func BenchmarkResult_HappyPath(b *testing.B) {
|
|||||||
|
|
||||||
// ── CaptureStack=false variants ────────────────────────────────────────────────
|
// ── CaptureStack=false variants ────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// These show the floor: goroutine spawn + Goexit mechanism with no stack
|
// Shows the floor: goroutine spawn + Goexit mechanism with no stack capture. Set
|
||||||
// capture. Set result.CaptureStack=false at startup to reach this level in
|
// result.CaptureStack=false at startup to reach this level in production. The
|
||||||
// production. The happy-path cost is unchanged (CaptureStack is only
|
// happy-path cost is unchanged (CaptureStack is only consulted on error paths).
|
||||||
// consulted on error paths).
|
|
||||||
|
|
||||||
func BenchmarkResult_NoStack_HappyPath(b *testing.B) {
|
func BenchmarkResult_NoStack_HappyPath(b *testing.B) {
|
||||||
result.CaptureStack = false
|
result.CaptureStack = false
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user