Skip to content

Commit b814016

Browse files
committed
feat: add source/redis adapter (Redis Streams)
Consumer-group Inlet over go-redis: XREADGROUP consume, XACK on ack, pending XCLAIM redelivery on nak, dead-letter stream routing on term. Honest capabilities by assertion: SharedDurable (the consumer group), Seekable (entry-ID XRANGE replay), LagReporter (XINFO GROUPS / XLEN). No partitions, so PartitionKey is empty and the Hopper shards by Key; no ConsumerGroups, no Transactional. Own module, GOWORK=off, wired into the source CI matrices. Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent 6d6dfdb commit b814016

12 files changed

Lines changed: 2211 additions & 21 deletions

File tree

.github/workflows/source.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ jobs:
6565
module:
6666
- source/kafka
6767
- source/jetstream
68+
- source/redis
6869
- source/cloudevents
6970
- examples/sourcedrive
7071
runs-on: ${{ matrix.os }}
@@ -116,6 +117,7 @@ jobs:
116117
- source/statemachine
117118
- source/kafka
118119
- source/jetstream
120+
- source/redis
119121
- source/cloudevents
120122
- examples/sourcedrive
121123
runs-on: ubuntu-latest

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ stability label.
6161
| `source` | Ingress seam: consume streams and drive statecharts; ack on durable transition. | experimental |
6262
| `source/kafka` | Kafka/RedPanda Inlet over franz-go: group consumer, mark-commit-after-process. | experimental |
6363
| `source/jetstream` | NATS JetStream Inlet over nats.go: pull consumer, ack/nak/term, MaxAckPending. | experimental |
64+
| `source/redis` | Redis Streams Inlet over go-redis: consumer group, XACK/pending-claim, DLQ. | experimental |
6465
| `source/cloudevents` | CloudEvents codec with structured and binary content modes. | experimental |
6566
| `source/statemachine` | Bridge: an inbound message drives a transition, ack tied to the durable commit. | experimental |
6667

@@ -81,9 +82,9 @@ snapshots; inspection; and JSON (de)serialization. It is backed by its `analysis
8182
`evolution`, and `conformance` companion packages. Treat its API as experimental
8283
until it reaches v1. The `telemetry` interface and its `slog`, `otel`, and
8384
`datadog` adapters are released. The `sink` egress seam and its destination
84-
adapters, and the `source` ingress seam with its Kafka and JetStream adapters,
85-
CloudEvents codec, reliability middleware, and state-machine bridge, are now
86-
available and documented; the `broker` module is planned.
85+
adapters, and the `source` ingress seam with its Kafka, JetStream, and Redis
86+
Streams adapters, CloudEvents codec, reliability middleware, and state-machine
87+
bridge, are now available and documented; the `broker` module is planned.
8788

8889
## Roadmap: event-driven seams
8990

docs/src/content/docs/source/adapters.md

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,33 +45,52 @@ deliver-by-start options. A JetStream durable consumer is the grouping analog of
4545
a Kafka consumer group, but it has no partitions and no assignment callbacks, so
4646
JetStream does **not** pretend to be a `ConsumerGroups` backend.
4747

48+
## redis (go-redis)
49+
50+
`crucible/source/redis` is a consumer-group reader over go-redis:
51+
52+
```go
53+
in, _ := redis.New(redis.WithAddr("localhost:6379"), redis.WithGroup("orders-svc"), redis.WithConsumer("worker-1"))
54+
sub, _ := in.Subscribe(ctx, source.SubscribeConfig{Topics: []string{"orders"}})
55+
m, _ := sub.Next(ctx)
56+
_ = sub.Settle(ctx, m, source.Ack())
57+
```
58+
59+
It reads a Redis Stream with `XREADGROUP`, acks with `XACK`, leaves a naked
60+
entry in the pending list for a later `XPENDING` plus `XCLAIM` redelivery, and
61+
routes a terminated entry to a configured dead-letter stream before acking the
62+
original. Replay is by entry ID through `XRANGE`, and lag comes from
63+
`XINFO GROUPS` with an `XLEN` fallback. A Redis consumer group is the grouping
64+
analog of a Kafka consumer group, but it has no partitions and no assignment
65+
callbacks, so Redis does **not** pretend to be a `ConsumerGroups` backend.
66+
4867
## Capability table per backend
4968

5069
Capabilities are detected by interface assertion, once, inside the engine. The
5170
table is honest: an adapter satisfies a capability only when its backend truly
5271
supports it, and a compile-time `var _ Seekable = ...` assertion in each module
5372
keeps it accurate.
5473

55-
| Capability | kafka | jetstream | Notes |
56-
|---|---|---|---|
57-
| `Seekable` | yes | yes | live `SetOffsets` on Kafka; consumer-recreate on JetStream |
58-
| `ConsumerGroups` | yes | no | Kafka rebalance hooks; JetStream has no partition assignment |
59-
| `SharedDurable` | no | yes | the JetStream durable-consumer grouping analog |
60-
| `PartitionOrdered` | yes | no | Kafka per-partition order |
61-
| `OrderedDelivery` | no | yes | JetStream `OrderedConsumer`, single-threaded |
62-
| `Batched` | yes | yes | batched fetch on both |
63-
| `Transactional` | yes | no | Kafka EOS only; JetStream does not, and does not fake it |
64-
| `Deduper` | yes | yes | dedup seam (see [reliability](/crucible/source/reliability/)) |
65-
| `LagReporter` | yes | yes | consumer-lag gauge |
74+
| Capability | kafka | jetstream | redis | Notes |
75+
|---|---|---|---|---|
76+
| `Seekable` | yes | yes | yes | live `SetOffsets` on Kafka; consumer-recreate on JetStream; entry-ID `XRANGE` on Redis |
77+
| `ConsumerGroups` | yes | no | no | Kafka rebalance hooks; JetStream and Redis have no partition assignment |
78+
| `SharedDurable` | no | yes | yes | the JetStream durable-consumer and Redis consumer-group grouping analogs |
79+
| `PartitionOrdered` | yes | no | no | Kafka per-partition order |
80+
| `OrderedDelivery` | no | yes | no | JetStream `OrderedConsumer`, single-threaded |
81+
| `Batched` | yes | yes | no | batched fetch on Kafka and JetStream |
82+
| `Transactional` | yes | no | no | Kafka EOS only; JetStream and Redis do not, and do not fake it |
83+
| `Deduper` | yes | yes | no | dedup seam (see [reliability](/crucible/source/reliability/)) |
84+
| `LagReporter` | yes | yes | yes | consumer-lag gauge; Redis reports group lag from `XINFO GROUPS` |
6685

6786
The divergences are documented, never papered over. A `Nak(delay)` is a real
6887
delayed redelivery on JetStream but is best-effort on Kafka (pause plus reseek),
6988
and that is called out where it matters.
7089

7190
## Roadmap and bring your own
7291

73-
`source/redis` (Redis Streams) and `source/cdc` (Debezium/OpenCDC
74-
change-data-capture) are on the roadmap. The catalog is a convenience: any type
92+
`source/cdc` (Debezium/OpenCDC change-data-capture) is on the roadmap. The
93+
catalog is a convenience: any type
7594
that satisfies the `Inlet` interface is an inlet, and the
7695
[`memsource`](/crucible/source/reliability/#testing-the-loop) in-memory adapter
7796
is itself an `Inlet`, so you can drive the whole engine in a unit test with no

magefiles/magefile.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,12 @@ var sinkDestinations = []string{
5656

5757
// sourceDestinations are the standalone source modules kept out of the workspace
5858
// (see modules above): the SDK-backed sources (source/kafka via franz-go,
59-
// source/jetstream via nats.go, source/cloudevents via the CloudEvents SDK) plus
60-
// the flagship examples/sourcedrive. Each builds via its own go.mod and replace
61-
// directives, so the SourceDestinations and Integration targets run with
62-
// GOWORK=off.
59+
// source/jetstream via nats.go, source/redis via go-redis, source/cloudevents
60+
// via the CloudEvents SDK) plus the flagship examples/sourcedrive. Each builds
61+
// via its own go.mod and replace directives, so the SourceDestinations and
62+
// Integration targets run with GOWORK=off.
6363
var sourceDestinations = []string{
64-
"source/kafka", "source/jetstream", "source/cloudevents", "examples/sourcedrive",
64+
"source/kafka", "source/jetstream", "source/redis", "source/cloudevents", "examples/sourcedrive",
6565
}
6666

6767
// Pinned tool versions — keep in sync with .github/workflows/ci.yml.

source/redis/README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# source/redis
2+
3+
A [`crucible/source`](../) ingress adapter that consumes a Redis Stream through
4+
a consumer group. Runtime dependency:
5+
[`github.com/redis/go-redis/v9`](https://github.com/redis/go-redis).
6+
7+
```go
8+
in, err := redis.New(
9+
redis.WithAddr("localhost:6379"),
10+
redis.WithGroup("orders-svc"),
11+
redis.WithConsumer("worker-1"),
12+
redis.WithDLQStream("orders.dlq"),
13+
redis.WithBlock(5*time.Second),
14+
redis.WithCount(64),
15+
)
16+
sub, _ := in.Subscribe(ctx, source.SubscribeConfig{Topics: []string{"orders"}})
17+
18+
m, _ := sub.Next(ctx) // or drive sub with a source.Hopper
19+
_ = sub.Settle(ctx, m, source.Ack())
20+
```
21+
22+
Construct an `Inlet` with `New` and functional options (`WithAddr`/`WithClient`,
23+
`WithGroup`, `WithConsumer`, `WithDLQStream`, `WithBlock`, `WithCount`,
24+
`WithMinIdle`). The returned `Subscription` reads entries with `Next` (via
25+
`XREADGROUP`) and applies a handler `source.Result` with `Settle`. Backpressure
26+
comes from the `Count` batch size plus the block window. Reach the underlying
27+
`*redis.Client` through `Inlet.As`, or the per-entry `redis.XMessage` through
28+
`Message.As`; no vendor type appears in the adapter's own signatures.
29+
30+
A stream entry maps onto `source.Message` as follows: the `value` field becomes
31+
the raw `Value`, the `crucible-key` field (when set) becomes the routing `Key`
32+
(otherwise the stream name), every field is exposed as a `Header`, `Subject` is
33+
the stream, and `Cursor` is the entry ID.
34+
35+
## Settle vocabulary
36+
37+
A consumer group reads with `XREADGROUP`; every delivered entry stays in the
38+
group's Pending Entries List (PEL) until it is settled.
39+
40+
- **Ack** calls `XACK`, removing the entry from the PEL.
41+
- **Nak** leaves the entry in the PEL. A consumer reclaims and redelivers it by
42+
scanning the backlog with `XPENDING` + `XCLAIM` once it has idled past the
43+
minimum; call `NakRedeliver` (on a timer or between read cycles) to drive that
44+
pass. The cadence is a deployment choice, so the engine does not force it.
45+
- **Term** (and **Reject**) append the entry's fields plus dead-letter metadata
46+
(`crucible-dlq-original-id`, `crucible-dlq-stream`, `crucible-dlq-class`,
47+
`crucible-dlq-error`) to the configured `WithDLQStream`, then `XACK` the
48+
original. With no dead-letter stream configured, a terminated entry is acked
49+
and dropped.
50+
- **InProgress** is a no-op: Redis has no per-message deadline to extend.
51+
- **Manual** is a no-op; the handler settled the entry itself through
52+
`Message.As` and the client.
53+
54+
## Capabilities
55+
56+
The subscription honestly advertises the Redis-shaped capabilities by type
57+
assertion: `source.SharedDurable` (the consumer group is the competing-consumer
58+
analog), `source.Seekable` (replay by entry ID via `XRANGE`, with `SeekToTime`
59+
translating a timestamp into an entry ID), and `source.LagReporter` (group lag
60+
from `XINFO GROUPS`, falling back to `XLEN`).
61+
62+
## Redis divergences from the source contract
63+
64+
- **No `ConsumerGroups`.** A Redis consumer group has no partitions and no
65+
assignment lifecycle, so the adapter does not implement
66+
`source.ConsumerGroups`. The group load-balances across processes instead,
67+
surfaced as `source.SharedDurable`. `PartitionKey` is always `""`, so the
68+
Hopper shards by `Key`.
69+
- **No `Transactional`.** Redis Streams offer no consume-side transaction, so
70+
the adapter does not implement `source.Transactional`; the capability is
71+
absent rather than faked.
72+
73+
## Stability
74+
75+
Experimental (pre-v1). The API may change until the suite locks v1.0.0.
76+
77+
## License
78+
79+
Apache-2.0. See [LICENSE](../../LICENSE) and [NOTICE](../../NOTICE).

source/redis/example_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package redis_test
4+
5+
import (
6+
"context"
7+
"fmt"
8+
9+
goredis "github.com/redis/go-redis/v9"
10+
11+
"github.com/stablekernel/crucible/source"
12+
credis "github.com/stablekernel/crucible/source/redis"
13+
)
14+
15+
// exampleClient is a stand-in Client seam that yields one stream entry, so the
16+
// example runs without a live Redis server. It implements only the methods the
17+
// consume-and-ack path touches; a real program passes redis.WithAddr or
18+
// redis.WithClient(realClient) instead.
19+
type exampleClient struct {
20+
credis.Client
21+
delivered bool
22+
}
23+
24+
func (c *exampleClient) XGroupCreateMkStream(ctx context.Context, _, _, _ string) *goredis.StatusCmd {
25+
return goredis.NewStatusCmd(ctx)
26+
}
27+
28+
func (c *exampleClient) XReadGroup(ctx context.Context, a *goredis.XReadGroupArgs) *goredis.XStreamSliceCmd {
29+
cmd := goredis.NewXStreamSliceCmd(ctx)
30+
if c.delivered {
31+
cmd.SetErr(goredis.Nil) // no further entries
32+
return cmd
33+
}
34+
c.delivered = true
35+
cmd.SetVal([]goredis.XStream{{
36+
Stream: a.Streams[0],
37+
Messages: []goredis.XMessage{{
38+
ID: "1526919030474-0",
39+
Values: map[string]any{"value": "placed", "crucible-key": "A-1"},
40+
}},
41+
}})
42+
return cmd
43+
}
44+
45+
func (c *exampleClient) XAck(ctx context.Context, _, _ string, _ ...string) *goredis.IntCmd {
46+
cmd := goredis.NewIntCmd(ctx)
47+
cmd.SetVal(1)
48+
return cmd
49+
}
50+
51+
// Example shows the consume-and-settle loop: open a consumer-group
52+
// subscription, take the next entry off the stream, and ack it after handling.
53+
// In a real program the seam is a live client supplied via WithAddr or
54+
// WithClient.
55+
func Example() {
56+
in, err := credis.New(
57+
credis.WithClient(&exampleClient{}),
58+
credis.WithGroup("orders-svc"),
59+
credis.WithConsumer("worker-1"),
60+
)
61+
if err != nil {
62+
panic(err)
63+
}
64+
defer func() { _ = in.Close() }()
65+
66+
sub, err := in.Subscribe(context.Background(), source.SubscribeConfig{Topics: []string{"orders"}})
67+
if err != nil {
68+
panic(err)
69+
}
70+
defer func() { _ = sub.Close() }()
71+
72+
m, err := sub.Next(context.Background())
73+
if err != nil {
74+
panic(err)
75+
}
76+
if err := sub.Settle(context.Background(), m, source.Ack()); err != nil {
77+
panic(err)
78+
}
79+
80+
fmt.Println(m.Subject())
81+
fmt.Println(string(m.Key()))
82+
fmt.Println(string(m.Value()))
83+
// Output:
84+
// orders
85+
// A-1
86+
// placed
87+
}

source/redis/go.mod

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
module github.com/stablekernel/crucible/source/redis
2+
3+
go 1.25.11
4+
5+
replace github.com/stablekernel/crucible/source => ../
6+
7+
replace github.com/stablekernel/crucible/telemetry => ../../telemetry
8+
9+
require (
10+
github.com/redis/go-redis/v9 v9.20.0
11+
github.com/stablekernel/crucible/source v0.0.0-00010101000000-000000000000
12+
github.com/testcontainers/testcontainers-go v0.42.0
13+
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
14+
)
15+
16+
require (
17+
dario.cat/mergo v1.0.2 // indirect
18+
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
19+
github.com/Microsoft/go-winio v0.6.2 // indirect
20+
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
21+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
22+
github.com/containerd/errdefs v1.0.0 // indirect
23+
github.com/containerd/errdefs/pkg v0.3.0 // indirect
24+
github.com/containerd/log v0.1.0 // indirect
25+
github.com/containerd/platforms v0.2.1 // indirect
26+
github.com/cpuguy83/dockercfg v0.3.2 // indirect
27+
github.com/davecgh/go-spew v1.1.1 // indirect
28+
github.com/distribution/reference v0.6.0 // indirect
29+
github.com/docker/go-connections v0.6.0 // indirect
30+
github.com/docker/go-units v0.5.0 // indirect
31+
github.com/ebitengine/purego v0.10.0 // indirect
32+
github.com/felixge/httpsnoop v1.0.4 // indirect
33+
github.com/go-logr/logr v1.4.3 // indirect
34+
github.com/go-logr/stdr v1.2.2 // indirect
35+
github.com/go-ole/go-ole v1.2.6 // indirect
36+
github.com/google/uuid v1.6.0 // indirect
37+
github.com/klauspost/compress v1.18.5 // indirect
38+
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
39+
github.com/magiconair/properties v1.8.10 // indirect
40+
github.com/mdelapenya/tlscert v0.2.0 // indirect
41+
github.com/moby/docker-image-spec v1.3.1 // indirect
42+
github.com/moby/go-archive v0.2.0 // indirect
43+
github.com/moby/moby/api v1.54.1 // indirect
44+
github.com/moby/moby/client v0.4.0 // indirect
45+
github.com/moby/patternmatcher v0.6.1 // indirect
46+
github.com/moby/sys/sequential v0.6.0 // indirect
47+
github.com/moby/sys/user v0.4.0 // indirect
48+
github.com/moby/sys/userns v0.1.0 // indirect
49+
github.com/moby/term v0.5.2 // indirect
50+
github.com/opencontainers/go-digest v1.0.0 // indirect
51+
github.com/opencontainers/image-spec v1.1.1 // indirect
52+
github.com/pmezard/go-difflib v1.0.0 // indirect
53+
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
54+
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
55+
github.com/sirupsen/logrus v1.9.4 // indirect
56+
github.com/stablekernel/crucible/telemetry v0.0.0 // indirect
57+
github.com/stretchr/testify v1.11.1 // indirect
58+
github.com/tklauser/go-sysconf v0.3.16 // indirect
59+
github.com/tklauser/numcpus v0.11.0 // indirect
60+
github.com/yusufpapurcu/wmi v1.2.4 // indirect
61+
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
62+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
63+
go.opentelemetry.io/otel v1.41.0 // indirect
64+
go.opentelemetry.io/otel/metric v1.41.0 // indirect
65+
go.opentelemetry.io/otel/trace v1.41.0 // indirect
66+
go.uber.org/atomic v1.11.0 // indirect
67+
golang.org/x/crypto v0.48.0 // indirect
68+
golang.org/x/sys v0.42.0 // indirect
69+
gopkg.in/yaml.v3 v3.0.1 // indirect
70+
)

0 commit comments

Comments
 (0)