ratomic 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6fbef9f27ee3ebd3839fb95b3e3c4685aaa34be9c22f1314974726c5f38aada8
4
- data.tar.gz: 668591405949669d19687a678029ca07f46f155b3740f9a7d5fff599a5d8003c
3
+ metadata.gz: 5196316489486d3034c046db087ae5878dc7fcf2f2e3d4086d91c128f8a30dd5
4
+ data.tar.gz: 6bf7a6aed05756ff9a0f49f274d69ed91b1f722930c94a140cc5450746fec3f2
5
5
  SHA512:
6
- metadata.gz: e31111fffe0abc54d5e7c945c7bd618dc7b201a0b35c48c438dbe7cf3db414106c5638ac4680f67a018a55545f5f62d09e6efe79887577f9a8516b5d7d6569cc
7
- data.tar.gz: b46b875f77412cfbb3edba3ed07fd2087fb70ded07023ad091d32f911d4fa5b9af06fa42a1db770bc24d357683828cc0ec3df5f429ae177c45bc3df4f7ebc9fb
6
+ metadata.gz: f0276e6c7e78c7bdcbabb3839d59eb70e58289f3662fe1df244fce9abc9b3a1eeb3454a4116081cd0f0426e470ed64a3d0108f8a57650455e17cc96f111f51e7
7
+ data.tar.gz: 8eb3b4c4f248569568ea7394a89156961aeb8108b0448c5ef3012045486537cd3c862fff684ac39192d2c31842159e5ad798b9005ce48909281f19d1c9043827
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-06-05
4
+
5
+ - Promote the DashMap-backed `Ratomic::Map` API as the primary concurrent Hash primitive.
6
+ - Add `Ratomic::Map#key?`, `#include?`, `#member?`, and `#delete`.
7
+ - Add `Ratomic::Map#fetch`, `#compute`, `#fetch_or_store`, and `#upsert` for atomic per-key workflows.
8
+ - Add `Ratomic::Map#increment`, `#decrement`, `#append`, and `#add_to_set` convenience methods for shared counters and bucket-style values.
9
+ - Improve API documentation for `Counter`, `Map`, `Queue`, and `Pool`.
10
+ - Add GitHub Pages deployment for generated YARD API documentation.
11
+ - Add Redis POC scripts for exercising `Ratomic::Map`, `Ratomic::Counter`, and `Ratomic::Pool` under Thread and Ractor workloads.
12
+ - Remove `Counter#inc` and `Counter#dec` in favor of the native `#increment` and `#decrement` methods.
13
+
14
+ ## [0.2.1] - 2026-06-04
15
+
16
+ - Fix `Ratomic::Queue` slot indexing for non-power-of-two capacities.
17
+ - Fix `Ratomic::Map#fetch_and_modify` to propagate block exceptions instead of panicking.
18
+
3
19
  ## [0.2.0] - 2026-06-04
4
20
 
5
21
  - Drop Ruby 3.x support and require Ruby 4.
data/README.md CHANGED
@@ -1,176 +1,250 @@
1
1
  # Ratomic
2
2
 
3
- Ratomic provides mutable data structures for use with Ruby's Ractors.
4
- This allows Ruby code to scale beyond the infamous GVL.
3
+ [![Gem Version](https://badge.fury.io/rb/ratomic.svg)](https://badge.fury.io/rb/ratomic)
4
+ [![CI](https://github.com/mperham/ratomic/workflows/CI/badge.svg)](https://github.com/mperham/ratomic/actions)
5
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%204.0-ruby.svg)](https://www.ruby-lang.org/en/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
7
 
6
- # HELP WANTED!
8
+ Ratomic provides mutable data structures for Ruby Ractors. Its primitives are backed by
9
+ native Rust concurrency libraries so Ruby code can share useful state across Ractors
10
+ without falling back to one global lock.
7
11
 
8
- > If you know Rust and Ruby C-extensions, we need your help!
9
- > This project is brand new and could use your knowledge!
10
- > If you don't know Rust or C, consider this a challenge to learn and solve.
11
- > Read through the [issues](//github.com/mperham/ratomic/issues) to find work that sounds interesting to you.
12
+ ## Project Direction
12
13
 
13
- ## How to contribute
14
+ Ratomic focuses on practical Ractor-safe primitives with a small API surface, clear
15
+ ownership semantics, and honest documentation about sharp edges.
14
16
 
15
- Please make sure to understand our [Code of Conduct](./CODE_OF_CONDUCT.md).
17
+ `Ratomic::Map` is the current priority: a Ruby-facing concurrent Hash powered by
18
+ DashMap, with atomic per-key operations designed for real Ractor workloads.
16
19
 
17
- After changing code, you can give it a spin with:
20
+ ## Requirements
18
21
 
19
- ```bash
20
- rake
21
- ```
22
-
23
- This should compile the Rust code and run all tests.
24
- The test suite writes a SimpleCov report to `coverage/index.html` so you can see which Ruby wrapper paths are covered.
22
+ - Ruby 4.0 or newer
23
+ - Bundler
24
+ - Rust toolchain when building the native extension from source
25
25
 
26
26
  ## Installation
27
27
 
28
- Install the gem and add to the application's Gemfile by executing:
28
+ Add Ratomic to your application's Gemfile:
29
29
 
30
30
  ```bash
31
31
  bundle add ratomic
32
32
  ```
33
33
 
34
- TODO: We have not released a gem yet.
34
+ Then require it from Ruby:
35
35
 
36
- ## Usage
36
+ ```ruby
37
+ require "ratomic"
38
+ ```
37
39
 
38
- Ratomic provides several useful Ractor-safe structures.
39
- Note the APIs available are frequently very limited compared to Ruby's broad API.
40
+ ## Documentation
41
+
42
+ API documentation is published to GitHub Pages:
43
+
44
+ - [mperham.github.io/ratomic](https://mperham.github.io/ratomic/)
45
+
46
+ ## Examples And Benchmarks
47
+
48
+ - [`redis_poc`](./redis_poc) contains local Redis scripts that exercise
49
+ `Ratomic::Map`, `Ratomic::Counter`, and `Ratomic::Pool` under Thread and
50
+ Ractor workloads.
51
+ - The [`cdc-parallel` Ratomic benchmark][cdc-parallel-ratomic] demonstrates
52
+ Ractor workers updating shared CDC processing metrics through `Ratomic::Map`
53
+ and `Ratomic::Counter`.
54
+
55
+ ## Usage
40
56
 
41
- These structures are designed for use as class-level constants so they can be shared by numerous Ractors.
57
+ Ratomic provides two safety models:
42
58
 
43
- Ratomic has two different safety models:
59
+ - `Counter`, `Map`, and `Queue` are shared concurrent structures.
60
+ - `Pool` transfers ownership of mutable objects between Ractors.
44
61
 
45
- * `Counter`, `Map`, and `Queue` are shared concurrent structures.
46
- * `Pool` transfers ownership of mutable objects between Ractors.
62
+ That distinction matters. A mutable pooled object is not shared by multiple Ractors
63
+ at the same time. It is moved to the caller on checkout and moved back to the pool
64
+ on checkin.
47
65
 
48
- 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.
66
+ These structures are designed for use as class-level constants so they can be
67
+ shared by many Ractors.
49
68
 
50
69
  ### `Ratomic::Counter`
51
70
 
71
+ `Ratomic::Counter` is a Ractor-shareable atomic counter.
72
+
52
73
  ```ruby
53
- c = Ratomic::Counter.new
54
- c.read # => 0
55
- c.inc
56
- c.inc(5)
57
- c.dec(1)
58
- c.dec
59
- c.read # => 4
60
- c.to_i # => 4
61
- c.zero? # => false
74
+ counter = Ratomic::Counter.new
75
+
76
+ counter.read # => 0
77
+ counter.increment(1)
78
+ counter.increment(5)
79
+ counter.decrement(1)
80
+ counter.decrement(1)
81
+
82
+ counter.read # => 4
83
+ counter.to_i # => 4
84
+ counter.zero? # => false
62
85
  ```
63
86
 
64
- ### `Ratomic::Pool`
87
+ ### `Ratomic::Map`
65
88
 
66
- A Ractor-safe object pool:
89
+ `Ratomic::Map` is a Ractor-safe concurrent Hash backed by Rust's DashMap. It is
90
+ not a full `Hash` replacement; iteration and arbitrary mutable object borrowing
91
+ are intentionally absent.
67
92
 
68
93
  ```ruby
69
- POOL = Ratomic::Pool.new(5, 1.0) { [] }
70
- POOL.with do |obj|
71
- # do something with obj
72
- obj << "work"
73
- end
94
+ OFFSETS = Ratomic::Map.new
95
+
96
+ OFFSETS["mike"] = 123
97
+ OFFSETS["mike"] # => 123
98
+ OFFSETS.key?("mike") # => true
99
+ OFFSETS.fetch("missing", 0) # => 0
100
+
101
+ OFFSETS.fetch_or_store("count") { 0 } # => 0
102
+ OFFSETS.compute("mike") { |value| value + 1 } # => 124
103
+ OFFSETS.upsert("mike", 1) { |value| value + 1 } # => 125
104
+ OFFSETS.fetch_and_modify("mike") { |value| value + 1 }
105
+
106
+ OFFSETS.delete("mike") # => 126
107
+ OFFSETS.length
108
+ OFFSETS.empty?
109
+ OFFSETS.clear
74
110
  ```
75
111
 
76
- `Pool` is an ownership-transfer pool for mutable Ruby objects. It uses Ruby 4's `Ractor::Port` and `move: true` semantics so only one Ractor owns a checked-out object at a time.
112
+ `Map` also includes atomic convenience methods for common bucket patterns:
77
113
 
78
- This design addresses [issue #5](https://github.com/mperham/ratomic/issues/5), where using a pooled object after `with` could lead to memory corruption or a process crash. The fix is Rust-inspired ownership transfer, not Rust's full borrow checker: Ruby enforces the boundary dynamically at runtime through Ractor move semantics, while Rust enforces ownership and borrowing statically at compile time.
114
+ ```ruby
115
+ counts = Ratomic::Map.new
116
+ counts.increment("jobs") # => 1
117
+ counts.decrement("jobs") # => 0
118
+
119
+ groups = Ratomic::Map.new
120
+ groups.append("jobs", "import") # => ["import"]
121
+ groups.add_to_set("workers", "alpha") # => #<Set: {"alpha"}>
122
+ ```
79
123
 
80
- In that model:
124
+ ### `Ratomic::Queue`
125
+
126
+ `Ratomic::Queue` is a Ractor-shareable multi-producer, multi-consumer queue.
81
127
 
82
- * the Rust owner maps to the Ractor that currently checked out the pooled object
83
- * the Rust move maps to `Ractor::Port#send(..., move: true)`
84
- * Rust's "cannot use after move" rule maps to Ruby raising `Ractor::MovedError`
85
- * borrowing is not modeled; `Pool` transfers ownership instead of lending references
128
+ ```ruby
129
+ queue = Ratomic::Queue.new(128)
130
+
131
+ queue.push("hello")
132
+ queue << "world"
133
+
134
+ queue.size # => 2
135
+ queue.empty? # => false
136
+ queue.peek # => "hello"
137
+ queue.pop # => "hello"
138
+ queue.pop # => "world"
139
+ queue.empty? # => true
140
+ ```
141
+
142
+ The `.new(capacity)` method initializes the queue with a fixed-size buffer.
143
+ Capacity must be at least `1` and at most `2**20`. Non-power-of-two capacities
144
+ are supported exactly.
145
+
146
+ Since `Ratomic::Queue` is concurrent, `size`, `empty?`, and `peek` are
147
+ moment-in-time observations. Their results may already be stale by the time your
148
+ code uses them.
149
+
150
+ ### `Ratomic::Pool`
151
+
152
+ `Ratomic::Pool` is a Ractor-safe ownership-transfer pool for mutable Ruby objects.
153
+
154
+ ```ruby
155
+ BUFFERS = Ratomic::Pool.new(5, 1.0) { [] }
156
+
157
+ BUFFERS.with do |buffer|
158
+ buffer.clear
159
+ buffer << "work"
160
+ end
161
+ ```
162
+
163
+ `Pool` uses Ruby 4's `Ractor::Port` and `move: true` semantics so only one
164
+ Ractor owns a checked-out object at a time.
86
165
 
87
166
  When an object is checked out:
88
167
 
89
- * the pool moves the object to the caller
90
- * the caller can mutate the object while it owns it
91
- * the pool cannot hand that object to another Ractor until it is checked in
168
+ - the pool moves the object to the caller
169
+ - the caller can mutate the object while it owns it
170
+ - the pool cannot hand that object to another Ractor until it is checked in
92
171
 
93
172
  When an object is checked in:
94
173
 
95
- * ownership moves back to the pool
96
- * stale references held by the caller become moved objects
97
- * using those stale references raises `Ractor::MovedError`
174
+ - ownership moves back to the pool
175
+ - stale references held by the caller become moved objects
176
+ - using those stale references raises `Ractor::MovedError`
98
177
 
99
- This means incorrect usage fails at the Ruby object-ownership boundary rather than allowing two Ractors to mutate the same object concurrently.
178
+ This means incorrect usage fails at the Ruby object-ownership boundary rather
179
+ than allowing two Ractors to mutate the same object concurrently.
100
180
 
101
181
  ```ruby
102
182
  outside = nil
103
183
 
104
- POOL.with do |obj|
105
- outside = obj
106
- obj << "inside"
184
+ BUFFERS.with do |buffer|
185
+ outside = buffer
186
+ buffer << "inside"
107
187
  end
108
188
 
109
189
  outside << "outside"
110
190
  # raises Ractor::MovedError
111
191
  ```
112
192
 
113
- 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.
114
-
115
- Manual checkout/checkin is also supported:
193
+ Manual checkout and checkin are also supported:
116
194
 
117
195
  ```ruby
118
- obj = POOL.checkout
119
- raise "pool checkout timeout" if obj.nil?
196
+ buffer = BUFFERS.checkout
197
+ raise "pool checkout timeout" if buffer.nil?
120
198
 
121
199
  begin
122
- obj << "manual work"
200
+ buffer << "manual work"
123
201
  ensure
124
- POOL.checkin(obj) if obj
202
+ BUFFERS.checkin(buffer) if buffer
125
203
  end
126
204
  ```
127
205
 
128
- `checkout` returns `nil` if no pooled object becomes available before the configured timeout. `with` raises `Ratomic::Error` in that case.
206
+ `checkout` returns `nil` if no pooled object becomes available before the
207
+ configured timeout. `with` raises `Ratomic::Error` in that case.
129
208
 
130
- ### `Ratomic::Map`
209
+ `Pool` uses ownership transfer, not Rust's full borrow checker:
131
210
 
132
- A Ractor-safe map/hash structure:
211
+ - the Rust owner maps to the Ractor that currently checked out the pooled object
212
+ - the Rust move maps to `Ractor::Port#send(..., move: true)`
213
+ - Rust's "cannot use after move" rule maps to Ruby raising `Ractor::MovedError`
214
+ - borrowing is not modeled; `Pool` transfers ownership instead of lending references
133
215
 
134
- ```ruby
135
- HASH = Ratomic::Map.new
136
- HASH["mike"] = 123
137
- HASH["mike"] # => 123
138
- HASH.fetch_and_modify("mike") { |value| value + 1 }
139
- HASH.length
140
- HASH.empty?
141
- HASH.clear
142
- ```
216
+ This design addresses [issue #5](https://github.com/mperham/ratomic/issues/5),
217
+ where using a pooled object after `with` could lead to memory corruption or a
218
+ process crash.
143
219
 
144
- ### `Ratomic::Queue`
220
+ The lower-level `Ratomic::FixedSizeObjectPool` native class may still exist, but
221
+ `Ratomic::Pool` does not inherit from it. The public `Pool` API is implemented
222
+ in Ruby so it can use Ruby's Ractor ownership primitives directly.
145
223
 
146
- A multi-producer, multi-consumer queue.
224
+ ## Contributing
147
225
 
148
- ```ruby
149
- q = Ratomic::Queue.new(128)
226
+ Please read the [Code of Conduct](./CODE_OF_CONDUCT.md) before contributing.
150
227
 
151
- q.push("hello")
152
- q << "world"
228
+ After changing code, run:
153
229
 
154
- q.size # => 2
155
- q.empty? # => false
156
- q.peek # => "hello"
157
- item = q.pop # => "hello"
158
- item = q.pop # => "world"
159
- q.empty? # => true
230
+ ```bash
231
+ rake
160
232
  ```
161
- The `.new(capacity)` method initializes the queue with a fixed-size buffer. The capacity must be greater than or equal to 1 and less than or equal to 2<sup>20</sup>.
162
-
163
- Values that are not a power of two are rounded up to the nearest greater power of two, which enables efficient indexing and wrap-around calculations in the underlying buffer.
164
233
 
165
- Since `Ratomic::Queue` is a concurrent queue, the `size`, `empty?`, and `peek` methods provide only a best-effort guess — the values they return might be stale or incorrect.
234
+ This compiles the Rust code and runs the test suite. The test suite writes a
235
+ SimpleCov report to `coverage/index.html` for the Ruby wrapper paths.
166
236
 
167
237
  ## Thanks
168
238
 
169
- [Ilya Bylich](https://github.com/iliabylich) wrote and documented his original research at [Ruby, Ractors, and Lock-free Data Structures/](https://iliabylich.github.io/ruby-ractors-and-lock-free-data-structures/).
170
- Thank you for your impressive work, Ilya!
239
+ [Ilya Bylich](https://github.com/iliabylich) wrote and documented his original
240
+ research at [Ruby, Ractors, and Lock-free Data Structures][ractor-research].
171
241
 
172
- This repo is further research into the usability and limitations of Ractor-friendly structures in Ruby code and gems.
242
+ This repo continues that research into the usability and limitations of
243
+ Ractor-friendly structures in Ruby code and gems.
173
244
 
174
245
  ## License
175
246
 
176
247
  [MIT License](https://opensource.org/licenses/MIT).
248
+
249
+ [cdc-parallel-ratomic]: https://github.com/kanutocd/cdc-parallel/blob/main/benchmark/RATOMIC.md
250
+ [ractor-research]: https://iliabylich.github.io/ruby-ractors-and-lock-free-data-structures/
@@ -1,3 +1,4 @@
1
+ use dashmap::mapref::entry::Entry;
1
2
  use rb_sys::{rb_eql, rb_hash, VALUE};
2
3
 
3
4
  #[derive(Debug)]
@@ -18,11 +19,11 @@ impl std::hash::Hash for RubyHashEql {
18
19
  }
19
20
  }
20
21
 
21
- pub struct ConcurrentHashMap {
22
+ pub struct MapStore {
22
23
  map: dashmap::DashMap<RubyHashEql, VALUE>,
23
24
  }
24
25
 
25
- impl ConcurrentHashMap {
26
+ impl MapStore {
26
27
  pub fn new() -> Self {
27
28
  Self {
28
29
  map: dashmap::DashMap::new(),
@@ -33,10 +34,18 @@ impl ConcurrentHashMap {
33
34
  self.map.get(&RubyHashEql(key)).map(|v| *v)
34
35
  }
35
36
 
37
+ pub fn contains_key(&self, key: VALUE) -> bool {
38
+ self.map.contains_key(&RubyHashEql(key))
39
+ }
40
+
36
41
  pub fn set(&self, key: VALUE, value: VALUE) {
37
42
  self.map.insert(RubyHashEql(key), value);
38
43
  }
39
44
 
45
+ pub fn delete(&self, key: VALUE) -> Option<VALUE> {
46
+ self.map.remove(&RubyHashEql(key)).map(|(_, value)| value)
47
+ }
48
+
40
49
  pub fn clear(&self) {
41
50
  self.map.clear()
42
51
  }
@@ -52,6 +61,73 @@ impl ConcurrentHashMap {
52
61
  self.map.alter(&RubyHashEql(key), |_, value| f(value));
53
62
  }
54
63
 
64
+ pub fn compute<F, E>(&self, key: VALUE, missing: VALUE, f: F) -> Result<VALUE, E>
65
+ where
66
+ F: FnOnce(VALUE) -> Result<VALUE, E>,
67
+ {
68
+ match self.map.entry(RubyHashEql(key)) {
69
+ Entry::Occupied(mut entry) => {
70
+ let new_value = f(*entry.get())?;
71
+ entry.insert(new_value);
72
+ Ok(new_value)
73
+ }
74
+ Entry::Vacant(entry) => {
75
+ let new_value = f(missing)?;
76
+ entry.insert(new_value);
77
+ Ok(new_value)
78
+ }
79
+ }
80
+ }
81
+
82
+ pub fn update<F, E>(&self, key: VALUE, f: F) -> Result<VALUE, E>
83
+ where
84
+ F: FnOnce(Option<VALUE>) -> Result<VALUE, E>,
85
+ {
86
+ match self.map.entry(RubyHashEql(key)) {
87
+ Entry::Occupied(mut entry) => {
88
+ let new_value = f(Some(*entry.get()))?;
89
+ entry.insert(new_value);
90
+ Ok(new_value)
91
+ }
92
+ Entry::Vacant(entry) => {
93
+ let new_value = f(None)?;
94
+ entry.insert(new_value);
95
+ Ok(new_value)
96
+ }
97
+ }
98
+ }
99
+
100
+ pub fn fetch_or_store<F, E>(&self, key: VALUE, f: F) -> Result<VALUE, E>
101
+ where
102
+ F: FnOnce() -> Result<VALUE, E>,
103
+ {
104
+ match self.map.entry(RubyHashEql(key)) {
105
+ Entry::Occupied(entry) => Ok(*entry.get()),
106
+ Entry::Vacant(entry) => {
107
+ let value = f()?;
108
+ entry.insert(value);
109
+ Ok(value)
110
+ }
111
+ }
112
+ }
113
+
114
+ pub fn upsert<F, E>(&self, key: VALUE, initial: VALUE, f: F) -> Result<VALUE, E>
115
+ where
116
+ F: FnOnce(VALUE) -> Result<VALUE, E>,
117
+ {
118
+ match self.map.entry(RubyHashEql(key)) {
119
+ Entry::Occupied(mut entry) => {
120
+ let new_value = f(*entry.get())?;
121
+ entry.insert(new_value);
122
+ Ok(new_value)
123
+ }
124
+ Entry::Vacant(entry) => {
125
+ entry.insert(initial);
126
+ Ok(initial)
127
+ }
128
+ }
129
+ }
130
+
55
131
  pub fn mark<F>(&self, f: F)
56
132
  where
57
133
  F: Fn(VALUE),
@@ -63,7 +139,7 @@ impl ConcurrentHashMap {
63
139
  }
64
140
  }
65
141
 
66
- impl Default for ConcurrentHashMap {
142
+ impl Default for MapStore {
67
143
  fn default() -> Self {
68
144
  Self::new()
69
145
  }