ratomic 0.2.1 → 0.3.1

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