solid-resp-ractor is a dependency-free RESP2/RESP3 codec for Ruby. It keeps
protocol parsing independent from Redis client behavior and provides explicit
extension points for transports, event loops, error hierarchies, value
representation, and command argument conversion.
The gem has no global mutable state. Immutable encoder instances can be shared between Ractors; readers remain local to the Ractor that owns their IO.
Add to your Gemfile:
gem "solid-resp-ractor"Then run:
bundle installrequire "solid_resp_ractor"
SolidRespRactor.encode(["SET", "key", "value"])
# => "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n"Arrays are expanded by one level, which supports APIs that group command arguments:
SolidRespRactor.encode(["MSET", ["first", 1], ["second", 2]])Use an immutable custom encoder for domain-specific values:
argument_encoder = Object.new
def argument_encoder.call(value)
value.respond_to?(:to_wire) ? value.to_wire : value.to_s
end
argument_encoder.freeze
encoder = SolidRespRactor::Encoder.new(argument_encoder: argument_encoder)
encoder.encode(["SET", "key", custom_value])Set expand_arrays: false when arrays represent individual values rather than
argument groups.
Codec groups all extension points into one immutable object:
codec = SolidRespRactor::Codec.new(
encoder: custom_encoder,
handler: SolidRespRactor::Handlers::Typed,
error_mapper: error_mapper,
limits: SolidRespRactor::Limits.new(max_blob_size: 64 * 1024 * 1024),
chunk_size: 32_768,
)
payload = codec.encode(["PING"])
reader = codec.reader(socket, read_timeout: 1.0)SolidRespRactor::DEFAULT_CODEC is Ractor-shareable. A custom codec is also
shareable when all injected collaborators are shareable.
Decoded responses are not automatically Ractor-shareable. Typed wrappers are
frozen, but mutable strings, arrays, hashes, and nested values they contain are
not deeply frozen. Transform a response explicitly with
Ractor.make_shareable (copying it first when mutation must remain possible)
before sending it to another Ractor.
The default reader accepts blocking IO objects such as StringIO, as well as
non-blocking TCP, Unix, and TLS-compatible streams:
socket = TCPSocket.new("127.0.0.1", 6379)
reader = SolidRespRactor.reader(socket, read_timeout: 1.0)
socket.write(SolidRespRactor.encode(["PING"]))
reader.read
# => "PONG"Use exception: false for pipelines or proxies that need error values:
result = reader.read(exception: false)
if result.is_a?(SolidRespRactor::ResponseError)
warn result.message
endNested errors are raised only after the complete aggregate has been consumed, so the next frame remains synchronized.
The compatible handler returns ordinary Ruby values and intentionally unwraps RESP3 sets, pushes, verbatim strings, and attributes:
reader = SolidRespRactor::Reader.new(io)Use the typed handler when those distinctions matter:
reader = SolidRespRactor::Reader.new(
io,
handler: SolidRespRactor::Handlers::Typed,
)
case (value = reader.read)
when SolidRespRactor::Types::Push
process_push(value.value)
when SolidRespRactor::Types::Attribute
process(value.value, metadata: value.attributes)
endTyped values include:
SolidRespRactor::Types::SetSolidRespRactor::Types::PushSolidRespRactor::Types::VerbatimSolidRespRactor::Types::Attribute
RESP3 streamed blob strings, arrays, maps, and sets are supported as well as
special double values (inf, -inf, and nan).
The default immutable limits protect generic protocol consumers from unbounded allocations and nesting:
| Limit | Default |
|---|---|
| Blob or chunked string | 512 MiB |
| Collection cardinality | 1,000,000 |
| Nesting depth | 128 |
| Line length | 64 KiB |
Provide stricter limits for untrusted peers:
limits = SolidRespRactor::Limits.new(
max_blob_size: 8 * 1024 * 1024,
max_collection_size: 10_000,
max_nesting_depth: 32,
max_line_size: 8 * 1024,
)
codec = SolidRespRactor::Codec.new(limits: limits)Map RESP error frames into an application's existing exception hierarchy:
error_mapper = lambda do |message, blob:|
MyProtocolError.new(message)
end
reader = SolidRespRactor::Reader.new(io, error_mapper: error_mapper)The mapper must return an Exception.
Every decoded value can be transformed:
handler = lambda do |type, value|
Event.new(type:, value:)
end
reader = SolidRespRactor::Reader.new(io, handler: handler)A source only needs:
class Source
def read(timeout:)
# Return a non-empty String, or nil at EOF.
end
def wait_readable(timeout)
# Optional polling API.
end
end
reader = SolidRespRactor::Reader.new(source: Source.new)This allows integration with event loops, in-memory transports, framed protocols, instrumentation, or test fixtures without changing the parser.
The built-in Sources::IO also accepts custom selector and clock objects.
read_timeout: nil waits indefinitely. Temporarily override a timeout without
rebuilding the reader:
reader.with_timeout(nil) do
reader.read
endwait_readable(timeout) polls without consuming bytes, which is useful for
subscriptions and event-driven clients.
The default encoder is shareable:
encoder = SolidRespRactor::DEFAULT_ENCODER
ractor = Ractor.new(encoder) do |shared_encoder|
socket = TCPSocket.new("127.0.0.1", 6379)
reader = SolidRespRactor::Reader.new(socket, read_timeout: 1.0)
socket.write(shared_encoder.encode(["PING"]))
reader.read
ensure
socket&.close
end
ractor.takeCreate sockets and readers inside their owning Ractor. Custom handlers, encoders, selectors, and clocks must themselves be Ractor-shareable if they are passed between Ractors. Responses remain local to the reader's Ractor unless the application explicitly transforms them into shareable values.
The reader advances a virtual cursor through buffered bytes. It clears a fully consumed buffer immediately and compacts a partially consumed buffer only after at least 16 KiB have been consumed and that prefix occupies at least half of the buffer. This avoids copying a large unread suffix after small fragmented reads while still releasing consumed data during long-lived streams.
Encoding, Reader-only, TCP, allocation, and Ractor-scaling benchmarks live in
the separate benchmark_solid_resp_ractor sibling bundle so benchmark tooling
and generated reports remain outside the gem.
Run the complete matrix from the sibling checkout:
cd ../benchmark_solid_resp_ractor
RBENV_VERSION=4.0.1 \
BENCHMARK_OUTPUT=results/ruby-4.0.1-reader.md \
bundle exec rakeEnvironment: Ruby 4.0.1 (arm64-darwin25); solid-resp-ractor 0.1.3; Redis 8.10.0. Values are medians of three runs with one second of warmup and three seconds of measurement per row. Pipeline metrics are amortized per command. Reader-only rows consume an in-memory repeating 16 KiB source. Reader + TCP rows use an isolated loopback Redis server. Allocation metrics are measured separately in one Ractor with GC disabled, then repeated across the scaling rows; Redis allocations are excluded.
| Ractors | Layer | Operation | ops/s | Scaling efficiency | alloc/op | bytes/op |
|---|---|---|---|---|---|---|
| 1 | Encode | GET | 1,261,840 | 100.0% | 2.0 | 104.8 |
| 2 | Encode | GET | 2,298,471 | 91.1% | 2.0 | 104.8 |
| 4 | Encode | GET | 3,898,679 | 77.2% | 2.0 | 104.8 |
| 8 | Encode | GET | 5,234,281 | 51.9% | 2.0 | 104.8 |
| 1 | Encode | SET | 974,781 | 100.0% | 2.0 | 104.8 |
| 2 | Encode | SET | 1,797,823 | 92.2% | 2.0 | 104.8 |
| 4 | Encode | SET | 3,249,650 | 83.3% | 2.0 | 104.8 |
| 8 | Encode | SET | 4,470,244 | 57.3% | 2.0 | 104.8 |
| 1 | Encode | pipeline 50 | 1,345,762 | 100.0% | 2.0 | 145.8 |
| 2 | Encode | pipeline 50 | 2,482,408 | 92.2% | 2.0 | 145.8 |
| 4 | Encode | pipeline 50 | 4,250,421 | 79.0% | 2.0 | 145.8 |
| 8 | Encode | pipeline 50 | 5,779,732 | 53.7% | 2.0 | 145.8 |
| 1 | Reader | +OK | 1,690,247 | 100.0% | 2.0 | 40.8 |
| 2 | Reader | +OK | 3,197,883 | 94.6% | 2.0 | 40.8 |
| 4 | Reader | +OK | 5,788,420 | 85.6% | 2.0 | 40.8 |
| 8 | Reader | +OK | 8,029,110 | 59.4% | 2.0 | 40.8 |
| 1 | Reader | integer | 1,562,655 | 100.0% | 2.0 | 40.8 |
| 2 | Reader | integer | 2,968,526 | 95.0% | 2.0 | 40.8 |
| 4 | Reader | integer | 5,459,562 | 87.3% | 2.0 | 40.8 |
| 8 | Reader | integer | 7,423,102 | 59.4% | 2.0 | 40.8 |
| 1 | Reader | bulk 16 B | 787,426 | 100.0% | 3.0 | 120.8 |
| 2 | Reader | bulk 16 B | 1,533,310 | 97.4% | 3.0 | 120.8 |
| 4 | Reader | bulk 16 B | 2,796,102 | 88.8% | 3.0 | 120.8 |
| 8 | Reader | bulk 16 B | 4,090,043 | 64.9% | 3.0 | 120.8 |
| 1 | Reader | bulk 1 KiB | 709,062 | 100.0% | 3.0 | 1,105.8 |
| 2 | Reader | bulk 1 KiB | 1,225,199 | 86.4% | 3.0 | 1,105.8 |
| 4 | Reader | bulk 1 KiB | 2,055,006 | 72.5% | 3.0 | 1,105.8 |
| 8 | Reader | bulk 1 KiB | 2,597,586 | 45.8% | 3.0 | 1,105.8 |
| 1 | Reader | array 50 | 32,877 | 100.0% | 103.0 | 2,680.8 |
| 2 | Reader | array 50 | 60,955 | 92.7% | 103.0 | 2,680.8 |
| 4 | Reader | array 50 | 115,104 | 87.5% | 103.0 | 2,680.8 |
| 8 | Reader | array 50 | 185,791 | 70.6% | 103.0 | 2,680.8 |
| 1 | Reader | nested RESP3 | 87,279 | 100.0% | 34.0 | 1,080.9 |
| 2 | Reader | nested RESP3 | 166,456 | 95.4% | 34.0 | 1,080.9 |
| 4 | Reader | nested RESP3 | 302,211 | 86.6% | 34.0 | 1,080.9 |
| 8 | Reader | nested RESP3 | 433,100 | 62.0% | 34.0 | 1,080.9 |
| 1 | Reader + TCP | GET | 40,173 | 100.0% | 4.0 | 120.8 |
| 2 | Reader + TCP | GET | 66,235 | 82.4% | 4.0 | 120.8 |
| 4 | Reader + TCP | GET | 89,146 | 55.5% | 4.0 | 120.8 |
| 8 | Reader + TCP | GET | 102,060 | 31.8% | 4.0 | 120.8 |
| 1 | Reader + TCP | pipeline 50 | 473,123 | 100.0% | 3.0 | 120.0 |
| 2 | Reader + TCP | pipeline 50 | 878,582 | 92.8% | 3.0 | 120.0 |
| 4 | Reader + TCP | pipeline 50 | 1,478,781 | 78.1% | 3.0 | 120.0 |
| 8 | Reader + TCP | pipeline 50 | 2,044,122 | 54.0% | 3.0 | 120.0 |
Pure bulk decoding represents roughly 5.1% of the service time of a non-pipelined loopback GET, but about 60% of an amortized pipeline command. These are directional ratios rather than profiler attribution.
Reusing the destination String passed to read_nonblock reduced GET
allocation from 16,609.8 to 120.8 bytes/op (-99.27%) and from 6.0 to 4.0
objects/op. GET throughput changed by -0.68% at 1R and improved by 8.18% at
8R. Pipeline allocation fell from 472.3 to 120.0 bytes/op (-74.59%), while
throughput improved by 2.23% at 8R. The allocation improvement is structural
and does not require changing Reader parser invariants.
bundle install
bundle exec rakeThe default task runs the tests and builds the gem.