Compare commits
1 Commits
main
...
concurent-
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
//
|
||||
// Reusable library code (packages under pkg/) must only *return* Expect[T] —
|
||||
// it must never call .Expect(), .Must(), or .Expectf() itself. Those methods
|
||||
// exit the current goroutine and are only safe inside a goroutine controlled
|
||||
// by [Go] or [Run].
|
||||
// The rule is simple: .Expect() is safe anywhere a boundary ([Go] or [Run])
|
||||
// owns the goroutine. In practice:
|
||||
//
|
||||
// The right split:
|
||||
//
|
||||
// - pkg/ functions: return Expect[T] — let the caller decide how to handle it.
|
||||
// - Application code (cmd/, HTTP handlers, …): chain .Expect() calls freely,
|
||||
// protected by a defer result.Catch(&err) or a result.Run wrapper.
|
||||
// - pkg/ functions that just compute and return: return Expect[T], let the
|
||||
// caller decide how to handle it.
|
||||
// - pkg/ functions that internally spawn goroutines via [Go] or [Run]: they
|
||||
// own those goroutines and may freely chain .Expect() inside them. From
|
||||
// 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
|
||||
// collected error via [StackTrace].
|
||||
@ -85,6 +85,33 @@
|
||||
// runtime.Goexit which recover() cannot intercept — use [Run] or [Go] instead,
|
||||
// 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.)
|
||||
// are not recovered — they still crash the program, as they should.
|
||||
package result
|
||||
|
||||
@ -148,3 +148,106 @@ func Example_unwrap() {
|
||||
// Output:
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// result as Expect[T]. It is a convenience wrapper around [Async] for the
|
||||
// common single-goroutine case.
|
||||
@ -218,6 +248,51 @@ func Run(fn func()) error {
|
||||
}).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.
|
||||
// For normal error propagation use [Go] or [Run] instead — they collect
|
||||
// failures from [Expect.Expect] and [Expect.Expectf] regardless of build tag.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user