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) | The name sent as SNI and the one the server's certificate must carry; defaults to host. |
tls_verify |
No | true |
false skips server-certificate verification (self-signed development certs), the hostname check included. |
tls_verify_hostname |
No | true |
false keeps the certificate-chain check but accepts a certificate that names another host. |
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.
With tls_verify on, the handshake checks both that the server's certificate chains to a trusted CA (tls_ca, or the system store when it is empty) and that it names the host connected to (tls_sni when set, otherwise host). A name is matched against the certificate's DNS subjectAltNames, or its subject CN when it has none, with a wildcard covering one whole left-most label; an IP address is matched against its IP subjectAltNames only. A mismatch fails the connect with an error that names the host, what the certificate names, and tls_verify_hostname=false. hiredis itself checks only the chain, so the connector builds the TLS session and asks for the name before the handshake.
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'
);
A NaN or infinite double in a row reaches this sink through the row_to_json_string bridge as null, or as "NaN", "Infinity" or "-Infinity" when the sink table sets json_non_finite='string'; the sink handles it as it handles any null or string value. See NaN and infinity in JSON output.
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¶
- Each sink must be the only sink on its chain: at every checkpoint barrier it writes out what it received before the barrier, which holds only while it owns its chain's checkpoint, so
Dag::add_sinkrefuses another sink beside it, whichever is added first. SQL and job graphs plan every sink as a subtask of its own; with the Dag API, put the other sink in a separate Dag (see checkpointing). - 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, andclink connectorsreports the connector without TLS.
Testing¶
The connector has in-process logic tests (test_redis_sink_logic.cpp, test_redis_source_logic.cpp), server-certificate tests (test_redis_tls.cpp), and an env-gated live integration test (test_redis_live.cpp).
The certificate tests run in every TLS-enabled build without a Redis server: they connect to a local TLS listener (tests/test_helpers/tls_handshake_server.hpp), since the handshake completes before the client sends any command. They cover a certificate that names the address, one that names another host (refused, with the error), tls_sni as the name checked, wildcards, the hostname opt-out still checking the chain, and tls_verify=false.
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.