Documentation
¶
Overview ¶
Package messaging provides a transport-agnostic, strongly-typed asynchronous messaging library for FOI platform services, built on Watermill and Redis Streams.
See docs/foi-messaging-go-prd-v1.1.md for the full design. The publish path (EventDef, Envelope, Config, and Publisher) and the consume path (Consumer, Handler, and routing) are implemented, as is the failure path: handlers classify errors with AsPermanent, AsRetryable, or AsDiscard, a retryable failure is retried in-process with exponential backoff and full jitter before the message nacks, redelivery is bounded by Consumer.MaxDeliveryAttempts, and an event that reaches the end of that path is published to its topic's dead letter queue as a DeadLetter and acked.
Observability is live: Publish opens a producer span and dispatch opens a consumer span parented to it via traceparent transport metadata, and both paths record OpenTelemetry metrics — eleven instruments covering publish, receive, process, failure, skip, retry, and dead-letter counts plus processing-duration and queue-latency histograms. The library depends on the OTel metric API only; see examples/telemetry for Prometheus wiring, including the histogram bucket View that recipe requires.
The application-facing testing package is implemented: import github.com/bcgov/foi-messaging-go/testing to record publishes, invoke a handler at the router's boundary, and assert what the consume path would do with a delivery — all without Redis.
Index ¶
- Constants
- func AsDiscard(err error) error
- func AsPermanent(err error) error
- func AsRetryable(err error) error
- func IsDiscard(err error) bool
- func IsPermanent(err error) bool
- func IsRetryable(err error) bool
- func RegisterHandler[T any](c *Consumer, def EventDef, h Handler[T]) error
- func RegisterRawHandler(c *Consumer, sel TopicSelector, h Handler[json.RawMessage]) error
- type Config
- type Consumer
- type ConsumerConfig
- type DeadLetter
- type Envelope
- type EventDef
- type Handler
- type PublishOption
- type PublishResult
- type Publisher
- type RedisConfig
- type RetryConfig
- type TelemetryConfig
- type TopicSelector
Constants ¶
const ( // ReasonPermanent is a handler error classified with AsPermanent. ReasonPermanent = "permanent" // ReasonMaxAttemptsExceeded is the delivery-attempt cap firing, // regardless of how the failures were classified. ReasonMaxAttemptsExceeded = "max_attempts_exceeded" // ReasonDeserializationFailed covers every event that could not be read // well enough to dispatch: invalid JSON, an envelope failing validation, // an unparseable schema version, or a stream entry Watermill's own // marshaller rejected. ReasonDeserializationFailed = "deserialization_failed" )
DLQ reason values, fixed by PRD §14. Exported so operational tooling consuming DLQ streams can compare against them rather than against string literals of its own.
Variables ¶
This section is empty.
Functions ¶
func AsDiscard ¶
AsDiscard marks err as a failure the application wants dropped: the event is logged at warn and acked, with no retry and no DLQ entry.
func AsPermanent ¶
AsPermanent marks err as a failure that will not resolve on retry — a validation failure, an unresolvable reference. The consume path routes it straight to the DLQ and acks, skipping both immediate retry and the delivery-attempt cap.
A nil err returns nil: wrapping "no error" would turn a successful handler return into a dead letter.
func AsRetryable ¶
AsRetryable marks err as transient. It is optional — an unclassified error is already retryable (PRD §15) — and exists so handlers can say so deliberately rather than by omission.
func IsPermanent ¶
IsPermanent reports whether err is classified permanent.
func IsRetryable ¶
IsRetryable reports whether err should be retried, which an unclassified error is by default (PRD §15).
The retry loop does not call this — retrying is its fallthrough — but operational tooling and tests do, and the default has to be stated somewhere executable rather than only in a comment.
func RegisterHandler ¶
RegisterHandler registers a typed handler for def. It is a function rather than a method because Go does not permit generic methods.
The handler is invoked for every event on def.Topic whose event type matches def.Type and whose major schema version matches def.Version's. Minor and patch differences do not affect dispatch: producers may add optional fields without a coordinated consumer release, so handlers must tolerate any additive change within their major version.
func RegisterRawHandler ¶
func RegisterRawHandler(c *Consumer, sel TopicSelector, h Handler[json.RawMessage]) error
RegisterRawHandler registers a handler that receives every event on a topic with its payload left as raw JSON. Use it when several payload shapes share an event type, or to consume events without a typed contract.
A topic may have typed handlers or one raw handler, never both.
Types ¶
type Config ¶
type Config struct {
Source string
StreamPrefix string
Redis RedisConfig
Consumer ConsumerConfig
Retry RetryConfig
Telemetry TelemetryConfig
}
Config is the library's single configuration object. A minimal config is three fields: Source, Redis.Address, and — for consumers — Consumer.Group. Every other field has a working default.
type Consumer ¶
type Consumer struct {
// contains filtered or unexported fields
}
Consumer subscribes to the topics its registered handlers cover and dispatches each event to the handler matching its event type and major schema version.
A Consumer is created from a Config, has handlers registered against it, and is then run. Registration after Run has started is an error.
func NewConsumer ¶
NewConsumer validates cfg and returns a Consumer with no handlers registered. It opens no connections — the Redis client, subscriber, and router are built by Run, once the set of topics is known.
func (*Consumer) Close ¶
Close releases the Redis client held by a Consumer that was constructed but never run. It is idempotent, returns nil when there is nothing to release, and is safe to defer unconditionally.
Close is not how a running consumer is stopped. Cancel the context passed to Run: Run drains its handlers and releases everything itself before returning. While Run is in progress — and after it has returned, when there is nothing left to release — Close does nothing and returns nil.
That guard matters. Closing the client under a live read loop wedged the consumer permanently and silently: "redis: client is closed" is neither ctx.Done nor a shutdown signal, so the read loop backed off and retried it forever, Run never returned, and the process never exited.
type ConsumerConfig ¶
type ConsumerConfig struct {
// Group is the Redis consumer group name. Required.
Group string
// ConsumerName identifies this instance within Group. Defaults to
// <hostname>-<random suffix>, which stays unique across replicas and
// restarts on one host.
ConsumerName string
// Concurrency bounds how many messages may be in flight at once *per
// subscribed topic*, not across the consumer as a whole: a consumer
// registered on three topics at Concurrency 3 can be running nine
// handlers. The bound is per topic because each topic has its own read
// loop, and a shared bound would let an idle topic's blocking read
// throttle a busy one. Defaults to 1, at which per-topic ordering is
// preserved.
Concurrency int
// ClaimInterval is how often the reclaim sweep runs. Defaults to 30s.
ClaimInterval time.Duration
// ClaimMinIdle is how long an entry must sit unacknowledged before
// another consumer may reclaim it. It must be >= ClaimInterval, and it
// must also exceed the longest one delivery can occupy its concurrency
// slot — which, with immediate retry, is
//
// (1 + Retry.MaxImmediateRetries) × handler duration + worst-case backoff
//
// not one handler run. A 20s handler at the defaults occupies its slot
// for up to 80.7s, so the 60s default is already too short for it: the
// entry is reclaimed and processed concurrently by this same process
// while the first delivery is still retrying.
//
// The backoff term alone is checked at construction; the handler term
// cannot be. Defaults to 60s.
ClaimMinIdle time.Duration
// MaxDeliveryAttempts bounds redeliveries. On the delivery whose
// attempt exceeds it, the event is dead-lettered and acked before it is
// even decoded — regardless of how its failures were classified
// (PRD §13 Layer 3). Attempts 1..MaxDeliveryAttempts dispatch;
// attempt MaxDeliveryAttempts+1 is dead-lettered. Defaults to 5.
MaxDeliveryAttempts int
// ShutdownTimeout bounds the drain of in-flight handlers after the Run
// context is cancelled.
//
// Immediate retry extends the drain: a message that fails on the last
// attempt before shutdown can still spend
// Retry.MaxImmediateRetries × handler duration + worst-case backoff
// finishing, across Concurrency × (number of subscribed topics)
// messages. Retries are deliberately not interrupted by shutdown —
// message contexts stay live for the whole drain — so budget for it
// here. Defaults to 30s.
ShutdownTimeout time.Duration
}
ConsumerConfig configures a Consumer. Its defaults and validation are applied by validateConsumer, which NewConsumer calls after Validate.
type DeadLetter ¶
type DeadLetter struct {
DeadLetteredAt time.Time `json:"dead_lettered_at"`
Reason string `json:"reason"`
Error string `json:"error"`
DeliveryAttempts int64 `json:"delivery_attempts"`
ConsumerGroup string `json:"consumer_group"`
ConsumerName string `json:"consumer_name"`
OriginalTopic string `json:"original_topic"`
Event json.RawMessage `json:"event,omitempty"`
EventRaw []byte `json:"event_raw,omitempty"`
}
DeadLetter is the wrapper written to a topic's DLQ stream.
PRD §5 forbids failure metadata inside the event, so the original event travels verbatim alongside the metadata rather than being modified to carry it. That is what lets replay tooling republish the event without transformation.
Exactly one of Event and EventRaw is set. Event holds the original bytes when they were valid JSON; EventRaw holds them — base64-encoded by encoding/json — when they were not, which is the only case where preserving unparseable input is what matters.
type Envelope ¶
type Envelope[T any] struct { EventID string `json:"event_id"` EventType string `json:"event_type"` Timestamp time.Time `json:"timestamp"` SchemaVersion string `json:"schema_version"` CorrelationID string `json:"correlation_id"` Source string `json:"source"` Payload T `json:"payload"` }
Envelope wraps every published and consumed event. It carries business and workflow metadata only — transport state (retry counters, delivery attempts, trace context) travels in message metadata, never here.
type EventDef ¶
EventDef identifies an event contract: which topic it publishes on, its event type, and its schema version. Declared once per event in the contract package that owns the payload type, and shared by publishers and consumers.
type Handler ¶
Handler processes a typed event payload. Applications implement this interface and register implementations with a Consumer.
Handlers must be idempotent: delivery is at-least-once, so the same event may arrive more than once (PRD §6). EventID is the deduplication key.
type PublishOption ¶
type PublishOption func(*publishOptions)
PublishOption customizes a single Publish call.
func WithCorrelationID ¶
func WithCorrelationID(id string) PublishOption
WithCorrelationID explicitly sets the correlation ID for a publish call, taking priority over any correlation ID carried on the context.
type PublishResult ¶
PublishResult is returned by a successful Publish.
type Publisher ¶
type Publisher struct {
// contains filtered or unexported fields
}
Publisher publishes typed payloads to Redis streams without exposing Watermill or go-redis to callers.
func NewPublisher ¶
NewPublisher validates cfg and builds a Publisher backed by it.
func (*Publisher) Publish ¶
func (p *Publisher) Publish(ctx context.Context, def EventDef, payload any, opts ...PublishOption) (PublishResult, error)
Publish builds a standard envelope around payload and writes it to the stream named by cfg.StreamPrefix + ":" + def.Topic. Errors are returned synchronously; the library does not buffer or retry publishes.
type RedisConfig ¶
type RedisConfig struct {
Address string
Username string
Password string
TLS *tls.Config
DB int
PoolSize int
}
RedisConfig configures the Redis connection the library uses internally. Applications never construct a go-redis client themselves.
type RetryConfig ¶
type RetryConfig struct {
// MaxImmediateRetries is the number of retries after the first
// attempt, so a message gets 1+MaxImmediateRetries invocations per
// delivery. Defaults to 3.
MaxImmediateRetries int
// InitialBackoff is the upper bound of the first retry's jittered
// sleep, doubling per retry. Defaults to 100ms.
InitialBackoff time.Duration
// MaxBackoff caps that doubling. Defaults to 5s.
MaxBackoff time.Duration
}
RetryConfig configures the in-process immediate-retry layer (PRD §13 Layer 1): the retries a handler gets within one delivery, before the message is nacked and left for the reclaim loop.
Zero values mean "use the default" — 3 retries, 100ms initial, 5s max — so there is no way to express "no retries" by zeroing MaxImmediateRetries. Set InitialBackoff and MaxBackoff to a nanosecond in tests that need the loop to run without waiting.
Retries sleep inside the handler's concurrency slot, so these values interact with Consumer.ClaimMinIdle; see its documentation.
type TelemetryConfig ¶
type TelemetryConfig struct {
TracerProvider trace.TracerProvider
MeterProvider metric.MeterProvider
// Propagator injects trace context at publish and extracts it at
// consume (PRD §5). It defaults to propagation.TraceContext{}
// directly, NOT to otel.GetTextMapPropagator(), which returns a no-op
// unless the application has called otel.SetTextMapPropagator. A
// no-op here would leave traceparent unwritten and every consumer
// span a disconnected root, with nothing reporting a fault — the
// failure would surface only during the first incident the tracing
// was bought for.
//
// Baggage is deliberately not composited in: PRD §5's transport
// metadata table lists traceparent and tracestate and nothing else.
// Applications wanting baggage pass their own composite here.
Propagator propagation.TextMapPropagator
Logger *slog.Logger
LogPayloads bool
}
TelemetryConfig configures observability integration. Logger is defaulted by Validate and is used throughout the consume path — the subscriber's loops, dispatch, and watermill's own router logs all go through it. Propagator is defaulted here and carries trace context across the publish/consume boundary. TracerProvider and MeterProvider default to otel.GetTracerProvider()/otel.GetMeterProvider() when nil, and are live: Publish and the consume path both open spans and record the full set of PRD §16 metrics through them. An application that never calls otel.SetTracerProvider/otel.SetMeterProvider gets the OTel SDK's no-op implementations, which is a silent way to end up with no telemetry rather than an error — set these fields explicitly, or call the otel.Set* functions before constructing a Publisher or Consumer.
type TopicSelector ¶
type TopicSelector struct {
Topic string
}
TopicSelector identifies a topic for raw handler registration, for cases where several payload shapes share an event type or a service consumes events it has no typed contract for.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package examples contains runnable example programs demonstrating library usage.
|
Package examples contains runnable example programs demonstrating library usage. |
|
consumer
command
Command consumer shows how a service consumes events with the messaging library.
|
Command consumer shows how a service consumes events with the messaging library. |
|
internal
|
|
|
redis
Package redis wraps the go-redis client and the Redis Streams adapter.
|
Package redis wraps the go-redis client and the Redis Streams adapter. |
|
testseam
Package testseam carries the hooks the root messaging package registers at init so this module's testing/ package can drive the real publish and consume paths without a Redis instance.
|
Package testseam carries the hooks the root messaging package registers at init so this module's testing/ package can drive the real publish and consume paths without a Redis instance. |
|
testsupport
Package testsupport provides infrastructure helpers for this repository's own integration tests.
|
Package testsupport provides infrastructure helpers for this repository's own integration tests. |
|
watermill
Package watermill wraps Watermill's Publisher and Subscriber over Redis Streams: Publisher publishes messages via watermill-redisstream, and Subscriber implements message.Subscriber with a bounded-concurrency read loop plus a claim loop that reclaims nacked or abandoned pending entries.
|
Package watermill wraps Watermill's Publisher and Subscriber over Redis Streams: Publisher publishes messages via watermill-redisstream, and Subscriber implements message.Subscriber with a bounded-concurrency read loop plus a claim loop that reclaims nacked or abandoned pending entries. |
|
Package messagingtest (imported from the testing/ directory) lets applications that consume this library unit-test their publish paths and handlers without a running Redis instance.
|
Package messagingtest (imported from the testing/ directory) lets applications that consume this library unit-test their publish paths and handlers without a running Redis instance. |