This commit is contained in:
commit
67f862a363
|
|
@ -0,0 +1,225 @@
|
|||
---
|
||||
name: watermill
|
||||
description: Expert in Watermill Go library for event-driven architecture. Use when designing pub/sub systems, implementing AMQP/RabbitMQ messaging, creating message handlers, configuring middleware, or troubleshooting message processing. Covers Router, Publisher, Subscriber, AMQP config, middleware, and common patterns.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
You are a Watermill pub/sub expert for Go. You have deep knowledge of the Watermill library, especially its AMQP (RabbitMQ) implementation.
|
||||
|
||||
## Your Role
|
||||
|
||||
When the user invokes `/watermill`, help them with:
|
||||
- Designing event-driven architectures using Watermill
|
||||
- Writing publishers, subscribers, handlers, and routers
|
||||
- Configuring AMQP (RabbitMQ) pub/sub with proper topology
|
||||
- Choosing and configuring middleware
|
||||
- Debugging message delivery issues
|
||||
- Migrating from raw amqp091-go to Watermill
|
||||
|
||||
If the user provides arguments (e.g., `/watermill how to set up retry middleware`), focus your response on that specific topic.
|
||||
|
||||
Always reference the detailed knowledge below and the separate reference.md file for comprehensive API details.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
go get github.com/ThreeDotsLabs/watermill
|
||||
go get github.com/ThreeDotsLabs/watermill-amqp/v3
|
||||
```
|
||||
|
||||
### Core Interfaces
|
||||
```go
|
||||
// Publisher
|
||||
type Publisher interface {
|
||||
Publish(topic string, messages ...*Message) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Subscriber
|
||||
type Subscriber interface {
|
||||
Subscribe(ctx context.Context, topic string) (<-chan *Message, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Handler function (used with Router)
|
||||
type HandlerFunc func(msg *Message) ([]*Message, error)
|
||||
|
||||
// No-publish handler (used with Router.AddConsumerHandler)
|
||||
type NoPublishHandlerFunc func(msg *Message) error
|
||||
```
|
||||
|
||||
### Message
|
||||
```go
|
||||
msg := message.NewMessage(watermill.NewUUID(), payload) // payload is []byte
|
||||
msg.Metadata.Set("key", "value")
|
||||
msg.Ack() // acknowledge successful processing
|
||||
msg.Nack() // negative ack, triggers redelivery
|
||||
```
|
||||
|
||||
### AMQP Pre-Built Configs (pick one)
|
||||
```go
|
||||
// Simple queue (1 consumer gets each message)
|
||||
config := amqp.NewDurableQueueConfig(amqpURI)
|
||||
|
||||
// Pub/Sub fanout (all consumers get every message)
|
||||
config := amqp.NewDurablePubSubConfig(amqpURI, amqp.GenerateQueueNameTopicNameWithSuffix("my-service"))
|
||||
|
||||
// Topic exchange (routing key pattern matching)
|
||||
config := amqp.NewDurableTopicConfig(amqpURI, exchangeNameGenerator, queueNameGenerator)
|
||||
```
|
||||
|
||||
### Router Setup
|
||||
```go
|
||||
logger := watermill.NewStdLogger(false, false)
|
||||
router, err := message.NewRouter(message.RouterConfig{}, logger)
|
||||
|
||||
// Plugins
|
||||
router.AddPlugin(plugin.SignalsHandler) // graceful SIGTERM handling
|
||||
|
||||
// Global middleware
|
||||
router.AddMiddleware(
|
||||
middleware.CorrelationID,
|
||||
middleware.Retry{
|
||||
MaxRetries: 3,
|
||||
InitialInterval: 100 * time.Millisecond,
|
||||
Logger: logger,
|
||||
}.Middleware,
|
||||
middleware.Recoverer,
|
||||
)
|
||||
|
||||
// Handler that consumes from one topic and publishes to another
|
||||
router.AddHandler(
|
||||
"handler_name",
|
||||
"input_topic",
|
||||
subscriber,
|
||||
"output_topic",
|
||||
publisher,
|
||||
func(msg *message.Message) ([]*message.Message, error) {
|
||||
// process msg, return new messages to publish
|
||||
return []*message.Message{outputMsg}, nil
|
||||
},
|
||||
)
|
||||
|
||||
// Consumer-only handler (no output topic)
|
||||
router.AddConsumerHandler(
|
||||
"consumer_name",
|
||||
"input_topic",
|
||||
subscriber,
|
||||
func(msg *message.Message) error {
|
||||
// process msg
|
||||
return nil // auto-acks on nil error
|
||||
},
|
||||
)
|
||||
|
||||
// Run (blocks until shutdown)
|
||||
if err := router.Run(ctx); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
```
|
||||
|
||||
### AMQP Custom Config
|
||||
```go
|
||||
config := amqp.Config{
|
||||
Connection: amqp.ConnectionConfig{
|
||||
AmqpURI: "amqp://guest:guest@localhost:5672/",
|
||||
},
|
||||
Marshaler: amqp.DefaultMarshaler{},
|
||||
Exchange: amqp.ExchangeConfig{
|
||||
GenerateName: func(topic string) string { return topic },
|
||||
Type: "direct", // "fanout", "topic", "direct", "headers"
|
||||
Durable: true,
|
||||
},
|
||||
Queue: amqp.QueueConfig{
|
||||
GenerateName: amqp.GenerateQueueNameTopicNameWithSuffix("my-service"),
|
||||
Durable: true,
|
||||
},
|
||||
QueueBind: amqp.QueueBindConfig{
|
||||
GenerateRoutingKey: func(topic string) string { return topic },
|
||||
},
|
||||
Publish: amqp.PublishConfig{
|
||||
GenerateRoutingKey: func(topic string) string { return topic },
|
||||
},
|
||||
Consume: amqp.ConsumeConfig{
|
||||
Qos: amqp.QosConfig{
|
||||
PrefetchCount: 10,
|
||||
},
|
||||
},
|
||||
TopologyBuilder: &amqp.DefaultTopologyBuilder{},
|
||||
}
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Watermill "topic" != AMQP "topic exchange"
|
||||
Watermill's `topic` parameter is an abstract name used to generate:
|
||||
- Exchange name (via `Exchange.GenerateName`)
|
||||
- Queue name (via `Queue.GenerateName`)
|
||||
- Routing key (via `Publish.GenerateRoutingKey`)
|
||||
|
||||
Enable debug logging to see the actual AMQP names being used.
|
||||
|
||||
### Delivery Semantics
|
||||
- **At-least-once delivery** — handlers MUST be idempotent
|
||||
- Router auto-acks on `nil` error, auto-nacks on error return
|
||||
- `Close()` on publisher flushes unsent messages — always defer it
|
||||
|
||||
### Consumer Groups (Kafka-style)
|
||||
Use queue name suffixes to create independent consumer groups:
|
||||
```go
|
||||
// Group A — each instance shares load
|
||||
amqp.GenerateQueueNameTopicNameWithSuffix("group-a")
|
||||
// Group B — gets all messages independently
|
||||
amqp.GenerateQueueNameTopicNameWithSuffix("group-b")
|
||||
```
|
||||
|
||||
## Built-in Middleware Summary
|
||||
|
||||
| Middleware | Purpose |
|
||||
|---|---|
|
||||
| `Retry` | Exponential backoff retry on handler error |
|
||||
| `CircuitBreaker` | Fail fast on repeated errors (uses gobreaker) |
|
||||
| `Timeout` | Cancel msg context after duration |
|
||||
| `Recoverer` | Catch panics, convert to errors with stacktrace |
|
||||
| `CorrelationID` | Propagate correlation ID across message chain |
|
||||
| `Deduplicator` | Drop duplicate messages (in-memory by default) |
|
||||
| `Throttle` | Rate limit message processing |
|
||||
| `PoisonQueue` | Route unprocessable messages to a dead letter topic |
|
||||
| `InstantAck` | Ack immediately before handler runs (throughput over safety) |
|
||||
| `IgnoreErrors` | Whitelist specific errors as non-failures |
|
||||
| `Duplicator` | Process messages twice (idempotency testing) |
|
||||
| `DelayOnError` | Add delay metadata for backoff on reprocessing |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Poison Queue (DLQ)
|
||||
```go
|
||||
poisonQueue, err := middleware.PoisonQueue(publisher, "dead_letter_topic")
|
||||
// or with filter:
|
||||
poisonQueue, err := middleware.PoisonQueueWithFilter(publisher, "dead_letter_topic",
|
||||
func(err error) bool { return errors.Is(err, ErrPermanent) },
|
||||
)
|
||||
router.AddMiddleware(poisonQueue)
|
||||
```
|
||||
|
||||
### Dynamic Handler Management
|
||||
```go
|
||||
// Add handler while router is running
|
||||
router.AddHandler(...)
|
||||
router.RunHandlers(ctx) // idempotent, safe to call multiple times
|
||||
|
||||
// Stop specific handler
|
||||
handler.Stop()
|
||||
<-handler.Stopped() // wait for completion
|
||||
```
|
||||
|
||||
### Context Values in Handlers
|
||||
```go
|
||||
func handler(msg *message.Message) ([]*message.Message, error) {
|
||||
handlerName := middleware.HandlerNameFromCtx(msg.Context())
|
||||
topic := middleware.SubscribeTopicFromCtx(msg.Context())
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
For detailed API reference, configuration structs, and advanced patterns, see the reference.md file in this skill directory.
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
# Watermill AMQP Detailed Reference
|
||||
|
||||
## Full AMQP Config Struct
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Connection ConnectionConfig
|
||||
Marshaler Marshaler
|
||||
Exchange ExchangeConfig
|
||||
Queue QueueConfig
|
||||
QueueBind QueueBindConfig
|
||||
Publish PublishConfig
|
||||
Consume ConsumeConfig
|
||||
TopologyBuilder TopologyBuilder
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
```
|
||||
|
||||
### ConnectionConfig
|
||||
```go
|
||||
type ConnectionConfig struct {
|
||||
AmqpURI string
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
```
|
||||
|
||||
### ExchangeConfig
|
||||
```go
|
||||
type ExchangeConfig struct {
|
||||
GenerateName func(topic string) string
|
||||
Type string // "fanout", "topic", "direct", "headers"
|
||||
Durable bool
|
||||
AutoDeleted bool
|
||||
Internal bool
|
||||
NoWait bool
|
||||
Arguments amqp.Table
|
||||
}
|
||||
```
|
||||
|
||||
### QueueConfig
|
||||
```go
|
||||
type QueueConfig struct {
|
||||
GenerateName func(topic string) string
|
||||
Durable bool
|
||||
AutoDelete bool
|
||||
Exclusive bool
|
||||
NoWait bool
|
||||
Arguments amqp.Table // x-message-ttl, x-dead-letter-exchange, etc.
|
||||
}
|
||||
```
|
||||
|
||||
### QueueBindConfig
|
||||
```go
|
||||
type QueueBindConfig struct {
|
||||
GenerateRoutingKey func(topic string) string
|
||||
NoWait bool
|
||||
Arguments amqp.Table
|
||||
}
|
||||
```
|
||||
|
||||
### PublishConfig
|
||||
```go
|
||||
type PublishConfig struct {
|
||||
GenerateRoutingKey func(topic string) string
|
||||
Mandatory bool
|
||||
Immediate bool
|
||||
}
|
||||
```
|
||||
|
||||
### ConsumeConfig
|
||||
```go
|
||||
type ConsumeConfig struct {
|
||||
Qos QosConfig
|
||||
Consumer string
|
||||
Exclusive bool
|
||||
NoLocal bool
|
||||
NoWait bool
|
||||
Arguments amqp.Table
|
||||
}
|
||||
|
||||
type QosConfig struct {
|
||||
PrefetchCount int
|
||||
PrefetchSize int
|
||||
Global bool
|
||||
}
|
||||
```
|
||||
|
||||
## Pre-Built Config Functions
|
||||
|
||||
### NewDurableQueueConfig
|
||||
- No exchange declared
|
||||
- Queue name = topic name
|
||||
- Persistent delivery mode
|
||||
- Best for: work queues, task distribution
|
||||
|
||||
### NewNonDurableQueueConfig
|
||||
- Same as above but non-durable, non-persistent
|
||||
- Best for: temporary/ephemeral queues
|
||||
|
||||
### NewDurablePubSubConfig
|
||||
- Fanout exchange (all consumers get all messages)
|
||||
- Requires `GenerateQueueNameFunc` for unique queue per consumer
|
||||
- Persistent delivery mode
|
||||
- Best for: event broadcasting, notifications
|
||||
|
||||
### NewNonDurablePubSubConfig
|
||||
- Same as above but non-durable
|
||||
- Best for: real-time updates where persistence isn't needed
|
||||
|
||||
### NewDurableTopicConfig / NewNonDurableTopicConfig
|
||||
- Topic exchange with routing key pattern matching
|
||||
- Best for: selective message routing by pattern
|
||||
|
||||
## Marshaler
|
||||
|
||||
```go
|
||||
type Marshaler interface {
|
||||
Marshal(msg *message.Message) (amqp091.Publishing, error)
|
||||
Unmarshal(amqpMsg amqp091.Delivery) (*message.Message, error)
|
||||
}
|
||||
```
|
||||
|
||||
### DefaultMarshaler
|
||||
```go
|
||||
type DefaultMarshaler struct {
|
||||
// Customize the amqp091.Publishing before sending
|
||||
PostprocessPublishing func(amqp091.Publishing) amqp091.Publishing
|
||||
|
||||
// Set true for non-persistent delivery (higher throughput, no disk writes)
|
||||
NotPersistentDeliveryMode bool
|
||||
|
||||
// Header key for storing Watermill message UUID (default: "Watermill_MessageUUID")
|
||||
MessageUUIDHeaderKey string
|
||||
}
|
||||
```
|
||||
|
||||
Example: adding content type
|
||||
```go
|
||||
marshaler := amqp.DefaultMarshaler{
|
||||
PostprocessPublishing: func(p amqp091.Publishing) amqp091.Publishing {
|
||||
p.ContentType = "application/json"
|
||||
p.CorrelationId = "my-correlation-id"
|
||||
return p
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## TopologyBuilder
|
||||
|
||||
Default: `DefaultTopologyBuilder` — declares exchanges, queues, and bindings automatically.
|
||||
|
||||
For custom topology (pre-existing infrastructure):
|
||||
```go
|
||||
type MyTopologyBuilder struct{}
|
||||
|
||||
func (t *MyTopologyBuilder) BuildTopology(channel *amqp091.Channel, queueName string, exchangeName string, config Config, logger watermill.LoggerAdapter) error {
|
||||
// Custom declarations or no-ops for pre-declared topology
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *MyTopologyBuilder) ExchangeDeclare(channel *amqp091.Channel, exchangeName string, config Config) error {
|
||||
return nil // skip exchange declaration
|
||||
}
|
||||
```
|
||||
|
||||
## Publisher Usage
|
||||
|
||||
```go
|
||||
publisher, err := amqp.NewPublisher(config, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer publisher.Close() // IMPORTANT: flushes unsent messages
|
||||
|
||||
msg := message.NewMessage(watermill.NewUUID(), []byte(`{"event":"user_created"}`))
|
||||
msg.Metadata.Set("correlation_id", "abc-123")
|
||||
|
||||
err = publisher.Publish("events.user", msg)
|
||||
```
|
||||
|
||||
- Thread-safe
|
||||
- Publish blocks until broker confirms receipt (with persistent delivery)
|
||||
- Topic maps to exchange/queue/routing-key depending on config
|
||||
|
||||
## Subscriber Usage
|
||||
|
||||
```go
|
||||
subscriber, err := amqp.NewSubscriber(config, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer subscriber.Close()
|
||||
|
||||
messages, err := subscriber.Subscribe(ctx, "events.user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for msg := range messages {
|
||||
var event UserEvent
|
||||
if err := json.Unmarshal(msg.Payload, &event); err != nil {
|
||||
msg.Nack() // redelivery
|
||||
continue
|
||||
}
|
||||
// process event...
|
||||
msg.Ack() // acknowledge
|
||||
}
|
||||
```
|
||||
|
||||
- Channel closes when `Close()` is called or context is canceled
|
||||
- Must Ack/Nack every message
|
||||
- Next message delivered only after Ack (with prefetch=1)
|
||||
|
||||
## Router Detailed Reference
|
||||
|
||||
### RouterConfig
|
||||
```go
|
||||
type RouterConfig struct {
|
||||
CloseTimeout time.Duration // default: 30s
|
||||
}
|
||||
```
|
||||
|
||||
### Handler Registration
|
||||
```go
|
||||
// Full handler: consume + publish
|
||||
handler := router.AddHandler(
|
||||
"handler_name", // must be unique
|
||||
"subscribe_topic", // consumed topic
|
||||
subscriber, // Subscriber implementation
|
||||
"publish_topic", // produced topic
|
||||
publisher, // Publisher implementation
|
||||
handlerFunc, // func(msg *Message) ([]*Message, error)
|
||||
)
|
||||
|
||||
// Consumer-only handler
|
||||
router.AddConsumerHandler(
|
||||
"consumer_name",
|
||||
"subscribe_topic",
|
||||
subscriber,
|
||||
noPublishHandlerFunc, // func(msg *Message) error
|
||||
)
|
||||
```
|
||||
|
||||
### Handler-Level Middleware
|
||||
```go
|
||||
handler := router.AddHandler(...)
|
||||
handler.AddMiddleware(
|
||||
middleware.Timeout(5 * time.Second),
|
||||
)
|
||||
```
|
||||
|
||||
### Context Helpers
|
||||
```go
|
||||
middleware.HandlerNameFromCtx(msg.Context()) // "handler_name"
|
||||
middleware.SubscriberNameFromCtx(msg.Context()) // e.g. "amqp.Subscriber"
|
||||
middleware.PublisherNameFromCtx(msg.Context())
|
||||
middleware.SubscribeTopicFromCtx(msg.Context())
|
||||
middleware.PublishTopicFromCtx(msg.Context())
|
||||
```
|
||||
|
||||
## Middleware Detailed Reference
|
||||
|
||||
### Retry
|
||||
```go
|
||||
middleware.Retry{
|
||||
MaxRetries: 3,
|
||||
InitialInterval: 100 * time.Millisecond,
|
||||
MaxInterval: 10 * time.Second,
|
||||
Multiplier: 2.0,
|
||||
MaxElapsedTime: 30 * time.Second,
|
||||
RandomizationFactor: 0.5,
|
||||
OnRetryHook: func(retryNum int, delay time.Duration) {
|
||||
log.Printf("retry %d after %v", retryNum, delay)
|
||||
},
|
||||
ShouldRetry: func(err error) bool {
|
||||
return !errors.Is(err, ErrPermanent) // skip retry for permanent errors
|
||||
},
|
||||
Logger: logger,
|
||||
}.Middleware
|
||||
```
|
||||
|
||||
### Circuit Breaker
|
||||
```go
|
||||
import "github.com/sony/gobreaker"
|
||||
|
||||
cb := middleware.NewCircuitBreaker(gobreaker.Settings{
|
||||
Name: "my-handler",
|
||||
MaxRequests: 3, // half-open state max requests
|
||||
Interval: 10 * time.Second,
|
||||
Timeout: 30 * time.Second,
|
||||
ReadyToTrip: func(counts gobreaker.Counts) bool {
|
||||
return counts.ConsecutiveFailures > 5
|
||||
},
|
||||
})
|
||||
router.AddMiddleware(cb.Middleware)
|
||||
```
|
||||
|
||||
### Timeout
|
||||
```go
|
||||
router.AddMiddleware(middleware.Timeout(5 * time.Second))
|
||||
// Handler must check: msg.Context().Done()
|
||||
```
|
||||
|
||||
### Throttle
|
||||
```go
|
||||
// 10 messages per second
|
||||
throttle := middleware.NewThrottle(10, time.Second)
|
||||
router.AddMiddleware(throttle.Middleware)
|
||||
```
|
||||
|
||||
### Poison Queue
|
||||
```go
|
||||
// All errors go to poison queue
|
||||
pq, err := middleware.PoisonQueue(publisher, "failed_messages")
|
||||
router.AddMiddleware(pq)
|
||||
|
||||
// Only specific errors
|
||||
pq, err := middleware.PoisonQueueWithFilter(publisher, "failed_messages",
|
||||
func(err error) bool {
|
||||
return errors.Is(err, ErrUnprocessable)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Deduplicator
|
||||
```go
|
||||
dedup, err := middleware.NewDeduplicator(
|
||||
middleware.NewMessageHasherSHA256(), // or NewMessageHasherAdler32()
|
||||
middleware.NewInMemoryExpiringKeyRepository(10 * time.Minute),
|
||||
)
|
||||
router.AddMiddleware(dedup.Middleware)
|
||||
```
|
||||
|
||||
### Correlation ID
|
||||
```go
|
||||
// Set on first message entering system
|
||||
middleware.SetCorrelationID(watermill.NewUUID(), msg)
|
||||
|
||||
// Read in any handler downstream
|
||||
corrID := middleware.MessageCorrelationID(msg)
|
||||
|
||||
// Auto-propagate (add as router middleware)
|
||||
router.AddMiddleware(middleware.CorrelationID)
|
||||
```
|
||||
|
||||
## Common AMQP Patterns
|
||||
|
||||
### Work Queue (competing consumers)
|
||||
```go
|
||||
// Multiple workers share the same queue
|
||||
config := amqp.NewDurableQueueConfig("amqp://localhost:5672/")
|
||||
// All subscribers with the same config compete for messages
|
||||
```
|
||||
|
||||
### Fanout (pub/sub broadcast)
|
||||
```go
|
||||
config := amqp.NewDurablePubSubConfig(
|
||||
"amqp://localhost:5672/",
|
||||
amqp.GenerateQueueNameTopicNameWithSuffix("service-name"),
|
||||
)
|
||||
// Each service gets its own queue bound to the fanout exchange
|
||||
```
|
||||
|
||||
### Topic Routing
|
||||
```go
|
||||
config := amqp.NewDurableTopicConfig(
|
||||
"amqp://localhost:5672/",
|
||||
func(topic string) string { return "events" }, // exchange
|
||||
func(topic string) string { return "service." + topic }, // queue
|
||||
)
|
||||
// Use routing key patterns like "user.*", "order.#"
|
||||
```
|
||||
|
||||
### Dead Letter Queue
|
||||
```go
|
||||
config := amqp.Config{
|
||||
// ...
|
||||
Queue: amqp.QueueConfig{
|
||||
GenerateName: func(topic string) string { return topic },
|
||||
Durable: true,
|
||||
Arguments: amqp.Table{
|
||||
"x-dead-letter-exchange": "dlx",
|
||||
"x-dead-letter-routing-key": "dead_letter",
|
||||
"x-message-ttl": int32(300000), // 5 minutes
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Delayed/Scheduled Messages
|
||||
Use the `DelayOnError` middleware or RabbitMQ delayed message exchange plugin.
|
||||
|
||||
## Logging
|
||||
|
||||
```go
|
||||
// Development
|
||||
logger := watermill.NewStdLogger(debug, trace)
|
||||
|
||||
// Production (slog)
|
||||
logger := watermill.NewSlogLogger(slog.Default())
|
||||
```
|
||||
|
||||
## Advanced: Outbox Pattern
|
||||
Watermill supports the transactional outbox pattern via the Forwarder component — publish messages to a database first, then forward to the broker. This guarantees no message loss even if the broker is down.
|
||||
|
||||
## Advanced: FanIn / FanOut
|
||||
- **FanIn**: Merge multiple topics into one handler
|
||||
- **FanOut**: Duplicate messages to multiple subscribers from one topic
|
||||
Loading…
Reference in New Issue