The project is in a healthy, maintained state
Dependency-free RESP command encoding and stream decoding with injectable sources, selectors, clocks, error mappers, and RESP3 type handlers.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

>= 5, < 7
~> 13.0
 Project Readme

Solid RESP Ractor

Build Status Gem Version Downloads Documentation Status

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 install

Encoding 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
end

Nested 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)
end

Typed values include:

  • SolidRespRactor::Types::Set
  • SolidRespRactor::Types::Push
  • SolidRespRactor::Types::Verbatim
  • SolidRespRactor::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
end

wait_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.take

Create 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 rake

Current 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 rake

The default task runs the tests and builds the gem.