Skip to content

redis.stackexchange: StackExchange.Redis with its connections on the reactor's ring (experimental) - #335

Merged
MDA2AV merged 3 commits into
mainfrom
feat/redis-stackexchange
Oct 12, 2026
Merged

MDA2AV merged 3 commits into
mainfrom
feat/redis-stackexchange

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

StackExchange.Redis 3.x ships an experimental transport seam (SER009; #3152, #3173, #3188). A Tunnel can hand a multiplexer a whole DuplexTransport through ConnectTransportAsync, and StackExchange.Redis then opens no socket of its own:

  • Outbound: the transport is an IBufferWriter<byte>, staged from any thread, with an explicit Flush().
  • Inbound: push - OnReceived with transport-owned bytes, then OnBatchEnd and OnClosed. StackExchange.Redis parses the bytes and completes the commands on the thread that pushed them.

Marc Gravell's SocketSet has the reference implementation, SocketSet.StackExchange.Redis. This PR adds ioxide.redis.stackexchange: the same seam on a reactor.

How it works

  • IoxideTunnel(reactor): each connection is a RingSocket on that reactor. A name is resolved off the reactor, then each address is tried in turn.
  • Receives complete on the reactor and push into StackExchange.Redis there. One OnBatchEnd is posted per loop turn that delivered anything, so the responses a burst produces go out in one flush.
  • Flush() hands the staged bytes to the ring as one send. Bytes flushed while a send is in flight queue behind it.
  • Closing: the socket is shut down, then disposed once nothing is in flight, and OnClosed fires once.
  • IoxideRedis.ConnectAsync(reactor, options) sets the tunnel and connects. With one multiplexer per reactor, made in OnStart, a handler's commands are staged, sent, parsed and completed on its own thread.
  • TCP without TLS for now: a configuration that asks for TLS is refused when the connection is made, and the refusal says so in the connect log.

Bench

The new Playground/Clients/RedisStackExchange has a knob, PLAYGROUND_REDIS_TRANSPORT=sockets, that runs the same handler on StackExchange.Redis's own sockets.

Setup:

  • the GET hot path, 2 reactors;
  • wrk -t2 -c64 -d10s against a local redis 7;
  • the server's CPU per request is utime+stime from /proc over the run;
  • three interleaved rounds.
Requests per second CPU per request
StackExchange.Redis over ioxide 448k, 451k, 456k 4.03, 4.02, 3.96 us
StackExchange.Redis on its own sockets 422k, 430k, 431k 8.73, 8.04, 8.80 us
ioxide.redis (Clients/Redis, 4 connections per reactor) 352k, 350k, 350k 5.47, 5.48, 5.50 us

This is one machine over loopback, with redis on the same box. The ioxide.redis row is its own sample's GET route: the same shape, through a different handler. ioxide.redis has since been dropped (#337); the row stays as the reference it was measured against.

Tests

These are in the Redis suite and run against the suite's redis; with ioxide.redis dropped (#337), they are the suite. 6 pass, 1 pending:

  • SET then GET, with StackExchange.Redis completing the command on the reactor's thread.
  • 1000 concurrent INCRs, which exercises flushes queued behind a send in flight.
  • A 1 MiB value, which arrives across several receives.
  • Pub/sub through the transport.
  • A connection the server kills (CLIENT KILL) comes back through the tunnel.
  • TLS is refused with the transport's reason rather than a timeout.

The thread test was confirmed both ways. On StackExchange.Redis's own sockets the completion is posted from another thread and the test fails; through the tunnel it is posted from the reactor and the test passes.

A StackExchange.Redis gap this surfaced

RecordConnectionFailed (PhysicalConnection.cs:571 in 3.4.0) reports a failure only when _ioStream is not null. A transport connection never gets a Stream, so when its transport closes:

  • no ConnectionFailed event is raised;
  • a command caught by the close is cancelled (TaskCanceledException) instead of failing with RedisConnectionException: SocketClosed, which is what StackExchange.Redis's own sockets give.

Reconnection still works. The gap affects every transport, SocketSet's included. A pending test pins it, and it will fail - asking to become a Test - once StackExchange.Redis reports transport closes too.

Not covered

  • Experimental. SER009 asks that no shipped code be built on the seam. The package pins StackExchange.Redis 3.4.0 exactly, and it is not in the CI pack and publish steps: whether to publish it is your call.
  • No TLS through the tunnel yet. ioxide's client TLS lives in ioxide.httpclient.
  • TCP only. No Unix domain sockets.
  • Async APIs only on a reactor. A synchronous StackExchange.Redis call there waits for a completion only that reactor can run - the same rule as everywhere else in ioxide.

…se transport is a socket on a reactor

StackExchange.Redis 3.x has an experimental transport seam (SER009): a Tunnel can hand a
multiplexer a whole DuplexTransport through ConnectTransportAsync, and StackExchange.Redis then opens
no socket of its own. Outbound the transport is an IBufferWriter staged from any thread with an
explicit Flush; inbound is push - OnReceived with transport-owned bytes, OnBatchEnd, OnClosed - and
StackExchange.Redis parses and completes commands on the thread that pushed them. SocketSet's
SocketSet.StackExchange.Redis is the reference implementation.

IoxideTunnel implements it on a reactor: the transport is a RingSocket, its receives complete on the
reactor and push into StackExchange.Redis there, one OnBatchEnd is posted per loop turn that
delivered anything, and Flush hands the staged bytes to the ring as one send (bytes flushed while a
send is in flight queue behind it). With one multiplexer per reactor, made in OnStart, a handler's
commands are staged, sent and completed on its own thread. TCP without TLS for now: a configuration
asking for TLS is refused with a message saying so. StackExchange.Redis is pinned to 3.4.0, since
the seam may change in any release.

Tests run against the suite's redis: SET/GET completed on the reactor's thread, 1000 concurrent
INCRs, a 1 MiB value, pub/sub, a connection the server kills coming back through the tunnel, and the
TLS refusal. The thread test is discriminating: on StackExchange.Redis's own sockets the completion
is posted from another thread and it fails. A pending test pins a StackExchange.Redis gap the
transport surfaced: RecordConnectionFailed reports a failure only for a connection with a Stream, so
a command caught by a transport's close is cancelled instead of failing with the connection's error.
MDA2AV added a commit that referenced this pull request Oct 12, 2026
ioxide.redis is weight the project does not want to carry: a whole Redis client to maintain - five
open fixes against it - when StackExchange.Redis can run on the reactor's ring instead (#335,
ioxide.redis.stackexchange).

Removed: the package, its Playground sample (Clients/Redis) and the Env helper only it used, its
tests (the Redis suite stays, empty, for the transport's tests), its CI pack step, its bench run and
row, its pane on the docs page, and docs/learn's two Redis pages with their links. The passing Redis
mentions in the Http3/Nghttp3Buffered sample's comments go too. The versions already on nuget.org
are untouched.
# Conflicts:
#	Playground/README.md
#	README.md
#	docs/assets/style.css
#	docs/index.html
#	ioxide.slnx
#	scripts/gen-docs-panes.py
#	tests/Ioxide.Tests.Redis/Ioxide.Tests.Redis.csproj
#	tests/Ioxide.Tests.Redis/Program.cs
#	tests/README.md
@MDA2AV
MDA2AV merged commit ac32a67 into main Oct 12, 2026
1 check passed
@MDA2AV MDA2AV mentioned this pull request Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant