Redis Streams¶
Connects to Redis Streams as both a source (
XREADGROUPover a consumer group) and a sink (XADD).
Overview¶
The Redis connector integrates Redis Streams over the synchronous hiredis client. The sink appends each record to a stream with XADD; the source reads a stream through a consumer group with XREADGROUP, with each parallel subtask joining the group as a distinct consumer so the group hands out disjoint entries. Both connectors operate on the string channel: records are carried as Redis stream entry field values, by default a single field v so a sink-then-source round-trip returns each payload verbatim. On the SQL path the record is a JSON object string; a source entry that is not a single v field is rendered as a JSON object of all its field/value pairs (all values are Redis bulk strings, so all string-typed).
Dependency and version¶
| Component | Provenance | Version |
|---|---|---|
| hiredis | System package via apt (Debian libhiredis-dev) / brew (macOS) |
Not pinned by clink |
| libhiredis_ssl + OpenSSL | System package via apt / brew, optional (enables TLS) | Not pinned by clink |
The connector does not use Arrow; records ride the string channel directly.
Enabling it¶
The module is gated by the CLINK_WITH_REDIS CMake option, default AUTO (AUTO/ON/OFF). Under AUTO the target is built only when hiredis is discovered (via pkg-config, a hiredis CONFIG package, or a manual header/library probe of the common Homebrew and Linux paths). With ON and no hiredis present, configuration fails with a fatal error. The apt package is libhiredis-dev (installed by scripts/install-connector-deps.sh).
TLS is a separate, optional capability: when both libhiredis_ssl and OpenSSL are found the build defines CLINK_REDIS_TLS and tls=true works at runtime; otherwise the connector still builds and tls=true is rejected at runtime with a clear message.
cmake -S . -B build -DCLINK_WITH_REDIS=ON
Factories¶
| Factory name | Direction | Record type |
|---|---|---|
redis_source |
Source | std::string |
redis_sink |
Sink | std::string |
redis_upsert_sink |
Sink | Row (SET/DEL by the primary-key-derived key) |
(Registered in impls/redis/src/register_factories.cpp.)
Configuration¶
Options are parsed off the BuildContext in register_factories.cpp; the shared connection options come from the ConnectOptions struct in redis_client.hpp. The sink-only and source-only options come from RedisSinkOptions (redis_sink.hpp) and RedisSourceOptions (redis_source.hpp).
Connection (both source and sink)¶
| Option | Required | Default | Description |
|---|---|---|---|
host |
No | localhost |
Redis host. |
port |
No | 6379 |
Redis port. |
username |
No | (empty) | ACL username (Redis 6+). Empty means legacy AUTH <password>. |
password |
No | (empty) | Password for AUTH. Empty means no authentication. |
db |
No | 0 |
Database index; SELECT <db> is issued only when non-zero. |
tls |
No | false |
true wraps the connection in TLS before AUTH. Requires a TLS-enabled build (CLINK_REDIS_TLS); otherwise rejected at runtime. |
tls_ca |
No | (empty) | Optional PEM path to a CA certificate for server verification. |
tls_cert |
No | (empty) | Optional PEM path to a client certificate for mutual TLS. |
tls_key |
No | (empty) | Optional PEM path to the client private key for mutual TLS. |
tls_sni |
No | (empty) | Overrides the SNI / verify hostname; defaults to host. |
tls_verify |
No | true |
false skips server-certificate verification (self-signed development certs). |
Authentication-related options are username, password, and the tls_* set. The TLS handshake runs before AUTH, so credentials are sent over the encrypted channel when tls=true.
Sink (redis_sink)¶
| Option | Required | Default | Description |
|---|---|---|---|
stream |
Yes | (none) | Target stream key. Construction fails if empty. |
field |
No | v |
Field name holding each record's payload. Must not be empty. |
maxlen |
No | 0 |
XADD MAXLEN cap on stream length; 0 means unbounded. |
approx_maxlen |
No | 1 (true) |
Non-zero uses approximate trim (MAXLEN ~); 0 uses exact (MAXLEN =). |
batch_records |
No | 1000 |
Flush the pipelined buffer after this many records. |
max_bytes |
No | 0 |
Flush after this many buffered payload bytes; 0 disables the byte threshold. |
linger_ms |
No | 0 |
Flush a partial batch once it is this old, in milliseconds; 0 disables linger. |
Source (redis_source)¶
| Option | Required | Default | Description |
|---|---|---|---|
stream |
Yes | (none) | Stream key. Construction fails if empty. |
group |
Yes | (none) | Consumer-group name. Construction fails if empty. |
consumer_prefix |
No | clink |
Consumer name is <prefix>-<subtask_idx>. |
field |
No | v |
Single-field round-trip: an entry holding only this field has its value emitted verbatim. |
count |
No | 500 |
XREADGROUP COUNT per call. Values below 1 are clamped to 1. |
block_ms |
No | 500 |
XREADGROUP BLOCK in milliseconds; bounds cancel latency. Non-positive values are normalised to 500 (a BLOCK 0 would block forever and wedge the runner thread). |
start_id |
No | $ |
Group-create position: $ reads new entries only, 0 reads existing history. |
SQL usage¶
Redis is mapped in src/sql/physical_plan.cpp to the connector='redis' string for both source (redis_source) and sink (redis_sink). On the SQL path the row is bridged to and from a JSON object string (json_string_to_row for the source, row_to_json_string for the sink). The default Streams sink is at-least-once; mode='upsert' with a PRIMARY KEY selects the changelog-aware key-value sink (redis_upsert_sink, see Delivery semantics) instead.
CREATE TABLE events_in (
id BIGINT,
payload STRING
) WITH (
connector = 'redis',
host = 'localhost',
port = '6379',
stream = 'events',
group = 'analytics',
start_id = '0'
);
CREATE TABLE events_out (
id BIGINT,
payload STRING
) WITH (
connector = 'redis',
host = 'localhost',
port = '6379',
stream = 'events_processed'
);
Example¶
Programmatic use via the typed classes and their option structs, mirroring impls/redis/tests/test_redis_live.cpp:
#include "clink/redis/redis_sink.hpp"
#include "clink/redis/redis_source.hpp"
using clink::redis::RedisSink;
using clink::redis::RedisSinkOptions;
using clink::redis::RedisSource;
using clink::redis::RedisSourceOptions;
// Sink: XADD each record onto the "events" stream.
RedisSinkOptions sink_opts;
sink_opts.conn.host = "localhost";
sink_opts.conn.port = 6379;
sink_opts.stream = "events";
RedisSink sink(std::move(sink_opts));
sink.open();
clink::Batch<std::string> batch;
batch.emplace(R"({"i":0})");
batch.emplace(R"({"i":1})");
sink.on_data(batch);
sink.flush();
sink.close();
// Source: read "events" through a consumer group from the start of history.
RedisSourceOptions src_opts;
src_opts.conn.host = "localhost";
src_opts.conn.port = 6379;
src_opts.stream = "events";
src_opts.group = "g1";
src_opts.start_id = "0"; // "$" for new entries only
RedisSource src(std::move(src_opts));
src.open();
clink::Emitter<std::string> out{/* ... */};
src.produce(out); // emits each entry's "v" value verbatim
src.close();
The factories can also be resolved through the runner registry by name (redis_source / redis_sink on the string channel), as in impls/redis/tests/test_factory_registration.cpp.
Delivery semantics¶
Both ends are at-least-once.
Sink (redis_sink): XADD is append-only with no producer dedup key. Records are buffered and pipelined, then flushed on the count / byte / linger threshold and on every checkpoint barrier, so everything buffered is durable in the stream by the barrier. On a flush error the connection is dropped and the exception is rethrown, so the job replays the buffered batch from the last checkpoint, which re-appends and produces duplicates downstream. There is no two-phase commit.
Upsert sink (redis_upsert_sink, mode='upsert'): a separate key-value view (not Streams). Maintains one Redis key per PRIMARY KEY tuple - SET <key_prefix><pk> for insert/update_after, DEL for delete/update_before - netted by key within a flush and pipelined. Effectively-once on the keyspace for a stable PRIMARY KEY and a deterministic defining query (SET/DEL are keyed and idempotent, so a replay converges). The stored value is the row's JSON with the synthetic __row_kind removed. Lets a retracting query maintain a Redis key-value view.
Source: the consumer group's per-consumer pending-entries list (PEL) is the durable cursor. On open() each consumer first re-drains its PEL (id 0), re-delivering anything delivered-but-not-acked before a crash, then switches to new entries (>). XACK is the offset commit and rides snapshot_offset(), so entries delivered since the last checkpoint are acknowledged when the checkpoint is taken.
Honest caveat (stated in redis_source.hpp): XACK is an external side effect that is not transactional with the global checkpoint. If snapshot_offset acks a batch and the global checkpoint then fails, those entries are gone from the PEL and not replayed, which is at-most-once for that one batch. Strict exactly-once would need a source on-commit hook, which is deferred. A reply-level XACK error keeps the ids buffered so the next checkpoint retries, and the entries remain in the PEL meanwhile, preserving at-least-once either way.
Limitations¶
- String channel only; record type is fixed to
std::stringfor both factories. - Sink is append-only at-least-once with no two-phase commit and no producer dedup;
mode='upsert'is rejected on the SQL path. - A source entry round-trips verbatim only when it carries the single configured
field(defaultv); any other entry is rendered as a JSON object of all field/value pairs, with all values string-typed (Redis bulk strings). - An
XDEL-tombstoned entry that is still pending is acknowledged and paged past but not emitted. - The
XACK-on-checkpoint commit is not transactional with the global checkpoint, so a single batch can be at-most-once if a checkpoint fails after the ack (see Delivery semantics). - Rescale caveat: a consumer is named
<prefix>-<subtask_idx>and a restart re-drains only its own PEL. On a scale-down the removed subtasks' consumers are never recreated, so their un-acked (pending) entries are orphaned in the group and not redelivered. Reclaiming them would needXAUTOCLAIMover the dropped consumers and is not done; op-level rescale is not currently reachable from the SQL path. Same-parallelism and scale-up restarts are unaffected. - TLS is available only when the build finds
libhiredis_ssland OpenSSL (CLINK_REDIS_TLS); otherwisetls=trueis rejected at runtime.
Testing¶
The connector has both in-process logic tests (test_redis_sink_logic.cpp, test_redis_source_logic.cpp) and an env-gated live integration test (test_redis_live.cpp).
The live test is skipped unless CLINK_REDIS_TEST_URL is set (for example redis://localhost:6379). The integration service is the redis:7 Docker image defined in docker/integration-services.yml. It covers a sink-to-source round-trip, PEL replay of an un-acked consumer on restart, that acked entries are not replayed, multi-field JSON-object rendering, and idempotent XGROUP CREATE.
export CLINK_REDIS_TEST_URL=redis://localhost:6379
ctest --test-dir build -L redis -R Live --output-on-failure
The TLS round-trip test additionally requires CLINK_REDIS_TLS_TEST_URL (set to a TLS-enabled redis host:port) and a build with CLINK_REDIS_TLS. The -L redis label runs the connector's test executable.