Solid RESP Ractor
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.
Installation
Add to your Gemfile:
gem "solid-resp-ractor"Then run:
bundle installEncoding commands
require "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.
Reusable codec configurations
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.
Reading RESP streams
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.
RESP3 types
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).
Resource limits
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)Extension points
Custom error mapping
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.
Custom value handling
Every decoded value can be transformed:
handler = lambda do |type, value|
Event.new(type:, value:)
end
reader = SolidRespRactor::Reader.new(io, handler: handler)Custom byte sources
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.
Timeouts and blocking commands
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.
Ractor usage
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.
Buffer management
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.
Reader benchmark
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 rakeCurrent baseline
Environment: 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.
Development
bundle install
bundle exec rakeThe default task runs the tests and builds the gem.