Ratomic
Ratomic provides mutable data structures for Ruby Ractors. Its core shared primitives are backed by native Rust concurrency libraries so Ruby code can share useful state across Ractors without falling back to one global lock. Pool and LocalPool are pure Ruby primitives that use Ruby Ractor ownership and locality semantics instead of the native Rust path.
Project Direction
Ratomic focuses on practical Ractor-safe primitives with a small API surface, clear ownership semantics, and honest documentation about sharp edges.
Requirements
- Ruby 4.0 or newer
- Bundler
- Rust toolchain when building the native extension from source
Installation
Add Ratomic to your application's Gemfile:
bundle add ratomic
Then require it from Ruby:
require "ratomic"
Documentation
API documentation is published to GitHub Pages:
RBS signatures are included under sig/ for downstream type checking.
Examples And Benchmarks
redis_poccontains local Redis scripts that exerciseRatomic::Map,Ratomic::Counter, andRatomic::LocalPoolunder Thread and Ractor workloads.pgoutput-parserusesRatomic::Mapfor relation metadata tracking in a real CDC pipeline POC, with a matching benchmark and deeper implementation notes in docs/relation_tracker.md.sidekiq-tenant-policy-cacheshowsRatomic::MapandRatomic::Counterin Sidekiq middleware for tenant policy caching and cache-hit / cache-miss tracking, with a benchmarked cache-vs-policy-every-job comparison.- The
cdc-parallelRatomic benchmark demonstrates Ractor workers updating shared CDC processing metrics throughRatomic::MapandRatomic::Counter.
Usage
Ratomic provides three safety models:
Counter,Map, andQueueare shared concurrent structures.Pooltransfers ownership of plain mutable objects between Ractors.LocalPoolkeeps live resources local to the Ractor that created them.
That distinction matters. A mutable pooled object is not shared by multiple Ractors at the same time. It is moved to the caller on checkout and moved back to the pool on checkin.
These structures are designed for use as class-level constants so they can be shared by many Ractors.
Ratomic::Counter
Ratomic::Counter is a Ractor-shareable atomic counter.
counter = Ratomic::Counter.new
counter.read # => 0
counter.increment(1)
counter.increment(5)
counter.decrement(1)
counter.decrement(1)
counter.read # => 4
counter.to_i # => 4
counter.zero? # => false
Ratomic::Map
Ratomic::Map is the primary Ractor-safe concurrent Hash primitive in Ratomic.
It is backed by Rust's DashMap and provides atomic per-key operations for real
Ractor workloads. It is not a full Hash replacement; iteration and arbitrary
mutable object borrowing are intentionally absent.
OFFSETS = Ratomic::Map.new
OFFSETS["mike"] = 123
OFFSETS["mike"] # => 123
OFFSETS.key?("mike") # => true
OFFSETS.fetch("missing", 0) # => 0
OFFSETS.fetch_or_store("count") { 0 } # => 0
OFFSETS.compute("mike") { |value| value + 1 } # => 124
OFFSETS.upsert("mike", 1) { |value| value + 1 } # => 125
OFFSETS.fetch_and_modify("mike") { |value| value + 1 }
OFFSETS.delete("mike") # => 126
OFFSETS.length
OFFSETS.empty?
OFFSETS.clear
Map also includes atomic convenience methods for common bucket patterns:
counts = Ratomic::Map.new
counts.increment("jobs") # => 1
counts.decrement("jobs") # => 0
groups = Ratomic::Map.new
groups.append("jobs", "import") # => ["import"]
groups.add_to_set("workers", "alpha") # => #<Set: {"alpha"}>
Some Map methods hold an internal guard while a block runs or while a
reference is live. Avoid re-entering the same map from inside those blocks or
mutating the same key while holding a reference from get or []. The API
docs cover the exact locking caveats.
Ratomic::Queue
Ratomic::Queue is a Ractor-shareable multi-producer, multi-consumer queue.
queue = Ratomic::Queue.new(128)
queue.push("hello")
queue << "world"
queue.size # => 2
queue.empty? # => false
queue.peek # => "hello"
queue.pop # => "hello"
queue.pop # => "world"
queue.empty? # => true
The .new(capacity) method initializes the queue with a fixed-size buffer.
Capacity must be at least 1 and at most 2**20. Non-power-of-two capacities
are supported exactly.
Since Ratomic::Queue is concurrent, size, empty?, and peek are
moment-in-time observations. Their results may already be stale by the time your
code uses them.
Ratomic::Pool
Ratomic::Pool is a Ractor-safe ownership-transfer pool for mutable Ruby objects.
BUFFERS = Ratomic::Pool.new(5, 1.0) { [] }
BUFFERS.with do |buffer|
buffer.clear
buffer << "work"
end
Pool uses Ruby 4's Ractor::Port and move: true semantics so only one
Ractor owns a checked-out object at a time.
When an object is checked out:
- the pool moves the object to the caller
- the caller can mutate the object while it owns it
- the pool cannot hand that object to another Ractor until it is checked in
When an object is checked in:
- ownership moves back to the pool
- stale references held by the caller become moved objects
- using those stale references raises
Ractor::MovedError
This means incorrect usage fails at the Ruby object-ownership boundary rather than allowing two Ractors to mutate the same object concurrently.
outside = nil
BUFFERS.with do |buffer|
outside = buffer
buffer << "inside"
end
outside << "outside"
# raises Ractor::MovedError
Manual checkout and checkin are also supported:
buffer = BUFFERS.checkout
raise "pool checkout timeout" if buffer.nil?
begin
buffer << "manual work"
ensure
BUFFERS.checkin(buffer) if buffer
end
checkout returns nil if no pooled object becomes available before the
configured timeout. with raises Ratomic::Error in that case.
Pool uses ownership transfer, not Rust's full borrow checker:
- the Rust owner maps to the Ractor that currently checked out the pooled object
- the Rust move maps to
Ractor::Port#send(..., move: true) - Rust's "cannot use after move" rule maps to Ruby raising
Ractor::MovedError - borrowing is not modeled;
Pooltransfers ownership instead of lending references
This design addresses issue #5,
where using a pooled object after with could lead to memory corruption or a
process crash.
The lower-level Ratomic::FixedSizeObjectPool native class may still exist, but
Ratomic::Pool does not inherit from it. The public Pool API is implemented
in Ruby so it can use Ruby's Ractor ownership primitives directly.
Ratomic::LocalPool
Ratomic::LocalPool is the safe pool shape for live resources that should stay
local to the Ractor that created them.
Use it for resources such as:
- Redis clients
- database connections
- HTTP clients
- Kafka producers
- OpenSearch clients
- per-worker caches, buffers, encoders, or aggregators
Unlike Ratomic::Pool, LocalPool does not move pooled objects between
Ractors. The LocalPool instance is a shareable facade. Each Ractor lazily
creates and owns its own private, thread-safe resource pool behind that facade.
Threads inside the same Ractor share that local pool, but different Ractors
never share the live resources.
require "ratomic"
require "redis-client"
RedisFactory = Data.define(:host) do
def call
RedisClient.new(host: host)
end
end
REDIS = Ratomic::LocalPool.new(
size: 10,
timeout: 1,
factory: RedisFactory.new("127.0.0.1".freeze)
)
REDIS.with do |client|
client.call("ping")
end
Use Pool for plain mutable values where ownership transfer is the intended
safety model. Use LocalPool for live resources that should be created, used,
and reused inside the same Ractor.
The intended topology is:
shareable LocalPool facade
↓
one local resource pool per Ractor
↓
threads inside that Ractor share local resources
The mental model is intentionally close to a Ruby local variable: the resource is
local to the execution scope that owns it. For LocalPool, that scope is the
current Ractor.
Pure Ruby implementation
LocalPool is implemented in pure Ruby.
Unlike Counter, Map, and Queue, it is not backed by the Rust native
extension. Its safety comes from Ruby Ractor ownership boundaries and locality,
not from Rust synchronization primitives.
Why not use Pool for Redis clients?
Pool moves checked-out objects between Ractors. That is correct for plain
mutable Ruby values such as arrays or buffers, but it is a poor fit for live I/O
resources. Redis clients, database connections, sockets, and similar resources
carry internal connection state. Moving those objects across Ractor boundaries can
leave nested internal state unusable, producing errors such as
Ractor::MovedError.
LocalPool avoids that class of bug by not moving live resources at all. Work
moves between Ractors. Live resources stay local.
Redis smoke-test snapshot
The Redis POC includes two scripts under redis_poc/.
basic_redis.rb exercises repeated Redis operations from both Threads and
Ractors:
Thread
[{"one" => 31501}, {"two" => 31379}, {"three" => 31320}, {"four" => 31410}, {"five" => 31454}]
Ractor
[{"one" => 42419}, {"two" => 42186}, {"three" => 42400}, {"four" => 42206}, {"five" => 42568}]
queue_redis.rb exercises a producer/consumer Redis queue workload:
[:start, Ractor, 2026-06-10 01:17:57.584727984 +0800]
[[:producer_done, 0], [:producer_done, 1]]
[[:consumer_done, 0, 7998], [:consumer_done, 1, 7987], [:consumer_done, 2, 7984], [:consumer_done, 3, 8035], [:consumer_done, 4, 7996]]
[{"one" => 0}, {"two" => 0}, {"three" => 0}, {"four" => 0}, {"five" => 0}]
[:end, 2026-06-10 01:18:02.226310862 +0800]
These numbers are a smoke-test snapshot, not a formal benchmark claim. The important interpretation is:
- no
Ractor::MovedError - no
Ractor::IsolationError - no process crash
- all produced queue items were consumed
- Redis queues drained to zero
- live Redis clients remained owned by the Ractor that created them
Inception pool
Internally, LocalPool follows the "inception pool" shape discovered while
experimenting with Redis clients under Ruby Ractors:
pool facade
↓
local pool
↓
resource
That same ownership pattern appears in hybrid execution runtimes: put parallel workers on the outside, keep I/O concurrency and live resources inside the worker that owns them. Ratomic keeps the primitive general-purpose and independent of any specific runtime, scheduler, database, or message system.
LocalPool#close closes only the current Ractor's local pool. Other Ractors own
their own pools and must close them independently when needed.
Contributing
Please read the Code of Conduct before contributing.
After changing code, run:
bundle exec rake
This compiles the Rust code and runs the test suite. The test suite writes a
SimpleCov report to coverage/index.html for the Ruby wrapper paths.
If you change the public Ruby API, update the curated RBS signatures under
sig/ratomic.rbs and run bundle exec rake rbs:validate before release or
review.
Thanks
Ilya Bylich wrote and documented his original research at Ruby, Ractors, and Lock-free Data Structures.
This repo continues that research into the usability and limitations of Ractor-friendly structures in Ruby code and gems.