adapters/redis — Phase 2: Pub/Sub¶
Status: Design draft — awaiting refinement / use case. ← Back to Roadmap
See also: Redis Cache Adapter (Phase 1, shipped) ·
adapters/redison pkg.go.dev
Motivation¶
Phase 1 shipped the typed cache boundary (CachePattern, GetAdapter/
SetAdapter/DrainSetAdapter/Seed). Redis is also a lightweight pub/sub
broker — services that already run Redis for caching often use it for
ephemeral fan-out (dashboards, presence, cache-invalidation signals) instead
of operating a second broker. Phase 2 adds SubscribeAdapter (SourcePort)
and PublishAdapter (SinkPort) via the existing EventPattern, mirroring
the MQTT/ZeroMQ adapters — same declaration, same codec validation, same
observer events, different transport semantics.
Semantics — closer to ZeroMQ than MQTT (drives the whole design)¶
| Property | Redis pub/sub | MQTT | ZeroMQ PUB/SUB |
|---|---|---|---|
| Delivery | at-most-once, fire-and-forget | QoS 0/½ | at-most-once |
| Persistence / retained | none | retained messages | none |
| Offline subscribers | messages LOST | QoS 1+ queued | lost |
| Wildcards | glob via PSUBSCRIBE (*, ?, [...]) |
+/# |
prefix match |
| Wildcard scope | * matches ACROSS / (multi-segment) |
+ single-level only |
n/a |
| Ack | none | per-QoS | none |
Consequences to document loudly on every constructor:
- A subscriber that is down (or mid-reconnect) LOSES messages — go-redis's
PubSub auto-reconnects but cannot replay. Use Redis Streams (Phase 3
candidate) when at-least-once matters.
- No retained value: a late subscriber sees nothing until the next publish —
pair with a Phase 1 Seed/LatestPort for "current state on connect".
- Glob * is broader than MQTT +: the derived pattern
"sensors/*/data" also matches "sensors/a/b/data". The subscribe
adapter MUST re-match each inbound Message.Channel against the
{var} template and silently DROP channels that don't fit the segment
structure (mirrors MQTT's topic re-match before var extraction) —
otherwise var extraction mis-parses. Literal *, ?, [ in a topic
template must be escaped (\*) when deriving the PSUBSCRIBE pattern.
Scope decisions¶
| In scope (Phase 2) | Out of scope |
|---|---|
SubscribeAdapter[T] → ports.SourceAdapter[T] via EventPattern |
Redis Streams / consumer groups (Phase 3 candidate — different semantics: persistent, at-least-once, XACK) |
PublishAdapter[T] → ports.SinkAdapter[T] via EventPattern |
Sharded pub/sub (SSUBSCRIBE, Redis 7 cluster) — revisit with a cluster use case |
PSUBSCRIBE glob derivation from {var} topic templates |
Keyspace notifications (__keyspace@*__ — server-config-dependent; interesting for cache invalidation, needs its own review) |
Narrow PubSubCommands interface + fake (same rule as Phase 1 Commands) |
Pattern-free raw channel API (EventPattern is the declaration surface) |
Still deferred from Phase 1 (unchanged, no use case yet): DelAdapter,
per-var codecs on cache keys, spec rendering for CachePattern, client-side
caching (rueidis territory).
Narrow interface extension — PubSubCommands¶
Phase 1's Commands stays untouched (cache ops only). Pub/sub gets its own
narrow interface — an implementation can satisfy either or both:
// Message is one pub/sub delivery.
type Message struct {
Channel string // concrete channel (useful under glob subscriptions)
Payload []byte
}
// Subscription is a live subscription. Messages closes when the
// subscription is closed or the connection is permanently lost.
type Subscription interface {
Messages() <-chan Message
Close() error
}
// PubSubCommands is the narrow pub/sub surface. NewPubSubCommands adapts a
// go-redis UniversalClient (SUBSCRIBE for literal channels, PSUBSCRIBE when
// the pattern contains glob metacharacters).
type PubSubCommands interface {
Publish(ctx context.Context, channel string, payload []byte) error
Subscribe(ctx context.Context, pattern string) (Subscription, error)
}
func NewPubSubCommands(c goredis.UniversalClient) PubSubCommands
go-redis mapping: client.Subscribe(ctx, ch) / client.PSubscribe(ctx, pat)
→ *redis.PubSub, pubsub.Channel() → <-chan *redis.Message
(Message.Payload is a string — one copy to []byte). Publish →
client.Publish(ctx, ch, payload).Err().
API surface¶
// SubscribeAdapterOptions configures [SubscribeAdapter].
type SubscribeAdapterOptions struct {
// ChannelPattern overrides the subscription pattern. When empty, derived
// from the EventPattern topic by replacing each {var} placeholder with
// the glob "*" (e.g. "sensors/{id}/data" -> "sensors/*/data") — the same
// auto-derivation as mqtt.SubscribeAdapterOptions.TopicFilter.
ChannelPattern string
// Observer receives RecordSubscribe per message. Resolved from ctx when nil.
Observer stats.Observer
}
// SubscribeAdapter returns a [ports.SourceAdapter] that subscribes to the
// EventPattern-derived channel pattern and decodes each message through the
// handle's format — the full events validation pipeline (topic vars, codec,
// observer), like mqtt/zeromq.SubscribeAdapter. Decode failures go to the
// port's Errors channel; messages during a reconnect window are LOST
// (at-most-once — documented, not fixable at this layer).
func SubscribeAdapter[T any](
client PubSubCommands,
handle *events.ChannelHandle[T],
fmt format.Format[T],
opts SubscribeAdapterOptions,
) ports.SourceAdapter[T]
// PublishAdapterOptions configures [PublishAdapter].
type PublishAdapterOptions struct {
// OnError receives publish/encode errors (fire-and-forget transport —
// there is no delivery error, only connection/encode errors).
OnError func(error)
// Observer receives RecordPublish per message. Resolved from ctx when nil.
Observer stats.Observer
}
// PublishAdapter returns a [ports.SinkAdapter] that encodes each item through
// the handle's format and publishes it to the EventPattern-derived channel.
//
// varsFor extracts per-item topic-template vars (open decision 2, leaning
// ADOPTED: a constructor argument like Phase 1's keyFn and the file
// adapters' varsFor — a func(T) field cannot live in the non-generic
// options struct, and per-item vars beat the static Vars map that
// mqtt.MQTTDrainPublishOptions still carries as a documented limitation).
// Nil is valid for var-free topics.
func PublishAdapter[T any](
client PubSubCommands,
handle *events.ChannelHandle[T],
varsFor func(T) map[string]string,
fmt format.Format[T],
opts PublishAdapterOptions,
) ports.SinkAdapter[T]
Declaration is the existing EventPattern — no new Pattern type:
var Alerts = codex.Must(ports.NewSinkPort[Alert]("alerts", alertCodec, ports.PortOptions{}))
var AlertsPattern = ports.EventPattern{Topic: "alerts/{severity}"}
// main.go:
handle, _ := Alerts.PluginEventPattern(AlertsPattern)
Alerts.Bind(ctx, redis.PublishAdapter(pubsub, handle,
func(a Alert) map[string]string { return map[string]string{"severity": a.Severity} },
format.JSON(alertCodec), opts))
Structured errors¶
Reuse Phase 1's shape — one new type, same conventions (Error(),
Unwrap(), LogValue()):
// PubSubError wraps a publish or subscribe failure.
type PubSubError struct {
Channel string // channel or pattern
Op string // "publish", "subscribe"
Err error
}
Decode failures on inbound messages reuse the events/codec error chain (ValidationErrors → port Errors channel), mirroring mqtt.
Observer integration¶
No new stats extension — pub/sub maps onto the EXISTING transport hooks
(the whole point of RecordSubscribe/RecordPublish being
transport-agnostic):
RecordSubscribe(channel, success, duration)per inbound messageRecordPublish(channel, success, duration)per outbound message- Payload validation failures →
stats.ReportErrors(obs, "payload", err) - Inbound topic-var failures (
ChannelHandle.ValidateTopicVarsafter extraction fromMessage.Channel) → location"topic_var"— same split as the mqtt adapter - Nil observer →
stats.ObserverFromContext(ctx)
Unit test plan (fake PubSubCommands, no live Redis)¶
| ID | Test | Verifies |
|---|---|---|
| P1 | Subscribe → decode → port stream | happy path, full validation pipeline |
| P2 | Glob derivation | "a/{x}/b" → "a/*/b"; explicit ChannelPattern wins |
| P3 | Decode failure → Errors channel | typed error, per-field ReportErrors location "payload" |
| P3b | Topic-var extraction + validation | vars from Message.Channel via the template; codec failure → "topic_var" report + Errors channel |
| P3c | Multi-segment glob over-match dropped | "sensors/*/data" delivery on "sensors/a/b/data" silently dropped (template re-match) |
| P4 | Subscription closed → adapter returns | no goroutine leak, channels NOT closed by adapter (SourceAdapter contract) |
| P5 | Publish encodes + sends | fake captures channel + payload |
| P6 | Publish failure → OnError | PubSubError{Op:"publish"}, errors.As chain |
| P7 | PubSubError.LogValue | KindGroup + keys channel/op/err |
| P8 | Observer subscribe/publish paths | success AND failure, nil observer safe |
| P9 | Message on non-matching decode continues stream | one bad message ≠ terminated subscription |
| — | ExampleSubscribeAdapter / ExamplePublishAdapter | deterministic, fake-backed |
Files to touch¶
| File | Change |
|---|---|
adapters/redis/pubsub.go |
Message, Subscription, PubSubCommands, NewPubSubCommands shim |
adapters/redis/binding.go (or pubsub_binding.go) |
SubscribeAdapter, PublishAdapter + options |
adapters/redis/errors.go |
PubSubError |
adapters/redis/*_test.go |
P1–P9 + Examples, fake PubSubCommands |
ports/source_port.go / sink_port.go |
add constructors to "Implemented by" godoc lists |
docs/features/redis.md |
new Pub/Sub section (+ semantics table) |
| instructions / review-skill / example | usual three-surface + R-history sync |
Open design decisions¶
- Reconnect visibility — go-redis's
PubSubreconnects silently; should the adapter surface reconnect events (e.g. aPubSubError{Op:"subscribe"}on the Errors channel per drop) so consumers KNOW a gap happened? Leaning: yes — silent gaps are the worst failure mode of at-most-once. Interface implication: theSubscriptionsketch carries onlyMessages()— surfacing gaps needs either anEvents() <-chan errorchannel onSubscriptionor go-redis's typed*redis.Subscriptioncontrol messages mapped in the shim. Resolve together with this decision; the two-method sketch is the no-gap-visibility variant. Per-item topic vars on publish— RESOLVED (review pass, 2026-07-17):varsFor func(T) map[string]stringas a CONSTRUCTOR argument (a generic func field cannot live in the non-generic options struct — same constraint that shaped Phase 1'skeyFn). API surface updated above.- One
Commandsor two interfaces — keepPubSubCommandsseparate (leaning: yes — cache-only users shouldn't fake pub/sub methods) or merge into a single grown interface. - Sharded pub/sub option — add
Sharded boolto options now (SSUBSCRIBE under cluster) or wait. Leaning: wait for a cluster use case. - Glob escaping ownership — escape literal
*?[during pattern derivation in the adapter (leaning: yes, adapter-owned — templates are protocol-agnostic and must not know PSUBSCRIBE syntax), or reject such topics at Bind with aPubSubError.