initial implementation
see TODO.md for problem definition
This commit is contained in:
parent
81f5a49cea
commit
386d6b548b
87
pkg/result/TODO.md
Normal file
87
pkg/result/TODO.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# result: Concurrency API — open design problem
|
||||||
|
|
||||||
|
## Current API
|
||||||
|
|
||||||
|
```go
|
||||||
|
Async[T](fn func() T) <-chan Expect[T]
|
||||||
|
AsyncOf[T](fn func() Expect[T]) <-chan Expect[T]
|
||||||
|
Bind[T, A](fn func(A) Expect[T], arg A) func() Expect[T]
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical call site:
|
||||||
|
|
||||||
|
```go
|
||||||
|
hostCh := result.AsyncOf(result.Bind(parseHost, "localhost"))
|
||||||
|
portCh := result.AsyncOf(func() result.Expect[int] { return parsePort("8080") })
|
||||||
|
```
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
Every async call requires either an explicit closure or a `Bind` call. For
|
||||||
|
single-arg library functions `Bind` works cleanly; for zero-arg and multi-arg
|
||||||
|
functions the caller must write a closure, introducing visual noise that does
|
||||||
|
not add information.
|
||||||
|
|
||||||
|
### Why `result.Async(parsePort("8080"))` does not work
|
||||||
|
|
||||||
|
`parsePort("8080")` is an eager call — it evaluates immediately on the calling
|
||||||
|
goroutine and returns an `int`. `Async` expects `func() int`. The compiler
|
||||||
|
rejects it. There is no way in Go to pass a call expression as a deferred
|
||||||
|
computation without wrapping it in a closure.
|
||||||
|
|
||||||
|
## Options explored
|
||||||
|
|
||||||
|
### 1. `AsyncBind` / `AsyncBind2` — collapse `AsyncOf` + `Bind`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func AsyncBind[T, A any](fn func(A) Expect[T], a A) <-chan Expect[T]
|
||||||
|
func AsyncBind2[T, A, B any](fn func(A, B) Expect[T], a A, b B) <-chan Expect[T]
|
||||||
|
```
|
||||||
|
|
||||||
|
Reduces call site to `result.AsyncBind(parsePort, "8080")`.
|
||||||
|
**Downside:** still need `AsyncBind3`, etc. for higher arities; `Bind` itself
|
||||||
|
becomes redundant.
|
||||||
|
|
||||||
|
### 2. Higher-level combinator — `Join2`, `Join3`, …
|
||||||
|
|
||||||
|
Hides channels entirely. Runs N computations concurrently, collects results,
|
||||||
|
calls a combiner only on success:
|
||||||
|
|
||||||
|
```go
|
||||||
|
url, err := result.Join2(
|
||||||
|
result.Bind(parseHost, "localhost"),
|
||||||
|
result.Bind(parsePort, "8080"),
|
||||||
|
func(host string, port int) string {
|
||||||
|
return fmt.Sprintf("http://%s:%d", host, port)
|
||||||
|
},
|
||||||
|
).Unwrap()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upside:** no channels, no closures in application code; most declarative.
|
||||||
|
**Downside:** loses the "start goroutines, do other work, collect later"
|
||||||
|
pattern; still needs `Join2`/`Join3`/… per arity; heterogeneous type
|
||||||
|
combinations explode the generic signature.
|
||||||
|
|
||||||
|
### 3. Reflection / `any` variadic
|
||||||
|
|
||||||
|
A single `AsyncCall(fn any, args ...any)` using `reflect.Call`.
|
||||||
|
**Downside:** no type safety, runtime panics on arity/type mismatch, poor IDE
|
||||||
|
support. Not worth it.
|
||||||
|
|
||||||
|
## Root constraint
|
||||||
|
|
||||||
|
Go generics have no variadic type parameters. There is no way to write a
|
||||||
|
single type-safe function that accepts an arbitrary function and its arguments.
|
||||||
|
Every approach above hits this wall and works around it by duplicating the
|
||||||
|
function for each arity (N = 1, 2, 3, …).
|
||||||
|
|
||||||
|
## Recommended direction when revisiting
|
||||||
|
|
||||||
|
Decide first which usage pattern dominates:
|
||||||
|
|
||||||
|
- **"Fire and forget, collect later"** — keep channels visible; `AsyncBind` /
|
||||||
|
`AsyncBind2` are the minimal improvement.
|
||||||
|
- **"Run N things, combine result"** — adopt `Join2`/`Join3`; channels
|
||||||
|
disappear from application code entirely.
|
||||||
|
|
||||||
|
A mixed API (both families) is possible but adds surface area. Keep it small.
|
||||||
@ -38,16 +38,16 @@
|
|||||||
//
|
//
|
||||||
// # Layering rule
|
// # Layering rule
|
||||||
//
|
//
|
||||||
// Reusable library code (packages under pkg/) must only *return* Expect[T] —
|
// The rule is simple: .Expect() is safe anywhere a boundary ([Go] or [Run])
|
||||||
// it must never call .Expect(), .Must(), or .Expectf() itself. Those methods
|
// owns the goroutine. In practice:
|
||||||
// exit the current goroutine and are only safe inside a goroutine controlled
|
|
||||||
// by [Go] or [Run].
|
|
||||||
//
|
//
|
||||||
// The right split:
|
// - pkg/ functions that just compute and return: return Expect[T], let the
|
||||||
//
|
// caller decide how to handle it.
|
||||||
// - pkg/ functions: return Expect[T] — let the caller decide how to handle it.
|
// - pkg/ functions that internally spawn goroutines via [Go] or [Run]: they
|
||||||
// - Application code (cmd/, HTTP handlers, …): chain .Expect() calls freely,
|
// own those goroutines and may freely chain .Expect() inside them. From
|
||||||
// protected by a defer result.Catch(&err) or a result.Run wrapper.
|
// the outside they still look like normal functions returning Expect[T].
|
||||||
|
// - Application code (cmd/, HTTP handlers, …): chain .Expect() freely,
|
||||||
|
// protected by a [Run] wrapper or defer [Catch].
|
||||||
//
|
//
|
||||||
// Stack traces are captured at the failure site and can be retrieved from the
|
// Stack traces are captured at the failure site and can be retrieved from the
|
||||||
// collected error via [StackTrace].
|
// collected error via [StackTrace].
|
||||||
@ -85,6 +85,33 @@
|
|||||||
// runtime.Goexit which recover() cannot intercept — use [Run] or [Go] instead,
|
// runtime.Goexit which recover() cannot intercept — use [Run] or [Go] instead,
|
||||||
// as they work correctly in both builds.
|
// as they work correctly in both builds.
|
||||||
//
|
//
|
||||||
|
// # Concurrent pattern
|
||||||
|
//
|
||||||
|
// Combining [Async] with the boundary pattern makes concurrent code almost as
|
||||||
|
// readable as sequential code. Fire goroutines with [Async], then collect with
|
||||||
|
// [All] or by reading channels individually — failures surface as normal errors
|
||||||
|
// at the boundary, with no manual WaitGroups, mutex guards, or error channels:
|
||||||
|
//
|
||||||
|
// func fetchAll(urls []string) ([]string, error) {
|
||||||
|
// return result.Map(urls, func(url string) string {
|
||||||
|
// return fetch(url).Expect("fetch") // happy path inside the goroutine
|
||||||
|
// }).Unwrap()
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// For heterogeneous concurrent work use [AsyncOf] — it accepts functions that
|
||||||
|
// already return Expect[T] (as library functions should), so only one .Expect()
|
||||||
|
// per goroutine is needed at the collection site:
|
||||||
|
//
|
||||||
|
// func loadConfig() (Config, error) {
|
||||||
|
// hostCh := result.AsyncOf(resolveHost) // resolveHost() Expect[string]
|
||||||
|
// portCh := result.AsyncOf(resolvePort) // resolvePort() Expect[int]
|
||||||
|
// return result.Go(func() Config {
|
||||||
|
// host := (<-hostCh).Expect("resolve host")
|
||||||
|
// port := (<-portCh).Expect("resolve port")
|
||||||
|
// return Config{Host: host, Port: port}
|
||||||
|
// }).Unwrap()
|
||||||
|
// }
|
||||||
|
//
|
||||||
// Genuine runtime panics (nil-pointer dereferences, index out of bounds, etc.)
|
// Genuine runtime panics (nil-pointer dereferences, index out of bounds, etc.)
|
||||||
// are not recovered — they still crash the program, as they should.
|
// are not recovered — they still crash the program, as they should.
|
||||||
package result
|
package result
|
||||||
|
|||||||
@ -148,3 +148,106 @@ func Example_unwrap() {
|
|||||||
// Output:
|
// Output:
|
||||||
// 443
|
// 443
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Example_all shows result.All collecting values from concurrently running
|
||||||
|
// goroutines. All goroutines are started before any result is read.
|
||||||
|
func Example_all() {
|
||||||
|
aCh := result.Async(func() int { return parsePort("80").Must() })
|
||||||
|
bCh := result.Async(func() int { return parsePort("443").Must() })
|
||||||
|
cCh := result.Async(func() int { return parsePort("8080").Must() })
|
||||||
|
|
||||||
|
ports := result.All(aCh, bCh, cCh).Must()
|
||||||
|
fmt.Println(ports)
|
||||||
|
// Output:
|
||||||
|
// [80 443 8080]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_allError shows result.All returning on the first error (in channel
|
||||||
|
// order). Here bCh fails, so its error is returned and cCh is never read.
|
||||||
|
func Example_allError() {
|
||||||
|
aCh := result.Async(func() int { return parsePort("80").Must() })
|
||||||
|
bCh := result.Async(func() int { return parsePort("99999").Must() })
|
||||||
|
cCh := result.Async(func() int { return parsePort("8080").Must() })
|
||||||
|
|
||||||
|
if err := result.All(aCh, bCh, cCh).Err(); err != nil {
|
||||||
|
fmt.Println("failed:", err)
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// failed: example_test.go:25: 99999 out of range
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_map shows result.Map running a function concurrently over a slice.
|
||||||
|
// All goroutines complete and all errors are collected.
|
||||||
|
func Example_map() {
|
||||||
|
ports, err := result.Map(
|
||||||
|
[]string{"80", "443", "8080"},
|
||||||
|
func(s string) int { return parsePort(s).Must() },
|
||||||
|
).Unwrap()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(ports)
|
||||||
|
// Output:
|
||||||
|
// [80 443 8080]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_mapError shows result.Map collecting all errors when multiple inputs
|
||||||
|
// fail, rather than stopping at the first.
|
||||||
|
func Example_mapError() {
|
||||||
|
_, err := result.Map(
|
||||||
|
[]string{"80", "bad", "99999"},
|
||||||
|
func(s string) int { return parsePort(s).Must() },
|
||||||
|
).Unwrap()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("failed:", err)
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// failed: example_test.go:22: result.Of
|
||||||
|
// strconv.Atoi: parsing "bad": invalid syntax
|
||||||
|
// example_test.go:25: 99999 out of range
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_asyncOf shows result.AsyncOf running library functions (which return
|
||||||
|
// Expect[T]) concurrently. Only one .Expect() per goroutine is needed — at the
|
||||||
|
// collection site inside the boundary.
|
||||||
|
func Example_asyncOf() {
|
||||||
|
hostCh := result.AsyncOf(result.Bind(parseHost, "localhost"))
|
||||||
|
portCh := result.AsyncOf(func() result.Expect[int] { return parsePort("8080") })
|
||||||
|
|
||||||
|
url, err := result.Go(func() string {
|
||||||
|
host := (<-hostCh).Expect("parse host")
|
||||||
|
port := (<-portCh).Expect("parse port")
|
||||||
|
return fmt.Sprintf("http://%s:%d", host, port)
|
||||||
|
}).Unwrap()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(url)
|
||||||
|
// Output:
|
||||||
|
// http://localhost:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_asyncOfError shows result.AsyncOf propagating a failure through the
|
||||||
|
// channel — the error surfaces at the .Expect() call inside the boundary.
|
||||||
|
func Example_asyncOfError() {
|
||||||
|
hostCh := result.AsyncOf(func() result.Expect[string] { return parseHost("") })
|
||||||
|
portCh := result.AsyncOf(func() result.Expect[int] { return parsePort("8080") })
|
||||||
|
|
||||||
|
_, err := result.Go(func() string {
|
||||||
|
host := (<-hostCh).Expect("parse host")
|
||||||
|
port := (<-portCh).Expect("parse port")
|
||||||
|
return fmt.Sprintf("http://%s:%d", host, port)
|
||||||
|
}).Unwrap()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("failed:", err)
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// failed: parse host
|
||||||
|
// example_test.go:13: host must not be empty
|
||||||
|
}
|
||||||
|
|||||||
@ -189,6 +189,36 @@ func Async[T any](fn func() T) <-chan Expect[T] {
|
|||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AsyncOf is like [Async] but for functions that already return Expect[T] —
|
||||||
|
// typically library functions that follow the layering rule. This avoids the
|
||||||
|
// double .Expect() that would otherwise be needed: one inside the goroutine to
|
||||||
|
// unwrap, and one on the channel read to re-propagate.
|
||||||
|
//
|
||||||
|
// hostCh := result.AsyncOf(resolveHost) // resolveHost() Expect[string]
|
||||||
|
// portCh := result.AsyncOf(resolvePort) // resolvePort() Expect[int]
|
||||||
|
// return result.Go(func() Config {
|
||||||
|
// host := (<-hostCh).Expect("resolve host")
|
||||||
|
// port := (<-portCh).Expect("resolve port")
|
||||||
|
// return Config{Host: host, Port: port}
|
||||||
|
// }).Unwrap()
|
||||||
|
func AsyncOf[T any](fn func() Expect[T]) <-chan Expect[T] {
|
||||||
|
ch := make(chan Expect[T], 1)
|
||||||
|
go func() {
|
||||||
|
ch <- fn()
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind partially applies a single-argument function, returning a zero-argument
|
||||||
|
// closure suitable for [AsyncOf]. This hides the closure syntax when the
|
||||||
|
// library function takes one argument:
|
||||||
|
//
|
||||||
|
// hostCh := result.AsyncOf(result.Bind(parseHost, "localhost"))
|
||||||
|
// portCh := result.AsyncOf(result.Bind(parsePort, "8080"))
|
||||||
|
func Bind[T, A any](fn func(A) Expect[T], arg A) func() Expect[T] {
|
||||||
|
return func() Expect[T] { return fn(arg) }
|
||||||
|
}
|
||||||
|
|
||||||
// Go runs fn in a new goroutine and blocks until it completes, returning the
|
// Go runs fn in a new goroutine and blocks until it completes, returning the
|
||||||
// result as Expect[T]. It is a convenience wrapper around [Async] for the
|
// result as Expect[T]. It is a convenience wrapper around [Async] for the
|
||||||
// common single-goroutine case.
|
// common single-goroutine case.
|
||||||
@ -218,6 +248,51 @@ func Run(fn func()) error {
|
|||||||
}).Err()
|
}).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All waits for each channel in order and returns the collected values, or the
|
||||||
|
// first error encountered. Remaining goroutines finish normally — their
|
||||||
|
// channels are buffered so no goroutines are leaked.
|
||||||
|
//
|
||||||
|
// Failure detection is channel-order, not wall-clock: if ch[2] fails before
|
||||||
|
// ch[0] finishes, you still wait for ch[0] first. In practice this rarely
|
||||||
|
// matters — arrange channels so the ones most likely to fail come first and
|
||||||
|
// the two orderings are equivalent.
|
||||||
|
func All[T any](chs ...<-chan Expect[T]) Expect[[]T] {
|
||||||
|
out := make([]T, len(chs))
|
||||||
|
for i, ch := range chs {
|
||||||
|
r := <-ch
|
||||||
|
if r.err != nil {
|
||||||
|
return Err[[]T](r.err)
|
||||||
|
}
|
||||||
|
out[i] = r.value
|
||||||
|
}
|
||||||
|
return Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map runs fn on each input in its own goroutine, waits for all to finish,
|
||||||
|
// and returns the results in input order. Unlike [All], every goroutine runs
|
||||||
|
// to completion — all errors are collected and returned together via
|
||||||
|
// errors.Join so callers see the full failure set.
|
||||||
|
func Map[In, Out any](inputs []In, fn func(In) Out) Expect[[]Out] {
|
||||||
|
chs := make([]<-chan Expect[Out], len(inputs))
|
||||||
|
for i, in := range inputs {
|
||||||
|
chs[i] = Async(func() Out { return fn(in) })
|
||||||
|
}
|
||||||
|
out := make([]Out, len(inputs))
|
||||||
|
var errs []error
|
||||||
|
for i, ch := range chs {
|
||||||
|
r := <-ch
|
||||||
|
if r.err != nil {
|
||||||
|
errs = append(errs, r.err)
|
||||||
|
} else {
|
||||||
|
out[i] = r.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return Err[[]Out](errors.Join(errs...))
|
||||||
|
}
|
||||||
|
return Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
// Catch recovers a panic produced by [Expect.Must] and stores it in *errp.
|
// Catch recovers a panic produced by [Expect.Must] and stores it in *errp.
|
||||||
// For normal error propagation use [Go] or [Run] instead — they collect
|
// For normal error propagation use [Go] or [Run] instead — they collect
|
||||||
// failures from [Expect.Expect] and [Expect.Expectf] regardless of build tag.
|
// failures from [Expect.Expect] and [Expect.Expectf] regardless of build tag.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user