fanout

package
v0.27.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package fanout carries real-time messages between replicas.

GoFastr's real-time surfaces — [event.EventBus], [island.Manager], and [stream.SSEBroker] — are per-process by default. A write on replica A notifies only the browsers connected to A. Fanout is the seam that lets those surfaces broadcast across replicas.

Lossy best-effort by design

This is the real-time lane. Implementations are permitted to drop a message published while a subscriber is disconnected, reconnecting, or queue-full. Durable, at-least-once delivery is the transactional outbox's job (framework/outbox — see "Two delivery lanes"). The two lanes are disjoint: fanout never participates in the durable path.

Loop guard

Every node publishes its messages wrapped in an envelope that carries the originator's node id (Wrap). Receivers drop any message whose node id matches their own (Unwrap) so a broadcast is not re-broadcast. The integrations (bus bridge, island manager, SSE broker) apply this guard on receive; the Fanout itself is node-agnostic.

Backends

  • InProcess — an in-memory pub/sub. Its primary purpose is tests: wiring two buses / two island managers / two brokers to one InProcess simulates two replicas inside a single test binary.

  • NewRedis — a thin adapter over a user-supplied RedisPubSub. No Redis library is imported; adapt go-redis (or redigo) in ~15 lines:

    // go-redis adapter type goRedisPubSub struct{ c *redis.Client }

    func (a goRedisPubSub) Publish(ctx context.Context, ch string, p []byte) error { return a.c.Publish(ctx, ch, p).Err() } func (a goRedisPubSub) Subscribe(ctx context.Context, ch string, fn func([]byte)) (func(), error) { sub := a.c.Subscribe(ctx, ch) ch2 := sub.Channel(redis.WithChannelSize(64)) done := make(chan struct{}) go func() { defer close(done); for m := range ch2 { fn([]byte(m.Payload)) } }() return func() { _ = sub.Close(); <-done }, nil } fanout.NewRedis(goRedisPubSub{c: client})

Trusted transport

The fanout transport is trusted input: write access to the underlying channel (a Postgres NOTIFY channel, a Redis pub/sub channel, …) equals event-injection into every replica. Payloads are not authenticated — a forged envelope published to the channel is re-emitted on every bus that subscribes to it. Secure the channel (network isolation, DB/Redis credentials) rather than relying on the fanout to reject malicious input.

For a Postgres LISTEN/NOTIFY backend see package framework/fanout.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func NewNodeID

func NewNodeID() string

NewNodeID returns 16 random bytes hex-encoded (32 chars). Random rather than counter-based avoids both global-contention and the assumption that every replica is built from the same source — two replicas that happen to both start at counter zero would loop on each other's broadcasts.

crypto/rand.Read on the default Reader does not fail on a supported platform (getrandom/SecRandomCopyBytes/RtlGenRandom), so the error is ignored — the modern idiom used elsewhere in the framework (e.g. core/middleware request ids).

func PublishQueue

func PublishQueue(f Fanout, topic string, depth int) (send func([]byte), stop func())

PublishQueue returns a non-blocking send for mirroring messages to a Fanout from hot paths (HTTP handlers, event emitters). Publishing to a real backend is a network/DB round-trip; calling Fanout.Publish inline would hand every caller an unbounded stall on a slow or wedged backend.

send enqueues into a bounded, drop-oldest queue drained by one dedicated goroutine that publishes each payload under a fixed per-publish deadline; it never blocks and never returns an error — publish failures are logged at Debug (lossy lane: the durable path is the outbox's job). After stop, send is a silent no-op; stop is prompt and safe to call multiple times.

depth <= 0 selects the default queue depth.

func SubscriberQueue

func SubscriberQueue(fn func([]byte), depth int) (send func([]byte), stop func())

SubscriberQueue wraps a subscriber callback in a per-subscriber bounded queue with drop-oldest overflow, running fn on a dedicated goroutine.

It is an implementation aid for Fanout backends whose transport delivers payloads on a SHARED goroutine — e.g. a single LISTEN/NOTIFY dispatcher (framework/fanout) or a Redis reader goroutine (NewRedis). Wrapping each subscriber in its own queue preserves the per-subscriber bounded-queue + drop-oldest contract the Fanout.Subscribe doc promises, so one slow subscriber cannot stall delivery to the others (and never blocks the shared transport goroutine). InProcess uses it internally too.

send is safe to call concurrently and NEVER blocks: when the queue is full it drops the oldest queued payload and enqueues the new one. After stop, send is a silent no-op. stop signals the goroutine to exit and returns promptly; safe to call multiple times.

depth <= 0 selects the default queue depth.

func Unwrap

func Unwrap(raw []byte) (nodeID string, body []byte, err error)

Unwrap decodes an envelope produced by Wrap, returning the originator's nodeID and the original body. It errors if raw is not a valid envelope or carries an empty node id.

func Wrap

func Wrap(nodeID string, body []byte) []byte

Wrap stamps body with the originator nodeID and returns the JSON envelope to publish to a Fanout. nodeID should come from NewNodeID.

json.Marshal of a struct of two string fields cannot fail, so the error is ignored; the returned envelope is always valid JSON decodable by Unwrap.

Types

type Fanout

type Fanout interface {
	// Publish broadcasts payload to all subscribers of topic on every node.
	// It is best-effort: a slow or absent subscriber is dropped, not
	// backpressured (unless the backend cannot help it).
	Publish(ctx context.Context, topic string, payload []byte) error

	// Subscribe registers fn for every payload published on topic by any
	// node. fn must not block; backends invoke it on a dedicated goroutine
	// with a bounded queue and drop oldest on overflow (mirroring
	// [stream.SSEBroker]). The returned cancel unregisters fn; safe to call
	// multiple times.
	Subscribe(topic string, fn func(payload []byte)) (cancel func(), err error)
}

Fanout carries real-time messages between replicas. Implementations are lossy best-effort: a message published while a subscriber is disconnected, reconnecting, or queue-full is gone. Durable delivery belongs to the transactional outbox (framework/outbox).

Payloads must be valid UTF-8: framework payloads are JSON envelopes, and a backend may reject invalid UTF-8 (e.g. the Postgres backend, which would otherwise silently corrupt such bytes via U+FFFD substitution). The fanout transport is trusted input — write access to the underlying channel equals event-injection into every replica (forged payloads are not authenticated).

func NewRedis

func NewRedis(client RedisPubSub) Fanout

NewRedis returns a Fanout backed by the supplied RedisPubSub. The caller owns the Redis client and adapts it to RedisPubSub.

type InProcess

type InProcess struct {
	// contains filtered or unexported fields
}

InProcess is an in-memory Fanout. Its primary purpose is tests: wiring two buses / two island managers / two brokers to one InProcess simulates two replicas inside a single test binary. It is fully concurrency-safe and delivers per-subscriber in publish order.

func NewInProcess

func NewInProcess(opts ...InProcessOption) *InProcess

NewInProcess returns a concurrency-safe in-memory fanout.

func (*InProcess) Publish

func (ip *InProcess) Publish(_ context.Context, topic string, payload []byte) error

Publish broadcasts payload to every subscriber of topic. It never blocks: a subscriber whose queue is full has its oldest queued message dropped to make room (mirroring [stream.SSEBroker]).

func (*InProcess) Subscribe

func (ip *InProcess) Subscribe(topic string, fn func(payload []byte)) (cancel func(), err error)

Subscribe registers fn for topic. fn runs on a dedicated goroutine per subscriber with a bounded queue; delivery is in publish order. The returned cancel unregisters fn and stops the goroutine; safe to call multiple times.

type InProcessOption

type InProcessOption func(*InProcess)

InProcessOption configures an InProcess.

func WithInProcessQueue

func WithInProcessQueue(depth int) InProcessOption

WithInProcessQueue overrides the per-subscriber bounded queue depth (default 256). A depth of 0 keeps the default. Mainly useful in tests that need to exercise the drop-oldest overflow path quickly.

type RedisPubSub

type RedisPubSub interface {
	Publish(ctx context.Context, channel string, payload []byte) error
	Subscribe(ctx context.Context, channel string, fn func(payload []byte)) (cancel func(), err error)
}

RedisPubSub is the minimal Redis surface NewRedis needs. No Redis library is imported; implement this with your preferred client (go-redis, redigo, …). See the package doc for a go-redis adapter example.

Subscribe must invoke fn for every message published on channel by ANY client, including other processes/replicas. The returned cancel stops delivery and releases the subscription.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL