Documentation
¶
Overview ¶
Package stream provides a declarative reactive pipeline for go-codex, bridging push-based transport adapters (MQTT, ZeroMQ) with governed forge.Function computations over typed Go channels.
Reactive programming paradigm ¶
Every operator in this package follows the same model:
- Source: From, FromCodec (accepts any format.Format)
- Transform: Apply (forge.Function per-item), Map (typed 1→1 with error path), Filter, Tap, MapErr, Retry, FlatMapSlice
- Fan-in/out: Merge, Tee, CombineLatest2, CombineLatest3, CombineLatest4, Zip
- Routing: Switch/SwitchKey (static named cases + rest), GroupBy (dynamic per-key sub-streams), OfType/SwitchType2/SwitchType3 (sum-type routing), SplitEither (total split of codex.Either)
- Time: Buffer (count/timeout), Window (fixed ticker), SlidingWindow, Debounce, Throttle
- Sink: Drain (safe — drains both channels), Collect
Pipelines are composed by passing Stream values between free functions:
sensors := stream.FromCodec(ctx, rawCh, format.JSON(sensorCodec), stream.SourceOptions{Observer: obs})
oeeData := stream.Apply(ctx, sensors, oeeCalcFn, stream.ApplyOptions{Observer: obs})
oeeData = stream.Tap(ctx, oeeData, func(oee OEE) { dashboard.Publish(oee) })
alerts := stream.Filter(ctx, oeeData, func(o OEE) bool { return float64(o) < 0.65 })
stream.Drain(ctx, alerts, publishAlert, logError, stream.DrainOptions{Observer: obs})
Explicit error channels ¶
Every Stream has two channels: Values for successful items and Errors for per-item errors. The stream continues after each error — a single bad sensor reading does not terminate a continuous monitoring pipeline.
Consumers MUST drain both channels concurrently to avoid goroutine leaks. Drain is the safe default sink: it handles both channels in a single select loop.
Use MapErr to recover from errors or reclassify them before the final sink.
Forge integration ¶
Apply bridges forge.Function (governed synchronous computation) into a streaming pipeline. Every item is validated by the function's input codec, processed, and validated by the output codec — the same governance that applies to batch computations also applies per-item in the stream.
Forge functions can be composed before being used in a stream:
composed := forge.Compose("c2k", "1.0.0", celsius2centi, centi2kelvin)
kelvinStream := stream.Apply(ctx, celsiusStream, composed, opts)
Two observer kinds ¶
Infrastructure metrics — how many items, latency, error counts:
opts := stream.ApplyOptions{Observer: obs} // obs implements stats.StreamObserver
Domain event observation — typed business values flowing through the pipeline:
oeeStream = stream.Tap(ctx, oeeStream, func(oee OEE) {
slog.Info("OEE computed", "value", float64(oee))
})
Both are orthogonal and composable. A pipeline can use both simultaneously.
Observer interfaces ¶
stats.StreamObserver.RecordStreamItem fires inside Apply for every item. stats.PipelineObserver.RecordApply fires separately inside forge for every item. Both fire independently — compose them via stats.NewFanout.
No external dependencies ¶
This package imports only codex, forge, format, stats, context, time, and sync — all already in the module. No RxGo or any other reactive library is needed.
Index ¶
- func Collect[T any](ctx context.Context, src Stream[T]) (values []T, errs []error)
- func Drain[T any](ctx context.Context, src Stream[T], onValue func(context.Context, T) error, ...)
- func GroupBy[T any, K comparable](ctx context.Context, src Stream[T], key func(T) K, onKey func(K, Stream[T]), ...)
- func LogOnError(logger *slog.Logger, context string) func(error)
- func SplitEither[A any, B any](ctx context.Context, src Stream[codex.Either[A, B]], opts SwitchOptions) (Stream[A], Stream[B])
- func Switch[T any](ctx context.Context, src Stream[T], cases []Case[T], opts SwitchOptions) (out []Stream[T], rest Stream[T])
- func SwitchKey[T any, K comparable](ctx context.Context, src Stream[T], keys []K, keyOf func(T) K, ...) (out []Stream[T], rest Stream[T])
- func SwitchType2[A any, B any, T any](ctx context.Context, src Stream[T], opts SwitchOptions) (Stream[A], Stream[B], Stream[T])
- func SwitchType3[A any, B any, C any, T any](ctx context.Context, src Stream[T], opts SwitchOptions) (Stream[A], Stream[B], Stream[C], Stream[T])
- func Tee[T any](ctx context.Context, src Stream[T]) (Stream[T], Stream[T])
- type ApplyOptions
- type BroadcastHub
- type Case
- type DrainOptions
- type GroupByOptions
- type MapOptions
- type SourceOptions
- type StepKind
- type Stream
- func Apply[In, Out any](ctx context.Context, src Stream[In], fn *forge.Function[In, Out], ...) Stream[Out]
- func Buffer[T any](ctx context.Context, src Stream[T], n int, maxWait time.Duration) Stream[[]T]
- func CombineLatest2[A, B, Out any](ctx context.Context, a Stream[A], b Stream[B], combine func(A, B) Out) Stream[Out]
- func CombineLatest3[A, B, C, Out any](ctx context.Context, a Stream[A], b Stream[B], c Stream[C], ...) Stream[Out]
- func CombineLatest4[A, B, C, D, Out any](ctx context.Context, a Stream[A], b Stream[B], c Stream[C], d Stream[D], ...) Stream[Out]
- func Debounce[T any](ctx context.Context, src Stream[T], d time.Duration) Stream[T]
- func Filter[T any](ctx context.Context, src Stream[T], pred func(T) bool) Stream[T]
- func FlatMapSlice[In, Out any](ctx context.Context, src Stream[In], fn func(In) []Out) Stream[Out]
- func From[T any](ctx context.Context, src <-chan T) Stream[T]
- func FromCodec[T any](ctx context.Context, src <-chan []byte, fmt format.Format[T], ...) Stream[T]
- func Map[In, Out any](ctx context.Context, src Stream[In], fn func(In) (Out, error), opts MapOptions) Stream[Out]
- func MapErr[T any](ctx context.Context, src Stream[T], fn func(error) (T, bool, error)) Stream[T]
- func Merge[T any](ctx context.Context, srcs ...Stream[T]) Stream[T]
- func OfType[U any, T any](ctx context.Context, src Stream[T]) Stream[U]
- func Retry[T any](ctx context.Context, src Stream[T], retry func(error) (T, bool, error)) Stream[T]
- func Single[T any](ctx context.Context, v T) Stream[T]
- func SlidingWindow[T any](ctx context.Context, src Stream[T], size, step int) Stream[[]T]
- func Tap[T any](ctx context.Context, src Stream[T], onValue func(T)) Stream[T]
- func Throttle[T any](ctx context.Context, src Stream[T], interval time.Duration) Stream[T]
- func Window[T any](ctx context.Context, src Stream[T], duration time.Duration) Stream[[]T]
- func Zip[A, B, Out any](ctx context.Context, a Stream[A], b Stream[B], combine func(A, B) Out) Stream[Out]
- type StreamApplyError
- type StreamDecodeError
- type StreamMapError
- type SwitchOptions
- type Topology
- func (t *Topology) Spec() TopologySpec
- func (t *Topology) WithBuffer(description string) *Topology
- func (t *Topology) WithCombineLatest(description string) *Topology
- func (t *Topology) WithDebounce(description string) *Topology
- func (t *Topology) WithDescription(desc string) *Topology
- func (t *Topology) WithFilter(description string) *Topology
- func (t *Topology) WithFlatMapSlice(description string) *Topology
- func (t *Topology) WithGroupBy(description string) *Topology
- func (t *Topology) WithMerge(description string) *Topology
- func (t *Topology) WithPort(name, description string) *Topology
- func (t *Topology) WithSink(name, description string) *Topology
- func (t *Topology) WithSlidingWindow(description string) *Topology
- func (t *Topology) WithSource(name, description string) *Topology
- func (t *Topology) WithSwitch(description string) *Topology
- func (t *Topology) WithTap(description string) *Topology
- func (t *Topology) WithTee(description string) *Topology
- func (t *Topology) WithThrottle(description string) *Topology
- func (t *Topology) WithWindow(description string) *Topology
- func (t *Topology) WithZip(description string) *Topology
- type TopologyInfo
- type TopologySpec
- type TopologyStep
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Collect ¶
Collect accumulates all values and errors from src until it terminates or ctx is cancelled, then returns two slices.
Collect is primarily intended for testing and bounded streams. For long-running or infinite streams use Drain instead.
func Drain ¶
func Drain[T any]( ctx context.Context, src Stream[T], onValue func(context.Context, T) error, onError func(error), opts DrainOptions, )
Drain consumes src until it terminates or ctx is cancelled. onValue is called for each successful item in Stream.Values. onError is called for each error in Stream.Errors AND for errors returned by onValue.
Drain ALWAYS drains both channels concurrently using a single select loop — it is the safe default sink that prevents goroutine leaks. Use Drain whenever you want to consume a stream without building your own select loop.
onError may be nil; in that case errors are silently discarded.
Example ¶
package main
import (
"context"
"fmt"
stream "github.com/DaniDeer/go-codex/stream"
)
func main() {
ctx := context.Background()
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
var sum int
stream.Drain(ctx, stream.From(ctx, ch),
func(_ context.Context, v int) error {
sum += v
return nil
},
nil,
stream.DrainOptions{},
)
fmt.Println("sum:", sum)
}
Output: sum: 6
func GroupBy ¶ added in v0.12.0
func GroupBy[T any, K comparable]( ctx context.Context, src Stream[T], key func(T) K, onKey func(K, Stream[T]), opts GroupByOptions, )
GroupBy splits src into per-key sub-streams. onKey is called once for each new key — from the GroupBy dispatch goroutine, so it must return promptly: START the per-key pipeline in it (usually a goroutine), don't run it. Each per-key Stream receives only that key's items and closes when src terminates or ctx is cancelled — the caller-owned per-key pipelines then drain out naturally.
Errors from src are forwarded to every ACTIVE per-key stream (each per-key consumer is an independent pipeline; an error is context every consumer should see — matching SinkPort fan-out semantics).
GroupBy blocks until src terminates — run it in a goroutine like SinkPort.Feed when the caller must continue concurrently:
stream.GroupBy(ctx, readings,
func(r Reading) string { return r.SensorID },
func(id string, s stream.Stream[Reading]) {
go runSensorPipeline(ctx, id, s) // caller owns the goroutine
},
stream.GroupByOptions{Buffer: 8})
The key set is unbounded by design — every distinct key allocates a sub-stream that lives until src terminates. If keys are adversarial or high-cardinality, bound them upstream (e.g. Filter) before grouping.
Example ¶
ExampleGroupBy splits a stream into per-key sub-pipelines: the callback STARTS each per-key consumer; sub-streams close when the source ends.
package main
import (
"context"
"fmt"
"sync"
stream "github.com/DaniDeer/go-codex/stream"
)
func main() {
ctx := context.Background()
src := make(chan string, 4)
src <- "a:1"
src <- "b:1"
src <- "a:2"
close(src)
var mu sync.Mutex
counts := map[string]int{}
var wg sync.WaitGroup
stream.GroupBy(ctx, stream.From(ctx, src),
func(s string) string { return s[:1] }, // key = first character
func(key string, sub stream.Stream[string]) {
wg.Add(1)
go func() {
defer wg.Done()
for range sub.Values {
mu.Lock()
counts[key]++
mu.Unlock()
}
for range sub.Errors {
}
}()
},
stream.GroupByOptions{Buffer: 2})
wg.Wait()
fmt.Println("a:", counts["a"], "b:", counts["b"])
}
Output: a: 2 b: 1
func LogOnError ¶ added in v0.12.0
LogOnError returns an `OnError func(error)` callback — the shape every adapter's `Options.OnError` field expects (`adapters/mqtt5`, `adapters/mqtt`, `adapters/nethttp`, `adapters/redis`, `adapters/websocket`, `adapters/zeromq`, `adapters/chi`, ...) — that logs err at logger, distinguishing StreamApplyError/StreamDecodeError from any other error via errors.As before falling back to a generic message. context is a short label (e.g. "alert publish") included in every log line, identifying which adapter/edge the error came from.
This is the common case every adapter's OnError ends up hand-rolling:
adaptermqtt.MQTTDrainPublishOptions{
OnError: gstream.LogOnError(logger, "alert publish"),
}
Use a custom `OnError` closure instead when you need different handling per error kind (e.g. incrementing a metric, retrying, or routing to a dead-letter queue) — LogOnError only logs.
func SplitEither ¶ added in v0.12.0
func SplitEither[A any, B any]( ctx context.Context, src Stream[codex.Either[A, B]], opts SwitchOptions, ) (Stream[A], Stream[B])
SplitEither splits a stream of codex.Either values into its two typed branches — the codec-native alternative to SwitchType2 when the boundary decoded a wire-level union via codex.Either2. The split is TOTAL: every item is Left or Right, so there is no rest stream (a strictly stronger contract than SwitchType2, and no interface type required).
Errors from src are forwarded to BOTH branches (independent consumers — GroupBy fan-out semantics). Both branches close when src terminates.
// boundary decodes into the sum; the pipeline splits it — typed end to end
created, cancelled := stream.SplitEither(ctx, events, stream.SwitchOptions{})
func Switch ¶ added in v0.12.0
func Switch[T any]( ctx context.Context, src Stream[T], cases []Case[T], opts SwitchOptions, ) (out []Stream[T], rest Stream[T])
Switch routes each item to the FIRST case whose predicate matches; items matching no case go to the returned default stream. All case streams exist up front — the case set is static, unlike GroupBy's dynamic keys — and out[i] corresponds to cases[i] (positional, compile-time checkable at call sites; names serve observability and topology).
Errors from src are forwarded to the default stream only (single ownership — no duplicate error handling across cases). All streams close when src terminates or ctx is cancelled.
Malformed cases (empty or duplicate Name, nil When) panic at call time — programming errors, not runtime conditions.
caseStreams, rest := stream.Switch(ctx, readings, []stream.Case[Reading]{
{Name: "alert", When: func(r Reading) bool { return r.Value > 90 }},
{Name: "warning", When: func(r Reading) bool { return r.Value > 70 }},
}, stream.SwitchOptions{})
go alertsPort.Feed(ctx, caseStreams[0])
go warningsPort.Feed(ctx, caseStreams[1])
go archivePort.Feed(ctx, rest)
Example ¶
ExampleSwitch routes readings into alert/warning cases with a default (archive) stream — first match wins, positional outputs.
package main
import (
"context"
"fmt"
stream "github.com/DaniDeer/go-codex/stream"
)
func main() {
ctx := context.Background()
src := make(chan int, 3)
src <- 95
src <- 75
src <- 20
close(src)
out, rest := stream.Switch(ctx, stream.From(ctx, src), []stream.Case[int]{
{Name: "alert", When: func(v int) bool { return v > 90 }},
{Name: "warning", When: func(v int) bool { return v > 70 }},
}, stream.SwitchOptions{Buffer: 4})
alerts, _ := stream.Collect(ctx, out[0])
warnings, _ := stream.Collect(ctx, out[1])
archive, _ := stream.Collect(ctx, rest)
fmt.Println("alerts:", alerts)
fmt.Println("warnings:", warnings)
fmt.Println("archive:", archive)
}
Output: alerts: [95] warnings: [75] archive: [20]
func SwitchKey ¶ added in v0.12.0
func SwitchKey[T any, K comparable]( ctx context.Context, src Stream[T], keys []K, keyOf func(T) K, opts SwitchOptions, ) (out []Stream[T], rest Stream[T])
SwitchKey routes each item by key to the case stream whose key matches — the keyed sibling of Switch (predicates) and the static sibling of GroupBy (dynamic keys). keys is the declared case set; out[i] corresponds to keys[i]. Items whose key is not in keys — and src errors — go to rest.
SwitchKey pairs naturally with codex.TaggedUnion: declare the discriminator as a NAMED function once and pass it to both the codec and the router — wire format, schema, spec, and routing then share one source of truth:
func orderEventKind(e OrderEvent) string { return e.Kind }
var orderEventCodec = codex.TaggedUnion("kind", variants,
func(e OrderEvent) (string, error) { return orderEventKind(e), nil })
streams, rest := stream.SwitchKey(ctx, events,
[]string{"created", "cancelled"}, orderEventKind, stream.SwitchOptions{})
func SwitchType2 ¶ added in v0.12.0
func SwitchType2[A any, B any, T any]( ctx context.Context, src Stream[T], opts SwitchOptions, ) (Stream[A], Stream[B], Stream[T])
SwitchType2 routes a sum-typed stream into two TYPED case streams plus the untyped rest — the pattern-matching shape Go can express without variadic type parameters. First match wins (A before B — relevant when types overlap via embedding); items of other types and src errors go to rest.
For more cases use SwitchType3 or compose on the rest stream — the same nesting guidance as CombineLatest beyond 4 sources.
created, cancelled, other := stream.SwitchType2[OrderCreated, OrderCancelled](ctx, events, opts)
func SwitchType3 ¶ added in v0.12.0
func SwitchType3[A any, B any, C any, T any]( ctx context.Context, src Stream[T], opts SwitchOptions, ) (Stream[A], Stream[B], Stream[C], Stream[T])
SwitchType3 routes a sum-typed stream into three TYPED case streams plus the untyped rest. Same contract as SwitchType2: first match wins (A, then B, then C); other types and src errors go to rest.
func Tee ¶
Tee splits src into two independent copies. Both copies receive all items and errors. Backpressure on either copy blocks the other — use buffered channels (via SourceOptions.Buffer on the original source) or Drain if the two consumers run at different speeds.
Use Tee when you need the same stream for two independent purposes (e.g. storing to a database while also computing KPIs):
store, compute := stream.Tee(ctx, sensorStream) go stream.Drain(ctx, store, saveToDB, logErr, opts) oeeStream := stream.Apply(ctx, compute, oeeCalcFn, opts)
Types ¶
type ApplyOptions ¶
type ApplyOptions struct {
// Observer receives [stats.StreamObserver.RecordStreamItem] for every item
// processed by Apply (success or failure).
// [stats.PipelineObserver.RecordApply] fires separately inside
// [forge.Function.Apply] — both observers fire independently.
// Defaults to [stats.NoopObserver] when nil.
Observer stats.Observer
// Buffer is the output Values and Errors channel buffer size. Default 0.
Buffer int
}
ApplyOptions configures Apply.
type BroadcastHub ¶
type BroadcastHub[T any] struct { // contains filtered or unexported fields }
BroadcastHub fans out a single Stream source to N independent subscribers. Each subscriber receives its own Stream with a private buffered channel. Slow subscribers apply backpressure only to themselves — items are dropped for a full subscriber buffer rather than blocking the hub goroutine.
Create a hub and subscribe before the source begins emitting:
hub := stream.NewBroadcastHub(ctx, oeeStream, 32) sub1 := hub.Subscribe() sub2 := hub.Subscribe()
Unsubscribe when a subscriber is no longer needed:
hub.Unsubscribe(sub1)
The hub runs until ctx is cancelled or the source stream terminates. All subscriber channels are closed when the hub exits.
Example ¶
package main
import (
"context"
stream "github.com/DaniDeer/go-codex/stream"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
hub := stream.NewBroadcastHub(ctx, stream.From(ctx, ch), 8)
sub1 := hub.Subscribe()
sub2 := hub.Subscribe()
done1 := make(chan []int, 1)
done2 := make(chan []int, 1)
go func() { v, _ := stream.Collect(ctx, sub1); done1 <- v }()
go func() { v, _ := stream.Collect(ctx, sub2); done2 <- v }()
<-done1
<-done2
}
Output:
func NewBroadcastHub ¶
func NewBroadcastHub[T any](ctx context.Context, src Stream[T], bufPerSubscriber int) *BroadcastHub[T]
NewBroadcastHub creates a BroadcastHub that reads from src and fans out every value and error to all current subscribers.
bufPerSubscriber is the buffer size for each subscriber's Values and Errors channels. A buffer of 0 is unbuffered — the hub blocks until the subscriber reads each item. Use a positive buffer to prevent slow subscribers from stalling each other.
The hub goroutine terminates when ctx is cancelled or src closes. All subscriber channels are then closed so downstream consumers drain cleanly.
func (*BroadcastHub[T]) Subscribe ¶
func (h *BroadcastHub[T]) Subscribe() Stream[T]
Subscribe adds a new subscriber and returns its Stream. The returned stream's channels are buffered with the hub's configured bufPerSubscriber size. The channels are closed when the hub exits. Subscribe is safe to call concurrently.
func (*BroadcastHub[T]) Unsubscribe ¶
func (h *BroadcastHub[T]) Unsubscribe(s Stream[T])
Unsubscribe removes a subscriber. The subscriber's channels are not closed by Unsubscribe — they close when the hub exits. After Unsubscribe the hub stops sending to those channels.
type Case ¶ added in v0.12.0
type Case[T any] struct { // Name labels the case in observer events and topology steps. Name string // When selects items for this case. When func(T) bool }
Case is one Switch branch: a name (observability, topology, and error identity) and a predicate. Build one from a codex.Constraint with CaseConstraint — the same declarative rule then serves wire validation, the spec, and routing.
func CaseConstraint ¶ added in v0.12.0
func CaseConstraint[T any](name string, c codex.Constraint[T]) Case[T]
CaseConstraint adapts a codex.Constraint into a Case — the validation vocabulary doubles as routing predicates:
hot := stream.CaseConstraint("hot", domain.HotReading(cfg.Threshold))
type DrainOptions ¶
type DrainOptions struct {
// Observer receives [stats.Observer.RecordValidationError] for errors returned
// by onValue. Defaults to [stats.NoopObserver] when nil.
Observer stats.Observer
}
DrainOptions configures Drain.
type GroupByOptions ¶ added in v0.12.0
type GroupByOptions struct {
// Buffer is each per-key channel's buffer. Default 0. A slow per-key
// consumer backpressures the WHOLE GroupBy (single dispatch goroutine) —
// buffer accordingly.
Buffer int
// Observer receives per-item [stats.StreamObserver.RecordStreamItem]
// events with location "groupby". Nil means resolved from ctx.
Observer stats.Observer
}
GroupByOptions configures GroupBy.
type MapOptions ¶ added in v0.12.0
type MapOptions struct {
// Name identifies the mapping step in [StreamMapError] and observer
// events. Default "map".
Name string
// Observer receives per-item [stats.StreamObserver.RecordStreamItem]
// events. When nil, resolved from ctx.
Observer stats.Observer
// Buffer is the output channel buffer size. Default 0.
Buffer int
}
MapOptions configures Map.
type SourceOptions ¶
type SourceOptions struct {
// Name identifies this source in [StreamDecodeError] for structured logging.
// Defaults to "stream" when empty.
Name string
// Observer receives [stats.Observer.RecordValidationError] for per-field decode
// failures. Defaults to [stats.NoopObserver] when nil.
Observer stats.Observer
// Buffer is the Values and Errors channel buffer size. Default 0 (unbuffered).
Buffer int
}
SourceOptions configures FromCodec.
type StepKind ¶
type StepKind string
StepKind identifies the type of a pipeline step.
const ( // StepKindSource is a data source (MQTT channel, Go channel, file, etc.). StepKindSource StepKind = "source" // StepKindApply is a forge.Function applied per-item. StepKindApply StepKind = "apply" // StepKindFilter retains only items matching a predicate. StepKindFilter StepKind = "filter" // StepKindTap observes items without transforming them. StepKindTap StepKind = "tap" // StepKindBuffer batches items by count or time window. StepKindBuffer StepKind = "buffer" // StepKindDebounce emits only after a silence window. StepKindDebounce StepKind = "debounce" // StepKindThrottle rate-limits item emission. StepKindThrottle StepKind = "throttle" // StepKindMerge fan-in from multiple sources. StepKindMerge StepKind = "merge" // StepKindTee splits a stream into two copies. StepKindTee StepKind = "tee" // StepKindWindow collects items into fixed-interval time windows. StepKindWindow StepKind = "window" // StepKindSlidingWindow collects items into overlapping count-based windows. StepKindSlidingWindow StepKind = "slidingWindow" // StepKindCombineLatest merges the latest values from multiple sources. StepKindCombineLatest StepKind = "combineLatest" // StepKindZip pairs items from two streams by position. StepKindZip StepKind = "zip" // StepKindFlatMapSlice expands each item into multiple output items. StepKindFlatMapSlice StepKind = "flatMapSlice" // StepKindPort is an IO hop through a ports port (e.g. persistence or // enrichment via an IOPort, submission to a SinkPort). StepKindPort StepKind = "port" // StepKindSwitch routes items into static named cases ([Switch]/[SwitchKey]). StepKindSwitch StepKind = "switch" // StepKindGroupBy splits the stream into dynamic per-key sub-streams ([GroupBy]). StepKindGroupBy StepKind = "groupBy" // StepKindSink consumes items and errors. StepKindSink StepKind = "sink" )
type Stream ¶
type Stream[T any] struct { // Values carries successfully processed items. Values <-chan T // Errors carries per-item errors. The stream continues after each error — // a failing item does not terminate the pipeline. Use [MapErr] to recover // from errors or reclassify them. Errors <-chan error }
Stream[T] is a typed reactive stream with explicit error separation.
Values carries successful items; Errors carries per-item errors. Both channels are closed when the stream terminates (source closed or context cancelled).
Consumers MUST drain both channels concurrently — reading only from Values while ignoring Errors will cause the goroutine writing to Errors to block, leaking resources. Use Drain as the safe default sink: it handles both channels in a single select loop.
The nil-channel pattern is used throughout the package to disable a channel once it is closed, preventing accidental reads from a closed channel in select statements. All operators follow this convention internally.
func Apply ¶
func Apply[In, Out any]( ctx context.Context, src Stream[In], fn *forge.Function[In, Out], opts ApplyOptions, ) Stream[Out]
Apply applies fn to every value in src using forge.Function.ApplyContext.
All forge validation — input codec Refine, optional WithRefinement, compute function, output codec Refine — runs per item. Successful outputs go to Stream.Values. Validation or compute failures are wrapped in StreamApplyError and sent to Stream.Errors. The stream continues after each error.
stats.PipelineObserver.RecordApply fires inside forge for every item. If opts.Observer also implements stats.StreamObserver, RecordStreamItem fires for every item with the forge function name, success flag, and duration.
The forge function's own observer (set via forge.Function.Register) fires independently — both observers can be active simultaneously.
Example ¶
package main
import (
"context"
"fmt"
"github.com/DaniDeer/go-codex/codex"
"github.com/DaniDeer/go-codex/forge"
stream "github.com/DaniDeer/go-codex/stream"
)
func main() {
ctx := context.Background()
double := forge.NewFunction("double", "1.0.0",
codex.Float64().WithTitle("input"),
codex.Float64().WithTitle("doubled"),
func(v float64) (float64, error) { return v * 2, nil },
)
ch := make(chan float64, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
s := stream.Apply(ctx, stream.From(ctx, ch), double, stream.ApplyOptions{})
vals, _ := stream.Collect(ctx, s)
for _, v := range vals {
fmt.Printf("%.0f\n", v)
}
}
Output: 2 4 6
func Buffer ¶
Buffer collects up to n value items (or until maxWait elapses since the last emission) and emits them as a batch []T. Errors from src are forwarded to the output Stream.Errors immediately, without buffering.
Use Buffer to integrate item-at-a-time streams with forge.Map (which takes []T):
batchStream := stream.Buffer(ctx, sensorStream, 10, 500*time.Millisecond) batchOEE := stream.Apply(ctx, batchStream, batchOEECalc, opts)
func CombineLatest2 ¶
func CombineLatest2[A, B, Out any]( ctx context.Context, a Stream[A], b Stream[B], combine func(A, B) Out, ) Stream[Out]
CombineLatest2 merges the latest value from two independent streams using combine. A new combined value is emitted whenever either source emits a new value. The output stream blocks until both sources have emitted at least one value. Errors from either source are forwarded to the output Stream.Errors.
Use CombineLatest2 to feed a forge.Function that takes a 2-field input struct, where the two fields arrive on independent streams:
oeeInputs := stream.CombineLatest2(ctx, availStream, perfStream,
func(a Availability, p Performance) OEEIn { return OEEIn{a, p} })
oeeStream := stream.Apply(ctx, oeeInputs, oeeCalcFn, opts)
func CombineLatest3 ¶
func CombineLatest3[A, B, C, Out any]( ctx context.Context, a Stream[A], b Stream[B], c Stream[C], combine func(A, B, C) Out, ) Stream[Out]
CombineLatest3 merges the latest value from three independent streams using combine. A new combined value is emitted whenever any source emits a new value, after all three sources have emitted at least one value. Errors from any source are forwarded to the output Stream.Errors.
Use CombineLatest3 to feed a forge function with a 3-field input struct where each field arrives on a separate stream (e.g. OEE = Availability × Performance × Quality):
oeeInputs := stream.CombineLatest3(ctx, availStream, perfStream, qualStream,
func(a Availability, p Performance, q Quality) OEEIn {
return OEEIn{Availability: a, Performance: p, Quality: q}
})
oeeStream := stream.Apply(ctx, oeeInputs, oeeCalcFn, opts)
func CombineLatest4 ¶
func CombineLatest4[A, B, C, D, Out any]( ctx context.Context, a Stream[A], b Stream[B], c Stream[C], d Stream[D], combine func(A, B, C, D) Out, ) Stream[Out]
CombineLatest4 merges the latest value from four independent streams. Same semantics as CombineLatest3 extended to four sources.
func Debounce ¶
Debounce emits a value only when src.Values is silent for at least d. Intermediate values during the silence window are dropped; only the last value before the silence elapses is emitted. Error items are forwarded to Stream.Errors immediately.
Use Debounce when only the final value of a burst matters — for example, sensor readings that settle after a transient spike.
func Filter ¶
Filter keeps value items where pred returns true. Value items for which pred returns false are dropped silently. Error items are forwarded to the output Stream.Errors unchanged.
func FlatMapSlice ¶
FlatMapSlice maps each value item to a []Out slice and emits each element of the slice individually to the output Stream.Values. An empty slice from fn produces no output items (filter-like behaviour for that item). Errors from src pass through to Stream.Errors unchanged.
Use FlatMapSlice when one incoming item should expand into multiple outgoing items — for example, one batch record expanding into N individual readings, or one sensor event triggering multiple derived measurements.
Unlike a hypothetical FlatMap[In, Stream[Out]], FlatMapSlice requires no goroutine pool: fn is called synchronously per item.
func From ¶
From wraps a typed channel as a Stream. Each value received from src becomes a value item. When src is closed or ctx is cancelled, both Stream channels are closed.
The returned Stream.Errors channel is never written — it closes when the stream terminates. This is intentional: From is a type-safe source with no error path. Use FromCodec when decode failures must be captured.
intCh := make(chan int, 3)
intCh <- 1; intCh <- 2; intCh <- 3; close(intCh)
s := stream.From(ctx, intCh)
stream.Drain(ctx, s, func(_ context.Context, v int) error {
fmt.Println(v)
return nil
}, nil, stream.DrainOptions{})
Example ¶
package main
import (
"context"
"fmt"
stream "github.com/DaniDeer/go-codex/stream"
)
func main() {
ctx := context.Background()
ch := make(chan int, 3)
ch <- 10
ch <- 20
ch <- 30
close(ch)
s := stream.From(ctx, ch)
vals, _ := stream.Collect(ctx, s)
for _, v := range vals {
fmt.Println(v)
}
}
Output: 10 20 30
func FromCodec ¶
func FromCodec[T any](ctx context.Context, src <-chan []byte, fmt format.Format[T], opts SourceOptions) Stream[T]
FromCodec decodes raw []byte payloads from src using the given format. Successful decodes go to Stream.Values. Decode or validation failures go to Stream.Errors as StreamDecodeError.
Pass any format.Format value — JSON, YAML, TOML, or a custom format:
sensors := stream.FromCodec(ctx, rawCh, format.JSON(sensorCodec),
stream.SourceOptions{Name: "mqtt/sensors/+", Observer: obs})
sensors := stream.FromCodec(ctx, rawCh, format.YAML(sensorCodec),
stream.SourceOptions{Name: "mqtt/sensors/+", Observer: obs})
Use with MQTT or ZeroMQ SubscribeHandlers that write raw payloads to a channel:
rawCh := make(chan []byte, 64)
mqttClient.Subscribe("sensors/+/data", 1,
adaptermqtt.SubscribeHandler(ctx, handle, func(_ context.Context, raw []byte) error {
select { case rawCh <- raw: default: }
return nil
}, adaptermqtt.SubscribeOptions{}))
sensors := stream.FromCodec(ctx, rawCh, format.JSON(sensorCodec),
stream.SourceOptions{Name: "mqtt/sensors/+", Observer: obs})
Example ¶
package main
import (
"context"
"fmt"
"github.com/DaniDeer/go-codex/codex"
"github.com/DaniDeer/go-codex/format"
stream "github.com/DaniDeer/go-codex/stream"
"github.com/DaniDeer/go-codex/validate"
)
type reading struct {
Sensor string
Value float64
}
var readingCodec = codex.Struct(
codex.RequiredField("sensor",
codex.String().Refine(validate.NonEmptyString),
func(r reading) string { return r.Sensor },
func(r *reading, v string) { r.Sensor = v }),
codex.RequiredField("value",
codex.Float64(),
func(r reading) float64 { return r.Value },
func(r *reading, v float64) { r.Value = v }),
)
func main() {
ctx := context.Background()
rawCh := make(chan []byte, 2)
rawCh <- []byte(`{"sensor":"s1","value":23.5}`)
rawCh <- []byte(`{"sensor":"s2","value":87.3}`)
close(rawCh)
s := stream.FromCodec(ctx, rawCh, format.JSON(readingCodec),
stream.SourceOptions{Name: "example"})
vals, errs := stream.Collect(ctx, s)
fmt.Println(len(vals), "values,", len(errs), "errors")
}
Output: 2 values, 0 errors
func Map ¶ added in v0.12.0
func Map[In, Out any]( ctx context.Context, src Stream[In], fn func(In) (Out, error), opts MapOptions, ) Stream[Out]
Map transforms each value item with fn — the typed 1→1 counterpart of FlatMapSlice with an error path. When fn returns an error, the item is dropped and a StreamMapError is sent to Stream.Errors; upstream errors are forwarded unchanged.
Use Map for plain typed transformations that need error handling but not the governance ceremony of a forge.Function + Apply (name, version, contract hash). For governed pipeline steps, keep using Apply.
func MapErr ¶
MapErr transforms errors in Stream.Errors, enabling error recovery or reclassification.
fn receives each error and returns one of:
- (value, true, nil) — recover: emit value to Stream.Values
- (zero, false, err) — reclassify: emit new error to Stream.Errors
- (zero, false, nil) — silence: drop the error entirely
Value items pass through to the output Stream.Values unchanged.
Use MapErr for dead-lettering, retry-after-transformation, or silencing expected transient errors:
recovered := stream.MapErr(ctx, src, func(err error) (T, bool, error) {
var sde stream.StreamDecodeError
if errors.As(err, &sde) && isTransient(sde.Err) {
return zero, false, nil // silence transient decode errors
}
return zero, false, err // re-emit all other errors
})
func Merge ¶
Merge combines multiple streams into one. Items and errors from all source streams are forwarded as they arrive. The output stream terminates when all source streams have terminated.
Use Merge to combine readings from multiple sensors or topics into a single processing pipeline.
func OfType ¶ added in v0.12.0
OfType filters src to items whose dynamic type is U, emitting them as a typed Stream[U]. Meaningful when T is an interface (sum type) — in a concretely-typed stream every item already IS a T. Items of other types are dropped silently; errors are forwarded. The single-case building block — for multi-case routing use SwitchType2/SwitchType3.
The observer is resolved from ctx (stats.ObserverFromContext) and receives RecordStreamItem with location "oftype" per matched item.
events := … // Stream[DomainEvent] (interface) created := stream.OfType[OrderCreated](ctx, events)
func Retry ¶
Retry transforms error items, enabling recovery or reclassification with caller-controlled retry logic. Successful value items pass through unchanged.
retry receives each error and returns one of:
- (value, true, nil) — recover: emit value to Stream.Values
- (zero, false, err) — reclassify: emit new error to Stream.Errors
- (zero, false, nil) — silence: drop the error entirely
The caller's retry function controls timing and backoff. For exponential backoff:
retried := stream.Retry(ctx, src, func(err error) (T, bool, error) {
var sde stream.StreamDecodeError
if errors.As(err, &sde) {
time.Sleep(100 * time.Millisecond) // simple backoff
return zero, false, err // reclassify for a retry queue
}
return zero, false, nil // silence unrecoverable errors
})
Retry is a specialisation of MapErr for the common case where the retry function needs a concise name. Callers who need the full (value,isValue,err) tuple directly should use MapErr.
func Single ¶
Single wraps a single value as a Stream that emits v once, then terminates. The Stream.Errors channel is never written.
Use Single to start a per-request pipeline inside a [PipelineHandlerFunc] or [AsPipelineFunc], or any time you need a bounded one-shot stream source:
s := stream.Single(ctx, req)
out := stream.Apply(ctx, s, computeFn, stream.ApplyOptions{Observer: obs})
out = stream.Tap(ctx, out, func(v Out) { slog.Info("computed", "v", v) })
Example ¶
package main
import (
"context"
"fmt"
stream "github.com/DaniDeer/go-codex/stream"
)
func main() {
ctx := context.Background()
s := stream.Single(ctx, 7)
vals, _ := stream.Collect(ctx, s)
fmt.Println(vals[0])
}
Output: 7
func SlidingWindow ¶
SlidingWindow emits a []T slice every step items, containing the last size items. The window slides forward by step items on each emission. When step == size the windows are non-overlapping (tumbling). Requires step > 0 and size >= step.
Errors from src are forwarded to Stream.Errors immediately without affecting the sliding window position.
func Tap ¶
Tap inserts a domain event observer on the value channel without transforming items. onValue is called for each successful value; the value is then forwarded unchanged. Error items are forwarded to the output Stream.Errors unchanged.
Use Tap for domain-level observation — auditing, triggering side effects, logging application-level events — independently from infrastructure metrics:
oeeStream = stream.Tap(ctx, oeeStream, func(oee OEE) {
slog.Info("OEE computed", "value", float64(oee))
businessDashboard.Publish(oee)
})
Example ¶
package main
import (
"context"
"fmt"
stream "github.com/DaniDeer/go-codex/stream"
)
func main() {
ctx := context.Background()
ch := make(chan int, 3)
ch <- 10
ch <- 20
ch <- 30
close(ch)
var observed []int
s := stream.Tap(ctx, stream.From(ctx, ch), func(v int) {
observed = append(observed, v)
})
stream.Collect(ctx, s)
fmt.Println(len(observed), "items observed")
}
Output: 3 items observed
func Throttle ¶
Throttle emits at most one value per interval, dropping intermediates. The first value in an interval is emitted; subsequent values arriving before the interval elapses are dropped. Error items are forwarded to Stream.Errors immediately.
Use Throttle to rate-limit high-frequency sources while ensuring at least one value is emitted per interval.
func Window ¶
Window emits all values collected during each fixed-duration time window as a []T slice. An empty slice is emitted when no items arrived during a window. Errors from src are forwarded immediately.
Unlike Buffer, Window always emits at fixed calendar-aligned intervals using time.NewTicker — the emission clock never resets when items arrive. This gives consistent time boundaries (e.g. "all readings in the past 1 minute, every minute") suitable for time-series analytics.
func Zip ¶
func Zip[A, B, Out any]( ctx context.Context, a Stream[A], b Stream[B], combine func(A, B) Out, ) Stream[Out]
Zip pairs items from two streams by position: (a[0],b[0]), (a[1],b[1]), ... A combined item is emitted when both sources have emitted their n-th value. If one source emits faster, the faster source's items are buffered internally. Errors from either source are forwarded to the output Stream.Errors immediately.
Unlike CombineLatest2, which emits on every update using the latest values, Zip waits for matched pairs in order.
type StreamApplyError ¶
type StreamApplyError struct {
// Function is the forge function name (from [forge.FunctionSpec.Name]).
Function string
// Err is the inner forge error. Use errors.As to reach
// forge.InputError, forge.OutputError, or forge.ApplyError.
Err error
}
StreamApplyError is sent to Stream.Errors by Apply when forge.Function.Apply fails. The inner Err is always a typed forge error (forge.InputError, forge.OutputError, forge.ApplyError, etc.) and is reachable via errors.As.
StreamApplyError implements slog.LogValuer for structured logging:
slog.Warn("apply failed", "error", sae)
// → {function:"oeeCalc", err:{...}}
func (StreamApplyError) Error ¶
func (e StreamApplyError) Error() string
func (StreamApplyError) LogValue ¶
func (e StreamApplyError) LogValue() slog.Value
LogValue implements slog.LogValuer for structured logging.
type StreamDecodeError ¶
type StreamDecodeError struct {
// Source identifies the stream source (from [SourceOptions.Name]).
Source string
// Err is the underlying codec error (e.g. [codex.ValidationErrors]).
Err error
}
StreamDecodeError is sent to Stream.Errors by FromCodec when a raw payload fails codec decode or Refine constraints.
StreamDecodeError implements slog.LogValuer for structured logging:
slog.Warn("decode failed", "error", sde)
// → {source:"mqtt/sensors/+", err:{...}}
func (StreamDecodeError) Error ¶
func (e StreamDecodeError) Error() string
func (StreamDecodeError) LogValue ¶
func (e StreamDecodeError) LogValue() slog.Value
LogValue implements slog.LogValuer for structured logging.
type StreamMapError ¶ added in v0.12.0
type StreamMapError struct {
// Name identifies the mapping step (from [MapOptions.Name]).
Name string
// Err is the error returned by the mapping function.
Err error
}
StreamMapError is sent to Stream.Errors by Map when the mapping function returns an error. Name is the MapOptions.Name (default "map").
StreamMapError implements slog.LogValuer for structured logging:
slog.Warn("map failed", "error", sme)
// → {name:"buildResult", err:{...}}
func (StreamMapError) Error ¶ added in v0.12.0
func (e StreamMapError) Error() string
func (StreamMapError) LogValue ¶ added in v0.12.0
func (e StreamMapError) LogValue() slog.Value
LogValue implements slog.LogValuer for structured logging.
type SwitchOptions ¶ added in v0.12.0
type SwitchOptions struct {
// Buffer is each output channel's buffer. Default 0.
Buffer int
// Observer receives per-item [stats.StreamObserver.RecordStreamItem]
// events (location = the case name / key / type-case index). Nil means
// resolved from ctx.
Observer stats.Observer
}
SwitchOptions configures Switch, SwitchKey, SwitchType2, SwitchType3, and SplitEither.
type Topology ¶
type Topology struct {
// contains filtered or unexported fields
}
Topology is a declarative builder for a TopologySpec. Chain builder methods to describe the pipeline, then call Topology.Spec to produce the machine-readable spec.
Usage mirrors forge.Registry:
topo := stream.NewTopology("Sensor OEE Pipeline", "1.0.0").
WithDescription("Real-time OEE from MQTT sensor readings.").
WithSource("mqtt/sensors/+/data", "Decoded sensor readings").
WithFilter("oee < 0.65").
WithSink("mqtt/alerts/oee", "Low-OEE alerts")
stream.WithApply(topo, oeeCalcFn) // free function — Go generics cannot add type params to methods
yaml, _ := streamrender.Render(topo.Spec())
func NewTopology ¶
NewTopology returns a new Topology with the given title and version.
Example ¶
package main
import (
"github.com/DaniDeer/go-codex/codex"
"github.com/DaniDeer/go-codex/forge"
stream "github.com/DaniDeer/go-codex/stream"
)
var topoFn = forge.NewFunction("oeeCalc", "1.0.0",
codex.Float64().WithTitle("oee"),
codex.Float64().WithTitle("grade"),
func(v float64) (float64, error) { return v * 100, nil },
)
func main() {
topo := stream.NewTopology("Sensor Pipeline", "1.0.0").
WithDescription("Real-time sensor processing pipeline.").
WithSource("mqtt/sensors/+/data", "Raw sensor readings from MQTT").
WithFilter("value > 0").
WithTap("dashboard observer").
WithSink("mqtt/alerts", "OEE alert publisher")
stream.WithApply(topo, topoFn)
spec := topo.Spec()
_ = spec // pass spec to render/stream.Render to get YAML
}
Output:
func WithApply ¶
WithApply records an apply step from a forge.Function. The function's name, version, hash, and description are captured from its forge.FunctionSpec.
func (*Topology) Spec ¶
func (t *Topology) Spec() TopologySpec
Spec returns the accumulated TopologySpec.
func (*Topology) WithBuffer ¶
WithBuffer records a buffer (windowing) step.
func (*Topology) WithCombineLatest ¶
WithCombineLatest records a CombineLatest step (merges latest values from multiple sources).
func (*Topology) WithDebounce ¶
WithDebounce records a debounce step.
func (*Topology) WithDescription ¶
WithDescription sets the pipeline-level description and returns t for chaining.
func (*Topology) WithFilter ¶
WithFilter records a filter step with a human-readable description of the predicate.
func (*Topology) WithFlatMapSlice ¶
WithFlatMapSlice records a FlatMapSlice step (expands each item into multiple items).
func (*Topology) WithGroupBy ¶ added in v0.12.0
WithGroupBy records a dynamic per-key split step (GroupBy) with a human-readable description of the key (e.g. "by sensorID").
func (*Topology) WithMerge ¶
WithMerge records a merge (fan-in) step combining multiple source streams.
func (*Topology) WithPort ¶ added in v0.12.0
WithPort records an IO-port step: an IO hop through a ports port inside the pipeline (persistence or enrichment via an IOPort, submission to a SinkPort). Name is the port name (e.g. "sql/readings/save"); description explains the hop.
func (*Topology) WithSlidingWindow ¶
WithSlidingWindow records a sliding window step (overlapping count-based windows).
func (*Topology) WithSource ¶
WithSource records a source step (e.g. an MQTT topic, a file path, a typed channel).
func (*Topology) WithSwitch ¶ added in v0.12.0
WithSwitch records a static case-routing step (Switch/SwitchKey) with a human-readable description of the cases (e.g. "alert | warning | archive").
func (*Topology) WithTee ¶
WithTee records a tee (fan-out) step splitting one stream into two copies.
func (*Topology) WithThrottle ¶
WithThrottle records a throttle step.
func (*Topology) WithWindow ¶
WithWindow records a window step (fixed-interval tumbling time windows).
type TopologyInfo ¶
TopologyInfo is pipeline-level metadata for a stream topology.
type TopologySpec ¶
type TopologySpec struct {
Info TopologyInfo
Steps []TopologyStep
}
TopologySpec is the full machine-readable description of a stream pipeline. Use render/stream.Render to serialise it as YAML.
type TopologyStep ¶
type TopologyStep struct {
// Kind identifies the operator type.
Kind StepKind
// Name is a human-readable label for this step.
// For source/sink steps this is typically the channel address or topic.
// For apply steps it is the forge function name.
Name string
// Description is an optional longer human-readable description.
Description string
// Function carries governance metadata when Kind == StepKindApply.
// Nil for all other step kinds.
Function *forge.FunctionSpec
}
TopologyStep describes one step in a stream pipeline.