Skip to content
catena
v1.5.0
Theme

Concurrency

Concurrency: a nightly job that turns a batch of orders into a shipping report, where every order needs two calls to other services. Sequentially that is slow in the dullest way — waiting. The parallel package runs the calls several at a time without the pipeline changing shape: a stage goes into the chain with Then, a failed call is an element so the consumer still picks the error policy, and every goroutine a stage starts has exited before the line that consumed it returns. The services here are a real HTTP server on localhost with 40ms of latency per request, so the timings printed are real.

Run it: go run ./examples/09-concurrency

// Concurrency: a nightly job that turns a batch of orders into a shipping
// report, where every order needs two calls to other services. Sequentially
// that is slow in the dullest way — waiting. The parallel package runs the
// calls several at a time without the pipeline changing shape: a stage goes
// into the chain with Then, a failed call is an element so the consumer
// still picks the error policy, and every goroutine a stage starts has
// exited before the line that consumed it returns.
//
// The services here are a real HTTP server on localhost with 40ms of
// latency per request, so the timings printed are real.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"time"
"github.com/NerdMeNot/catena"
"github.com/NerdMeNot/catena/parallel"
)
type Order struct {
ID int
Customer string
SKU string
}
type Shipment struct {
Order Order
Address string
InStock bool
}
func main() {
api := startServices()
defer api.Close()
ctx := context.Background()
orders := catena.Of(
Order{1001, "ada", "kbd-01"}, Order{1002, "bob", "mse-07"},
Order{1003, "ghost", "kbd-01"}, // no such customer
Order{1004, "cy", "mon-27"}, Order{1005, "ada", "cam-02"},
Order{1006, "dee", "kbd-01"}, Order{1007, "eve", "mse-07"},
Order{1008, "flo", "mon-27"},
)
// prepare makes the two calls an order needs and combines them. It is
// an ordinary function: it takes a context, returns a value or an
// error, and knows nothing about being run concurrently.
prepare := func(ctx context.Context, o Order) (Shipment, error) {
address, err := api.address(ctx, o.Customer)
if err != nil {
return Shipment{}, fmt.Errorf("order %d: %w", o.ID, err)
}
inStock, err := api.inStock(ctx, o.SKU)
if err != nil {
return Shipment{}, fmt.Errorf("order %d: %w", o.ID, err)
}
return Shipment{o, address, inStock}, nil
}
// Eight orders, two 40ms calls each: 640ms one at a time. Four at a
// time it is two rounds of 80ms. Map keeps the orders in order, and
// CollectAll keeps going past the order that cannot be prepared.
start := time.Now()
shipments, failures := orders.
Then(parallel.Map(ctx, 4, prepare)).
CollectAll()
fmt.Printf("prepared %d shipments in about %v (sequentially: 640ms)\n",
len(shipments), time.Since(start).Round(40*time.Millisecond))
for _, s := range shipments[:3] {
fmt.Printf(" #%d -> %-12s in stock: %v\n", s.Order.ID, s.Address, s.InStock)
}
fmt.Println("could not prepare:", failures)
// The same stage under a stricter policy. Collect stops at the first
// failure; the calls still in flight are cancelled through their
// context and have finished before Collect returns.
api.requests.Store(0)
_, err := orders.Then(parallel.Map(ctx, 4, prepare)).Collect()
fmt.Printf("strict run stopped: %v\n", err)
fmt.Printf(" it made %d of the 16 requests a full run makes\n", api.requests.Load())
// When order does not matter, MapUnordered hands each result over as
// its call finishes. Here: which SKUs are out of stock — the answer
// is a set, so nothing is gained by waiting for the slow ones in turn.
missing := catena.Of("kbd-01", "mse-07", "mon-27", "cam-02").
Then(parallel.MapUnordered(ctx, 4, func(ctx context.Context, sku string) (string, error) {
ok, err := api.inStock(ctx, sku)
if err != nil || ok {
return "", err
}
return sku, nil
})).
Ignore().
Filter(func(sku string) bool { return sku != "" }).
Collect()
fmt.Println("out of stock:", missing)
// Not every concurrent job is "the same call for many inputs".
// Sometimes it is three different calls that a page needs at once. A
// sequence of functions does that: ForEach runs them together and
// returns the first error, having cancelled the others.
var address string
var stocked bool
var recent int
start = time.Now()
err = catena.Of(
func(ctx context.Context) (err error) { address, err = api.address(ctx, "ada"); return },
func(ctx context.Context) (err error) { stocked, err = api.inStock(ctx, "kbd-01"); return },
func(ctx context.Context) (err error) { recent, err = api.recentOrders(ctx, "ada"); return },
).Then(parallel.ForEach(ctx, 3, func(ctx context.Context, load func(context.Context) error) error {
return load(ctx)
}))
fmt.Printf("dashboard for ada in about %v: %s, kbd-01 in stock: %v, %d recent orders (err: %v)\n",
time.Since(start).Round(40*time.Millisecond), address, stocked, recent, err)
// And the write side. Audit events arrive whenever they arrive, and
// the audit log wants them in bulk: up to four per write, but never
// held back more than 100ms. Chunked alone would sit on the last
// partial batch until more events came — or forever.
events := make(chan string)
go func() {
defer close(events)
for i := 1; i <= 6; i++ {
if i == 6 {
time.Sleep(300 * time.Millisecond) // a quiet spell
}
events <- fmt.Sprintf("shipped #%d", 1000+i)
}
}()
fmt.Println("audit log writes:")
for batch := range catena.FromChan(ctx, events).Then(parallel.ChunkedWithin[string](4, 100*time.Millisecond)) {
fmt.Printf(" %d events: %v\n", len(batch), batch)
}
}
// ---- the services ----
type services struct {
*httptest.Server
requests atomic.Int64
}
// startServices runs the customer, inventory and order-history services
// as one local HTTP server, each request taking 40ms.
func startServices() *services {
s := &services{}
addresses := map[string]string{
"ada": "12 Canal St", "bob": "4 Hill Rd", "cy": "9 Dock Lane",
"dee": "77 Mill Way", "eve": "3 Elm Court", "flo": "58 Quay Side",
}
outOfStock := map[string]bool{"mon-27": true}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.requests.Add(1)
select {
case <-time.After(40 * time.Millisecond):
case <-r.Context().Done(): // the client gave up: so do we
return
}
kind, name, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
switch kind {
case "customers":
address, ok := addresses[name]
if !ok {
http.Error(w, "no customer "+name, http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(address)
case "stock":
json.NewEncoder(w).Encode(!outOfStock[name])
case "history":
json.NewEncoder(w).Encode(len(name) - 1)
}
}))
return s
}
// call makes one request and decodes its JSON answer. It passes the
// context on, which is what lets a stage cancel a request it no longer
// needs.
func call[T any](ctx context.Context, s *services, path string) (T, error) {
var out T
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.URL+path, nil)
if err != nil {
return out, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return out, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return out, fmt.Errorf("%s: %s", path, resp.Status)
}
return out, json.NewDecoder(resp.Body).Decode(&out)
}
func (s *services) address(ctx context.Context, customer string) (string, error) {
return call[string](ctx, s, "/customers/"+customer)
}
func (s *services) inStock(ctx context.Context, sku string) (bool, error) {
return call[bool](ctx, s, "/stock/"+sku)
}
func (s *services) recentOrders(ctx context.Context, customer string) (int, error) {
return call[int](ctx, s, "/history/"+customer)
}

View on GitHub