Protobuf::Nats
An rpc client and server library built using the protobuf gem and the NATS protocol.
Requirements
- Ruby
>= 3.1.0(CRuby or JRuby) - A reachable NATS server
Installation
Add this line to your application's Gemfile:
gem 'protobuf-nats'
Usage
This library is an alternative transport for the protobuf gem. Point protobuf at it with:
PB_SERVER_TYPE="protobuf/nats/runner"
PB_CLIENT_TYPE="protobuf/nats/client"
Example
For a more detailed example, see the warehouse app in the examples directory.
# app.rb
require "protobuf/nats"
class User < ::Protobuf::Message
optional :int64, :id, 1
optional :string, :username, 2
end
class UserService < ::Protobuf::Rpc::Service
rpc :create, User, User
def create
respond_with User.new(:id => 123, :username => request.username)
end
end
Start a server, then call it from a client:
$ export PB_SERVER_TYPE="protobuf/nats/runner"
$ export PB_CLIENT_TYPE="protobuf/nats/client"
$ bundle exec rpc_server start ./app.rb
$ bundle exec irb -r ./app
irb> UserService.client.create(User.new(:username => "testing 123"))
=> #<User id=123 username="testing 123">
An rpc without a matching instance method (e.g. an unimplemented search) is simply not subscribed to.
Configuring
Environment variables
Numeric variables are parsed strictly: a malformed value (e.g. PB_NATS_CLIENT_ACK_TIMEOUT=5s) is logged and the
default is used, instead of silently becoming 0.
Client
| Variable | Default | Description |
|---|---|---|
PB_NATS_CLIENT_ACK_TIMEOUT |
5 |
Seconds to wait for the server's ACK. |
PB_NATS_CLIENT_RESPONSE_TIMEOUT |
60 |
Seconds to wait for the RPC response. |
PB_NATS_CLIENT_MAX_RETRIES |
3 |
Attempts for ACK timeouts and transient transport errors. Retries re-send the request — see Delivery semantics. |
PB_NATS_CLIENT_NACK_BACKOFF_INTERVALS |
0,1,3,5,10 |
Milliseconds to wait between NACK retries (one attempt per interval). |
PB_NATS_CLIENT_NACK_BACKOFF_SPLAY_LIMIT |
10 |
Random jitter (ms) added to each NACK backoff. |
PB_NATS_CLIENT_RECONNECT_DELAY |
ACK timeout | Seconds to sleep before retrying after a transient transport error — see Resilience. |
PB_NATS_CLIENT_RECONNECT_DELAY_SPLAY_LIMIT |
1000 |
Random jitter (ms, 0..limit) added to the reconnect delay so a fleet doesn't retry in lockstep. 0 disables. |
PB_NATS_RESPONSE_MUXER_DISPATCHERS |
CPUs on JRuby, 1 on CRuby |
Threads draining the shared response subscription (min 1). |
Server
| Variable | Default | Description |
|---|---|---|
PB_NATS_SERVER_MAX_QUEUE_SIZE |
thread count | Queue in front of the handler thread pool; requests beyond it are NACKed. |
PB_NATS_SERVER_SUBSCRIPTION_HANDLERS |
CPUs on JRuby, 1 on CRuby |
Threads draining the shared intake queue and publishing ACK/NACKs (min 1). Consumer parallelism only — does not change queue-group delivery. |
PB_NATS_SERVER_INTAKE_QUEUE_SIZE |
65536 |
Capacity of the shared intake queue. Smaller turns overload into prompt drops-and-retries instead of a deep stale backlog; tune down alongside PB_NATS_SERVER_STALE_REQUEST_MS. |
PB_NATS_SERVER_SUBSCRIPTIONS_PER_RPC_ENDPOINT |
10 |
Subscriptions created per endpoint (lets JVM servers warm up gradually). Queue groups still deliver each request to exactly one consumer. |
PB_NATS_SERVER_SLOW_START_DELAY |
10 |
Seconds between slow-start subscription rounds. |
PB_NATS_SERVER_PAUSE_FILE_PATH |
nil |
While this file exists the server unsubscribes from all services; it resubscribes (with slow start) when the file is removed. |
PB_NATS_SERVER_SLOW_HANDLER_THRESHOLD_MS |
0 (off) |
Emit server.slow_handler when a handler runs longer than this. Informational only. |
PB_NATS_SERVER_HANDLER_OVERDUE_MS |
65000 |
Age at which a still-running handler is reported overdue (its client has already given up). Keep ≈ your clients' response timeout plus a small grace. |
PB_NATS_SERVER_RECLAIM_OVERDUE_HANDLERS |
false |
"true" reclaims an overdue handler's pool slot by aborting it — see note below. |
PB_NATS_SERVER_STALE_REQUEST_MS |
0 (off) |
Drop requests older than this at intake (the client has already retried or given up) — see note below. |
PB_NATS_SERVER_SHUTDOWN_DRAIN_TIMEOUT |
overdue ms / 1000 + 5 | Seconds to let in-flight handlers finish during shutdown before abandoning them. |
Shared
| Variable | Default | Description |
|---|---|---|
PB_NATS_CONNECTION_NAME |
hostname | Connection name shown in NATS server monitoring. Precedence: env var > yaml connection_name > hostname. |
PROTOBUF_NATS_CONFIG_PATH |
config/protobuf_nats.yml |
Path to the yaml config. |
Overdue handlers are never aborted by default — killing a thread mid-handler can corrupt state. Enable
PB_NATS_SERVER_RECLAIM_OVERDUE_HANDLERS only when orphaned work (whose clients already gave up) is saturating the
pool and healthy traffic is being NACKed.
Stale-request shedding trusts client clocks. The request age comes from the UUIDv7 reply token, which encodes the client's wall-clock time — enable only with sane NTP across hosts, and keep the threshold comfortably above the client ACK timeout. Requests without a UUIDv7 token (foreign clients) are never shed.
YAML config
Connection-level settings live in a yaml file, keyed by environment (RAILS_ENV / RACK_ENV / APP_ENV, default
development). The default path is config/protobuf_nats.yml; override with PROTOBUF_NATS_CONFIG_PATH.
# config/protobuf_nats.yml
---
production:
servers:
- "nats://127.0.0.1:4222"
- "nats://127.0.0.1:4223"
- "nats://127.0.0.1:4224"
max_reconnect_attempts: 500 # -1 reconnects forever
reconnect_time_wait: 2 # seconds between reconnect attempts (nats-pure default: 2)
ping_interval: 120 # seconds between health-check PINGs (nats-pure default: 120)
max_outstanding_pings: 2 # missed PINGs before the connection is declared dead (nats-pure default: 2)
connection_name: "my-service"
connect_timeout: 2
uses_tls: true
tls_client_cert: "/path/to/client-cert.pem"
tls_client_key: "/path/to/client-key.pem"
tls_ca_cert: "/path/to/ca.pem"
server_subscription_key_only_subscribe_to_when_includes_any_of:
- "search"
- "create"
server_subscription_key_do_not_subscribe_to_when_includes_any_of:
- "old_search"
- "old_create"
subscription_key_replacements:
- "original_service": "replacement_service"
subscription_key_replacements is useful for local testing but subject to breaking changes.
TLS
With uses_tls, the client negotiates TLS 1.2–1.3 and verifies the NATS server's certificate chain
(VERIFY_PEER): certificates must chain to tls_ca_cert when set, or to the system trust store otherwise. If you are
upgrading from a release that did not verify (< 0.13.1), make sure tls_ca_cert points at the CA that signed your
NATS server certificate, or the connection will be rejected. Hostname (SAN/CN) verification is not yet enabled.
How it works
protobuf-nats uses nats-pure's NATS::IO::Client on both CRuby and JRuby.
- ResponseMuxer (client) — one wildcard subscription multiplexes all RPC responses instead of subscribing per request. Dispatcher threads route each reply to its waiting caller by UUIDv7 token, lock-free on the hot path, and self-heal with decaying exponential backoff if they crash.
- SuperSubscriptionManager (server) — manages the endpoint subscriptions (NATS queue groups: one consumer per request) including slow start and pause/resume. All subscriptions feed one shared intake queue drained by handler threads, so a slow ACK publish can't head-of-line block other subjects. Handlers self-heal like the muxer.
- Observability — thread-pool gauges plus in-flight handler metrics (
server.inflight_count,server.inflight_oldest_age_ms,server.overdue_handler_count,server.pending_intake_queue_size,server.thread_pool_saturated). Error callbacks run on a bounded background executor; drops are counted (Protobuf::Nats.error_callback_drop_count) and emiterror_callback_dropped.
Resilience
The client rides out transient NATS hiccups rather than surfacing them as request failures:
- Transient transport errors are retried (
Errors::RETRYABLE_TRANSPORT_ERRORS): the client sleepsPB_NATS_CLIENT_RECONNECT_DELAY(plus jitter) and retries up toPB_NATS_CLIENT_MAX_RETRIES, rebuilding a terminally closed connection — and moving the muxer's subscription onto it — before each retry. - A failing NATS node fails over automatically.
nats-purereconnects through the wholeserverspool and replays all subscriptions on the new node. A node that dies silently (no FIN/RST) is only detected by the PING health check — up toping_interval * max_outstanding_pings(240s at defaults); lower those yaml keys for faster failover. - Missing ACKs and NACKs are retried with their own timeouts/backoff. Every retry re-sends the request — see Delivery semantics.
- Server-side failures fail the caller fast. A failed handler publishes a generic RPC error response (details stay in server logs) so the client raises immediately instead of waiting out its response timeout.
- The server exits rather than running deaf. If its connection is terminally closed (reconnects exhausted), the run
loop stops — emitting
server.connection_closed— so a supervisor restarts the process. Usemax_reconnect_attempts: -1to prefer in-process reconnects forever.
Delivery semantics (at-least-once)
RPC delivery is at-least-once and the gem does not deduplicate: a client retry can re-send a request the server already processed, so a single call can run a handler more than once. This is deliberate — dropping work on a transient blip is usually worse than occasionally repeating it.
Making that safe is the service author's responsibility: key writes on a natural id or idempotency token
(upsert / find_or_create), and make external side effects (charges, emails, downstream RPCs) safe to repeat or guard
them with your own dedup. An opt-in per-RPC dedup with a pluggable store may be added later; it will not be the default.
Future improvements
- Migrate from the
uuid7gem to nativeRandom#uuid_v7once the minimum Ruby version supports it (seeUUIDv7Helper).
Benchmarks
Microbenchmarks live in bench/ and need no NATS server; see bench/bench.md. Highlights on JRuby: muxer dispatch
~2.5× faster with the per-message lock removed (bench/muxer_resilience_bench.rb), server intake fan-out ~8×
throughput with head-of-line stalls ~505ms → ~2ms (bench/server_intake_bench.rb).
bundle exec ruby -Ilib bench/server_intake_bench.rb
bundle exec ruby -Ilib bench/muxer_resilience_bench.rb
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can
also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the
version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version,
push git commits and tags, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/mxenabled/protobuf-nats.
License
The gem is available as open source under the terms of the MIT License.