Documentation
¶
Overview ¶
Package shunt routes Kafka messages from a consumer group to per-topic handlers, with a bounded worker pool, per-topic retry chains, and an optional dead-letter topic.
A topic's retry policy is an ordered chain of steps. InProcess steps retry the handler inline with exponential backoff; RetryTopic steps republish the failed message to a dedicated retry topic and re-attempt it after a configurable delay. Steps compose freely, so a topic can escalate from cheap inline retries to progressively longer-delayed retry topics before giving up:
shunt.NewTopic("orders", handleOrder,
shunt.WithRetry(
shunt.InProcess(2),
shunt.RetryTopic("orders.retry.10s", 10*time.Second, 2),
shunt.RetryTopic("orders.retry.5m", 5*time.Minute, 1),
),
shunt.WithDLQ("orders.dlq"),
)
Retry topics are consumed by the same router and consumer group as the main topic; Register adds them to the routing table automatically. Retry state (original coordinates, attempt counts, the not-before instant) travels in x-shunt-* record headers, and handlers can inspect it via Message.Retry. shunt does not create topics: main, retry, and DLQ topics must exist before Run (or the cluster must allow auto-creation).
Delivery is at-least-once: an offset is marked only after the handler succeeds, the message is republished to the next retry hop, it lands in the DLQ, or the chain is exhausted with no DLQ configured (logged and dropped so a poison message cannot stall its partition). A crash or rebalance can redeliver messages that were already processed but not yet committed — and can repeat a delivery's inline attempts — so handlers must be idempotent.
Messages within one partition are processed strictly in order; parallelism comes from distinct partitions, capped globally by WithWorkers and optionally per topic by WithTopicWorkers. Messages waiting out a retry delay do not hold worker slots. Ordering holds only for messages that succeed on the main topic: anything sent to a retry topic is reordered by design, since a hop moves the message to another topic and delays it while later messages from the main partition keep flowing. Chains built solely from InProcess steps retry inline and preserve ordering under failure.
Observability is opt-in via OpenTelemetry and each signal is independent. WithTracing opens one consumer span per delivery ("process <topic>", visible to the handler through its context) and one producer span per retry-hop or DLQ publish ("send <topic>"); trace context travels across hops in W3C traceparent/tracestate/baggage record headers, so a message's whole retry chain forms a single connected trace, and failed attempts are recorded as exception events. WithMetrics registers the default instruments under the "github.com/devilreza/shunt" scope:
- shunt.messages.processed — counter of completed deliveries, by registered topic and outcome (success, retried, dlq, dropped, redeliver, canceled)
- shunt.handler.duration — histogram (seconds) of individual handler attempts, by topic and error flag
- shunt.messages.published — counter of retry-hop and DLQ publishes, by topic, kind, and error flag
- shunt.messages.inflight — up-down counter of deliveries holding a worker slot, by topic
Metric attributes are restricted to bounded sets; partition, offset, and error details appear only on spans. Both options accept a provider or default to the OTel globals, and WithPropagator overrides header propagation. With neither option set, shunt adds no overhead.
Usage:
router, err := shunt.New(brokers, "billing", shunt.WithWorkers(8))
if err != nil { ... }
router.Register(
shunt.NewTopic("invoice.created", handleInvoice,
shunt.WithRetry(shunt.InProcess(3), shunt.RetryTopic("invoice.created.retry", time.Minute, 3)),
shunt.WithDLQ("invoice.created.dlq"),
shunt.WithTopicWorkers(2),
),
)
go func() { _ = router.Run() }()
...
_ = router.Shutdown(ctx)
Index ¶
- Variables
- type BackoffConfig
- type Claim
- type HandlerFunc
- type Header
- type Message
- type Producer
- type RetryMeta
- type RetryStep
- type Router
- type RouterOption
- func WithLogger(logger *slog.Logger) RouterOption
- func WithMetrics(mp ...metric.MeterProvider) RouterOption
- func WithPropagator(p propagation.TextMapPropagator) RouterOption
- func WithSaramaConfig(cfg *sarama.Config) RouterOption
- func WithTracing(tp ...trace.TracerProvider) RouterOption
- func WithWorkers(n int) RouterOption
- type Session
- type Topic
- type TopicOption
Constants ¶
This section is empty.
Variables ¶
var ( // ErrAlreadyRunning is returned by Run when the router is already consuming. ErrAlreadyRunning = errors.New("shunt: router is already running") // ErrNoTopics is returned by Run when no topics have been registered. ErrNoTopics = errors.New("shunt: no topics registered") )
Functions ¶
This section is empty.
Types ¶
type BackoffConfig ¶
type BackoffConfig struct {
Initial time.Duration
Max time.Duration
Multiplier float64
Jitter float64
}
BackoffConfig shapes the exponential backoff between in-process retry attempts.
type Claim ¶
type Claim interface {
Topic() string
Partition() int32
Messages() <-chan *sarama.ConsumerMessage
}
Claim is the narrow slice of sarama.ConsumerGroupClaim the router needs.
type HandlerFunc ¶
HandlerFunc processes one message; returning an error triggers the topic's retry chain and DLQ policy.
type Message ¶
type Message struct {
Topic string
Partition int32
Offset int64
Key []byte
Value []byte
Timestamp time.Time
Headers []Header
// Retry is non-nil when this delivery arrived through a retry topic; it
// describes where the message sits in its topic's retry chain.
Retry *RetryMeta
}
Message is a Kafka record decoupled from sarama, as delivered to handlers.
type Producer ¶
type Producer interface {
SendMessage(msg *sarama.ProducerMessage) (partition int32, offset int64, err error)
Close() error
}
Producer is the seam over sarama.SyncProducer used for retry-topic and DLQ publishing; sarama.SyncProducer satisfies it structurally.
type RetryMeta ¶
type RetryMeta struct {
// OriginalTopic, OriginalPartition, and OriginalOffset locate the very
// first delivery of this message on its main topic.
OriginalTopic string
OriginalPartition int32
OriginalOffset int64
// Attempts is how many times a handler was invoked before this delivery.
Attempts int
// Step is the index of the RetryTopic step in the chain that delivered
// this message.
Step int
// StepAttempt is the 1-based delivery number within that step.
StepAttempt int
// NotBefore is the instant before which the handler must not run.
NotBefore time.Time
// LastError is the (possibly truncated) error from the previous attempt.
LastError string
}
RetryMeta is the retry state carried in x-shunt-* headers across hops.
type RetryStep ¶
type RetryStep struct {
// contains filtered or unexported fields
}
RetryStep is one stage of a topic's retry chain; build steps with InProcess, InProcessWithBackoff, or RetryTopic and compose them in order with WithRetry.
func InProcess ¶
InProcess retries the handler up to attempts more times inline, with the default exponential backoff (1s initial, 30s cap, x2, 0.2 jitter).
func InProcessWithBackoff ¶
func InProcessWithBackoff(attempts int, cfg BackoffConfig) RetryStep
InProcessWithBackoff retries the handler up to attempts more times inline, waiting per cfg between attempts. Inline waits block the message's partition, so keep cfg.Max modest and use RetryTopic steps for long delays.
func RetryTopic ¶
RetryTopic republishes a failed message to the named topic and re-attempts it after delay, up to attempts deliveries. The router consumes the topic itself; it must not be shared with any other registered topic or chain.
type Router ¶
type Router struct {
// contains filtered or unexported fields
}
Router consumes from a Kafka consumer group and dispatches each message to the Topic registered for it, bounding concurrency with worker pools and driving each topic's retry chain and DLQ.
func New ¶
func New(brokers []string, groupID string, opts ...RouterOption) (*Router, error)
New builds a Router; it validates its arguments and performs no I/O.
func (*Router) Register ¶
Register adds topics — and every retry topic in their chains — to the routing table. Like gin route registration, misuse is a programmer error at boot time and panics: nil topic, empty name or handler, an invalid retry chain, any name collision, or registering after Run.
type RouterOption ¶
type RouterOption func(*Router)
RouterOption customizes a Router at construction time.
func WithLogger ¶
func WithLogger(logger *slog.Logger) RouterOption
WithLogger sets the router's logger; slog.Default() is used otherwise. A nil logger is ignored.
func WithMetrics ¶ added in v0.2.0
func WithMetrics(mp ...metric.MeterProvider) RouterOption
WithMetrics enables the router's default OpenTelemetry metrics — shunt.messages.processed, shunt.handler.duration, shunt.messages.published, and shunt.messages.inflight — labeled only with bounded attributes (registered topic, outcome, publish kind, error flag). With no argument the global otel.GetMeterProvider() is used, resolved at Run.
func WithPropagator ¶ added in v0.2.0
func WithPropagator(p propagation.TextMapPropagator) RouterOption
WithPropagator overrides the propagator used to extract and inject trace context on record headers. The default is a W3C TraceContext+Baggage composite — deliberately not otel.GetTextMapPropagator(), whose global default is a no-op that would silently disconnect traces across retry hops; pass otel.GetTextMapPropagator() explicitly to use the global.
func WithSaramaConfig ¶
func WithSaramaConfig(cfg *sarama.Config) RouterOption
WithSaramaConfig replaces the router's default sarama config, for brokers that need TLS/SASL, a specific version, or tuned offsets. The router still forces the few fields it depends on: consumer error reporting and, when any topic publishes to a retry topic or DLQ, durable synchronous producing.
func WithTracing ¶ added in v0.2.0
func WithTracing(tp ...trace.TracerProvider) RouterOption
WithTracing enables OpenTelemetry tracing: one consumer span per delivery (the handler receives its context), a producer span per retry-hop or DLQ publish, and W3C trace-context propagation through record headers so a message's whole retry chain forms one connected trace. With no argument the global otel.GetTracerProvider() is used, resolved at Run.
func WithWorkers ¶
func WithWorkers(n int) RouterOption
WithWorkers bounds how many handlers run concurrently across all claimed partitions; messages within one partition are always processed in order.
type Session ¶
type Session interface {
Context() context.Context
MarkMessage(msg *sarama.ConsumerMessage, metadata string)
}
Session is the narrow slice of sarama.ConsumerGroupSession the router needs; sarama's interface satisfies it structurally.
type Topic ¶
type Topic struct {
// contains filtered or unexported fields
}
Topic couples a Kafka topic name with its handler, retry chain, DLQ, and worker policy.
func NewTopic ¶
func NewTopic(name string, handler HandlerFunc, opts ...TopicOption) *Topic
NewTopic builds a Topic with the default retry chain (InProcess(3)), then applies the given options.
type TopicOption ¶
type TopicOption func(*Topic)
TopicOption customizes a Topic at construction time.
func WithDLQ ¶
func WithDLQ(topic string) TopicOption
WithDLQ publishes a message to the given dead-letter topic once the whole retry chain is exhausted; without it the message is dropped after the last attempt.
func WithRetry ¶
func WithRetry(steps ...RetryStep) TopicOption
WithRetry replaces the topic's default retry chain with the given steps, executed in order after the initial delivery fails. WithRetry() with no steps disables retries entirely: a single failure goes straight to the DLQ (or is dropped).
func WithTopicWorkers ¶
func WithTopicWorkers(n int) TopicOption
WithTopicWorkers caps how many handlers run concurrently for this topic, counting its retry topics against the same cap, so one heavy topic cannot starve the router's shared pool.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
consumer
command
Command consumer demonstrates a shunt router with a multi-step retry chain, a DLQ, and per-topic worker caps.
|
Command consumer demonstrates a shunt router with a multi-step retry chain, a DLQ, and per-topic worker caps. |
|
otel
command
Command otel demonstrates shunt's OpenTelemetry integration: traces and the default metrics are printed to stdout so a local run needs nothing but a Kafka broker.
|
Command otel demonstrates shunt's OpenTelemetry integration: traces and the default metrics are printed to stdout so a local run needs nothing but a Kafka broker. |