ratomic 0.2.1 → 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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +167 -98
- data/ext/ratomic/src/hashmap.rs +79 -3
- data/ext/ratomic/src/lib.rs +140 -18
- data/lib/ratomic/counter.rb +32 -26
- data/lib/ratomic/map.rb +274 -10
- data/lib/ratomic/pool.rb +2 -0
- data/lib/ratomic/queue.rb +58 -4
- data/lib/ratomic/version.rb +6 -1
- data/lib/ratomic.rb +8 -10
- data/ratomic.gemspec +6 -5
- metadata +11 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5196316489486d3034c046db087ae5878dc7fcf2f2e3d4086d91c128f8a30dd5
|
|
4
|
+
data.tar.gz: 6bf7a6aed05756ff9a0f49f274d69ed91b1f722930c94a140cc5450746fec3f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f0276e6c7e78c7bdcbabb3839d59eb70e58289f3662fe1df244fce9abc9b3a1eeb3454a4116081cd0f0426e470ed64a3d0108f8a57650455e17cc96f111f51e7
|
|
7
|
+
data.tar.gz: 8eb3b4c4f248569568ea7394a89156961aeb8108b0448c5ef3012045486537cd3c862fff684ac39192d2c31842159e5ad798b9005ce48909281f19d1c9043827
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
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
|
+
|
|
3
14
|
## [0.2.1] - 2026-06-04
|
|
4
15
|
|
|
5
16
|
- Fix `Ratomic::Queue` slot indexing for non-power-of-two capacities.
|
data/README.md
CHANGED
|
@@ -2,180 +2,249 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/ratomic)
|
|
4
4
|
[](https://github.com/mperham/ratomic/actions)
|
|
5
|
-
[](https://codecov.io/gh/mperham/ratomic)
|
|
6
5
|
[](https://www.ruby-lang.org/en/)
|
|
7
6
|
[](https://opensource.org/licenses/MIT)
|
|
8
7
|
|
|
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.
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
This allows Ruby code to scale beyond the infamous GVL.
|
|
12
|
+
## Project Direction
|
|
12
13
|
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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.
|
|
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.
|
|
19
19
|
|
|
20
|
-
##
|
|
20
|
+
## Requirements
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
- Ruby 4.0 or newer
|
|
23
|
+
- Bundler
|
|
24
|
+
- Rust toolchain when building the native extension from source
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Add Ratomic to your application's Gemfile:
|
|
25
29
|
|
|
26
30
|
```bash
|
|
27
|
-
|
|
31
|
+
bundle add ratomic
|
|
28
32
|
```
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
The test suite writes a SimpleCov report to `coverage/index.html` so you can see which Ruby wrapper paths are covered.
|
|
34
|
+
Then require it from Ruby:
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
```ruby
|
|
37
|
+
require "ratomic"
|
|
38
|
+
```
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
## Documentation
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
bundle add ratomic
|
|
39
|
-
```
|
|
42
|
+
API documentation is published to GitHub Pages:
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
- [mperham.github.io/ratomic](https://mperham.github.io/ratomic/)
|
|
45
|
+
|
|
46
|
+
## Examples And Benchmarks
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
|
|
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`.
|
|
45
54
|
|
|
46
|
-
|
|
55
|
+
## Usage
|
|
47
56
|
|
|
48
|
-
Ratomic
|
|
57
|
+
Ratomic provides two safety models:
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
- `Counter`, `Map`, and `Queue` are shared concurrent structures.
|
|
60
|
+
- `Pool` transfers ownership of mutable objects between Ractors.
|
|
52
61
|
|
|
53
|
-
That distinction matters. A mutable pooled object is not shared by multiple 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.
|
|
65
|
+
|
|
66
|
+
These structures are designed for use as class-level constants so they can be
|
|
67
|
+
shared by many Ractors.
|
|
54
68
|
|
|
55
69
|
### `Ratomic::Counter`
|
|
56
70
|
|
|
71
|
+
`Ratomic::Counter` is a Ractor-shareable atomic counter.
|
|
72
|
+
|
|
57
73
|
```ruby
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
67
85
|
```
|
|
68
86
|
|
|
69
|
-
### `Ratomic::
|
|
87
|
+
### `Ratomic::Map`
|
|
70
88
|
|
|
71
|
-
|
|
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.
|
|
72
92
|
|
|
73
93
|
```ruby
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`Map` also includes atomic convenience methods for common bucket patterns:
|
|
113
|
+
|
|
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"}>
|
|
79
122
|
```
|
|
80
123
|
|
|
81
|
-
|
|
124
|
+
### `Ratomic::Queue`
|
|
125
|
+
|
|
126
|
+
`Ratomic::Queue` is a Ractor-shareable multi-producer, multi-consumer queue.
|
|
82
127
|
|
|
83
|
-
|
|
128
|
+
```ruby
|
|
129
|
+
queue = Ratomic::Queue.new(128)
|
|
84
130
|
|
|
85
|
-
|
|
131
|
+
queue.push("hello")
|
|
132
|
+
queue << "world"
|
|
86
133
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
91
165
|
|
|
92
166
|
When an object is checked out:
|
|
93
167
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
97
171
|
|
|
98
172
|
When an object is checked in:
|
|
99
173
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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`
|
|
103
177
|
|
|
104
|
-
This means incorrect usage fails at the Ruby object-ownership boundary rather
|
|
178
|
+
This means incorrect usage fails at the Ruby object-ownership boundary rather
|
|
179
|
+
than allowing two Ractors to mutate the same object concurrently.
|
|
105
180
|
|
|
106
181
|
```ruby
|
|
107
182
|
outside = nil
|
|
108
183
|
|
|
109
|
-
|
|
110
|
-
outside =
|
|
111
|
-
|
|
184
|
+
BUFFERS.with do |buffer|
|
|
185
|
+
outside = buffer
|
|
186
|
+
buffer << "inside"
|
|
112
187
|
end
|
|
113
188
|
|
|
114
189
|
outside << "outside"
|
|
115
190
|
# raises Ractor::MovedError
|
|
116
191
|
```
|
|
117
192
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
Manual checkout/checkin is also supported:
|
|
193
|
+
Manual checkout and checkin are also supported:
|
|
121
194
|
|
|
122
195
|
```ruby
|
|
123
|
-
|
|
124
|
-
raise "pool checkout timeout" if
|
|
196
|
+
buffer = BUFFERS.checkout
|
|
197
|
+
raise "pool checkout timeout" if buffer.nil?
|
|
125
198
|
|
|
126
199
|
begin
|
|
127
|
-
|
|
200
|
+
buffer << "manual work"
|
|
128
201
|
ensure
|
|
129
|
-
|
|
202
|
+
BUFFERS.checkin(buffer) if buffer
|
|
130
203
|
end
|
|
131
204
|
```
|
|
132
205
|
|
|
133
|
-
`checkout` returns `nil` if no pooled object becomes available before the
|
|
206
|
+
`checkout` returns `nil` if no pooled object becomes available before the
|
|
207
|
+
configured timeout. `with` raises `Ratomic::Error` in that case.
|
|
134
208
|
|
|
135
|
-
|
|
209
|
+
`Pool` uses ownership transfer, not Rust's full borrow checker:
|
|
136
210
|
|
|
137
|
-
|
|
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
|
|
138
215
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
HASH["mike"] # => 123
|
|
143
|
-
HASH.fetch_and_modify("mike") { |value| value + 1 }
|
|
144
|
-
HASH.length
|
|
145
|
-
HASH.empty?
|
|
146
|
-
HASH.clear
|
|
147
|
-
```
|
|
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.
|
|
148
219
|
|
|
149
|
-
|
|
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.
|
|
150
223
|
|
|
151
|
-
|
|
224
|
+
## Contributing
|
|
152
225
|
|
|
153
|
-
|
|
154
|
-
q = Ratomic::Queue.new(128)
|
|
226
|
+
Please read the [Code of Conduct](./CODE_OF_CONDUCT.md) before contributing.
|
|
155
227
|
|
|
156
|
-
|
|
157
|
-
q << "world"
|
|
228
|
+
After changing code, run:
|
|
158
229
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
q.peek # => "hello"
|
|
162
|
-
item = q.pop # => "hello"
|
|
163
|
-
item = q.pop # => "world"
|
|
164
|
-
q.empty? # => true
|
|
230
|
+
```bash
|
|
231
|
+
rake
|
|
165
232
|
```
|
|
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
233
|
|
|
170
|
-
|
|
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.
|
|
171
236
|
|
|
172
237
|
## Thanks
|
|
173
238
|
|
|
174
|
-
[Ilya Bylich](https://github.com/iliabylich) wrote and documented his original
|
|
175
|
-
|
|
239
|
+
[Ilya Bylich](https://github.com/iliabylich) wrote and documented his original
|
|
240
|
+
research at [Ruby, Ractors, and Lock-free Data Structures][ractor-research].
|
|
176
241
|
|
|
177
|
-
This repo
|
|
242
|
+
This repo continues that research into the usability and limitations of
|
|
243
|
+
Ractor-friendly structures in Ruby code and gems.
|
|
178
244
|
|
|
179
245
|
## License
|
|
180
246
|
|
|
181
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/
|
data/ext/ratomic/src/hashmap.rs
CHANGED
|
@@ -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
|
|
22
|
+
pub struct MapStore {
|
|
22
23
|
map: dashmap::DashMap<RubyHashEql, VALUE>,
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
impl
|
|
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
|
|
142
|
+
impl Default for MapStore {
|
|
67
143
|
fn default() -> Self {
|
|
68
144
|
Self::new()
|
|
69
145
|
}
|
data/ext/ratomic/src/lib.rs
CHANGED
|
@@ -7,17 +7,17 @@ mod sem;
|
|
|
7
7
|
|
|
8
8
|
use counter::AtomicCounter;
|
|
9
9
|
use fixed_size_object_pool::FixedSizeObjectPool;
|
|
10
|
-
use hashmap::
|
|
10
|
+
use hashmap::MapStore;
|
|
11
11
|
use magnus::{
|
|
12
|
-
data_type_builder, method,
|
|
12
|
+
data_type_builder, method,
|
|
13
13
|
prelude::*,
|
|
14
14
|
typed_data::{DataType, DataTypeFunctions},
|
|
15
15
|
value::Lazy,
|
|
16
|
-
Error, RClass, Ruby, TryConvert, TypedData, Value,
|
|
16
|
+
Error, IntoValue, RClass, Ruby, TryConvert, TypedData, Value,
|
|
17
17
|
};
|
|
18
18
|
use mpmc_queue::MpmcQueue;
|
|
19
|
-
use rb_sys::{rb_ext_ractor_safe, rb_thread_call_without_gvl, ruby_special_consts, VALUE};
|
|
20
19
|
use parking_lot::Mutex;
|
|
20
|
+
use rb_sys::{rb_ext_ractor_safe, rb_thread_call_without_gvl, ruby_special_consts, VALUE};
|
|
21
21
|
use std::{ffi::c_void, mem::transmute};
|
|
22
22
|
|
|
23
23
|
fn value_to_raw(value: Value) -> VALUE {
|
|
@@ -64,7 +64,8 @@ impl DataTypeFunctions for Counter {}
|
|
|
64
64
|
unsafe impl TypedData for Counter {
|
|
65
65
|
fn class(ruby: &Ruby) -> RClass {
|
|
66
66
|
static CLASS: Lazy<RClass> = Lazy::new(|ruby| {
|
|
67
|
-
let class = ruby
|
|
67
|
+
let class = ruby
|
|
68
|
+
.define_module("Ratomic")
|
|
68
69
|
.unwrap()
|
|
69
70
|
.define_class("Counter", ruby.class_object())
|
|
70
71
|
.unwrap();
|
|
@@ -75,17 +76,18 @@ unsafe impl TypedData for Counter {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
fn data_type() -> &'static DataType {
|
|
78
|
-
static DATA_TYPE: DataType =
|
|
79
|
-
|
|
79
|
+
static DATA_TYPE: DataType = data_type_builder!(Counter, "ratomic/counter")
|
|
80
|
+
.frozen_shareable()
|
|
81
|
+
.build();
|
|
80
82
|
&DATA_TYPE
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
struct HashMap(
|
|
86
|
+
struct HashMap(MapStore);
|
|
85
87
|
|
|
86
88
|
impl HashMap {
|
|
87
89
|
fn new(ruby: &Ruby, class: RClass) -> Result<Value, Error> {
|
|
88
|
-
let value = ruby.wrap_as(Self(
|
|
90
|
+
let value = ruby.wrap_as(Self(MapStore::new()), class).as_value();
|
|
89
91
|
make_shareable(ruby, value)
|
|
90
92
|
}
|
|
91
93
|
|
|
@@ -94,10 +96,19 @@ impl HashMap {
|
|
|
94
96
|
unsafe { value_from_raw(raw) }.into_value_with(ruby)
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
fn contains_key(&self, key: Value) -> bool {
|
|
100
|
+
self.0.contains_key(value_to_raw(key))
|
|
101
|
+
}
|
|
102
|
+
|
|
97
103
|
fn set(&self, key: Value, value: Value) {
|
|
98
104
|
self.0.set(value_to_raw(key), value_to_raw(value));
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
fn delete(ruby: &Ruby, rb_self: &Self, key: Value) -> Value {
|
|
108
|
+
let raw = rb_self.0.delete(value_to_raw(key)).unwrap_or_else(qnil_raw);
|
|
109
|
+
unsafe { value_from_raw(raw) }.into_value_with(ruby)
|
|
110
|
+
}
|
|
111
|
+
|
|
101
112
|
fn clear(&self) {
|
|
102
113
|
self.0.clear();
|
|
103
114
|
}
|
|
@@ -132,20 +143,113 @@ impl HashMap {
|
|
|
132
143
|
Ok(())
|
|
133
144
|
}
|
|
134
145
|
}
|
|
146
|
+
|
|
147
|
+
fn compute(ruby: &Ruby, rb_self: &Self, key: Value) -> Result<Value, Error> {
|
|
148
|
+
if !ruby.block_given() {
|
|
149
|
+
return Err(Error::new(
|
|
150
|
+
ruby.exception_local_jump_error(),
|
|
151
|
+
"no block given",
|
|
152
|
+
));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let proc = ruby.block_proc()?;
|
|
156
|
+
let raw = rb_self.0.compute(value_to_raw(key), qnil_raw(), |value| {
|
|
157
|
+
proc.call::<_, Value>((unsafe { value_from_raw(value) },))
|
|
158
|
+
.map(value_to_raw)
|
|
159
|
+
})?;
|
|
160
|
+
|
|
161
|
+
Ok(unsafe { value_from_raw(raw) }.into_value_with(ruby))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fn fetch_or_store(ruby: &Ruby, rb_self: &Self, key: Value) -> Result<Value, Error> {
|
|
165
|
+
if !ruby.block_given() {
|
|
166
|
+
return Err(Error::new(
|
|
167
|
+
ruby.exception_local_jump_error(),
|
|
168
|
+
"no block given",
|
|
169
|
+
));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let proc = ruby.block_proc()?;
|
|
173
|
+
let raw = rb_self.0.fetch_or_store(value_to_raw(key), || {
|
|
174
|
+
proc.call::<_, Value>(()).map(value_to_raw)
|
|
175
|
+
})?;
|
|
176
|
+
|
|
177
|
+
Ok(unsafe { value_from_raw(raw) }.into_value_with(ruby))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fn upsert(ruby: &Ruby, rb_self: &Self, key: Value, initial: Value) -> Result<Value, Error> {
|
|
181
|
+
if !ruby.block_given() {
|
|
182
|
+
return Err(Error::new(
|
|
183
|
+
ruby.exception_local_jump_error(),
|
|
184
|
+
"no block given",
|
|
185
|
+
));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let proc = ruby.block_proc()?;
|
|
189
|
+
let raw = rb_self
|
|
190
|
+
.0
|
|
191
|
+
.upsert(value_to_raw(key), value_to_raw(initial), |value| {
|
|
192
|
+
proc.call::<_, Value>((unsafe { value_from_raw(value) },))
|
|
193
|
+
.map(value_to_raw)
|
|
194
|
+
})?;
|
|
195
|
+
|
|
196
|
+
Ok(unsafe { value_from_raw(raw) }.into_value_with(ruby))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fn increment_numeric(
|
|
200
|
+
ruby: &Ruby,
|
|
201
|
+
rb_self: &Self,
|
|
202
|
+
key: Value,
|
|
203
|
+
by: Value,
|
|
204
|
+
) -> Result<Value, Error> {
|
|
205
|
+
let key_inspect = key.funcall::<_, _, String>("inspect", ())?;
|
|
206
|
+
let numeric_class: RClass = ruby.class_object().const_get("Numeric")?;
|
|
207
|
+
let raw = rb_self.0.update(value_to_raw(key), |current| {
|
|
208
|
+
let next = match current {
|
|
209
|
+
Some(value) if value == qnil_raw() => {
|
|
210
|
+
return Err(Error::new(
|
|
211
|
+
ruby.exception_type_error(),
|
|
212
|
+
format!("existing value for {key_inspect} must be numeric: nil"),
|
|
213
|
+
));
|
|
214
|
+
}
|
|
215
|
+
Some(value) => {
|
|
216
|
+
let old_value = unsafe { value_from_raw(value) };
|
|
217
|
+
if !old_value.funcall::<_, _, bool>("is_a?", (numeric_class,))? {
|
|
218
|
+
let old_value_inspect = old_value.funcall::<_, _, String>("inspect", ())?;
|
|
219
|
+
return Err(Error::new(
|
|
220
|
+
ruby.exception_type_error(),
|
|
221
|
+
format!(
|
|
222
|
+
"existing value for {key_inspect} must be numeric: {old_value_inspect}"
|
|
223
|
+
),
|
|
224
|
+
));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
old_value.funcall::<_, _, Value>("+", (by,))?
|
|
228
|
+
}
|
|
229
|
+
None => by,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
Ok(value_to_raw(next))
|
|
233
|
+
})?;
|
|
234
|
+
|
|
235
|
+
Ok(unsafe { value_from_raw(raw) }.into_value_with(ruby))
|
|
236
|
+
}
|
|
135
237
|
}
|
|
136
238
|
|
|
137
239
|
impl DataTypeFunctions for HashMap {
|
|
138
240
|
fn mark(&self, marker: &magnus::gc::Marker) {
|
|
139
|
-
self.0
|
|
241
|
+
self.0
|
|
242
|
+
.mark(|value| marker.mark(unsafe { value_from_raw(value) }));
|
|
140
243
|
}
|
|
141
244
|
}
|
|
142
245
|
|
|
143
246
|
unsafe impl TypedData for HashMap {
|
|
144
247
|
fn class(ruby: &Ruby) -> RClass {
|
|
145
248
|
static CLASS: Lazy<RClass> = Lazy::new(|ruby| {
|
|
146
|
-
let class = ruby
|
|
249
|
+
let class = ruby
|
|
250
|
+
.define_module("Ratomic")
|
|
147
251
|
.unwrap()
|
|
148
|
-
.define_class("
|
|
252
|
+
.define_class("Map", ruby.class_object())
|
|
149
253
|
.unwrap();
|
|
150
254
|
class.undef_default_alloc_func();
|
|
151
255
|
class
|
|
@@ -245,14 +349,16 @@ impl Queue {
|
|
|
245
349
|
|
|
246
350
|
impl DataTypeFunctions for Queue {
|
|
247
351
|
fn mark(&self, marker: &magnus::gc::Marker) {
|
|
248
|
-
self.0
|
|
352
|
+
self.0
|
|
353
|
+
.mark(|value| marker.mark(unsafe { value_from_raw(value) }));
|
|
249
354
|
}
|
|
250
355
|
}
|
|
251
356
|
|
|
252
357
|
unsafe impl TypedData for Queue {
|
|
253
358
|
fn class(ruby: &Ruby) -> RClass {
|
|
254
359
|
static CLASS: Lazy<RClass> = Lazy::new(|ruby| {
|
|
255
|
-
let class = ruby
|
|
360
|
+
let class = ruby
|
|
361
|
+
.define_module("Ratomic")
|
|
256
362
|
.unwrap()
|
|
257
363
|
.define_class("Queue", ruby.class_object())
|
|
258
364
|
.unwrap();
|
|
@@ -278,7 +384,10 @@ impl Pool {
|
|
|
278
384
|
if args.len() > 2 {
|
|
279
385
|
return Err(Error::new(
|
|
280
386
|
ruby.exception_arg_error(),
|
|
281
|
-
format!(
|
|
387
|
+
format!(
|
|
388
|
+
"wrong number of arguments (given {}, expected 0..2)",
|
|
389
|
+
args.len()
|
|
390
|
+
),
|
|
282
391
|
));
|
|
283
392
|
}
|
|
284
393
|
let size = args
|
|
@@ -296,7 +405,10 @@ impl Pool {
|
|
|
296
405
|
.unwrap_or(1000);
|
|
297
406
|
|
|
298
407
|
if size == 0 {
|
|
299
|
-
return Err(Error::new(
|
|
408
|
+
return Err(Error::new(
|
|
409
|
+
ruby.exception_arg_error(),
|
|
410
|
+
"pool size must be positive",
|
|
411
|
+
));
|
|
300
412
|
}
|
|
301
413
|
if !ruby.block_given() {
|
|
302
414
|
return Err(Error::new(
|
|
@@ -351,7 +463,8 @@ impl DataTypeFunctions for Pool {
|
|
|
351
463
|
unsafe impl TypedData for Pool {
|
|
352
464
|
fn class(ruby: &Ruby) -> RClass {
|
|
353
465
|
static CLASS: Lazy<RClass> = Lazy::new(|ruby| {
|
|
354
|
-
let class = ruby
|
|
466
|
+
let class = ruby
|
|
467
|
+
.define_module("Ratomic")
|
|
355
468
|
.unwrap()
|
|
356
469
|
.define_class("FixedSizeObjectPool", ruby.class_object())
|
|
357
470
|
.unwrap();
|
|
@@ -383,14 +496,23 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
383
496
|
counter.define_method("decrement", method!(Counter::decrement, 1))?;
|
|
384
497
|
counter.define_method("read", method!(Counter::read, 0))?;
|
|
385
498
|
|
|
386
|
-
let hashmap = root.define_class("
|
|
499
|
+
let hashmap = root.define_class("Map", ruby.class_object())?;
|
|
387
500
|
hashmap.undef_default_alloc_func();
|
|
388
501
|
hashmap.define_singleton_method("new", method!(HashMap::new, 0))?;
|
|
389
502
|
hashmap.define_method("get", method!(HashMap::get, 1))?;
|
|
503
|
+
hashmap.define_method("key?", method!(HashMap::contains_key, 1))?;
|
|
390
504
|
hashmap.define_method("set", method!(HashMap::set, 2))?;
|
|
505
|
+
hashmap.define_method("delete", method!(HashMap::delete, 1))?;
|
|
391
506
|
hashmap.define_method("clear", method!(HashMap::clear, 0))?;
|
|
392
507
|
hashmap.define_method("size", method!(HashMap::size, 0))?;
|
|
393
508
|
hashmap.define_method("fetch_and_modify", method!(HashMap::fetch_and_modify, 1))?;
|
|
509
|
+
hashmap.define_method("compute", method!(HashMap::compute, 1))?;
|
|
510
|
+
hashmap.define_method("fetch_or_store", method!(HashMap::fetch_or_store, 1))?;
|
|
511
|
+
hashmap.define_method("upsert", method!(HashMap::upsert, 2))?;
|
|
512
|
+
hashmap.define_private_method(
|
|
513
|
+
"__increment_numeric",
|
|
514
|
+
method!(HashMap::increment_numeric, 2),
|
|
515
|
+
)?;
|
|
394
516
|
|
|
395
517
|
let queue = root.define_class("Queue", ruby.class_object())?;
|
|
396
518
|
queue.undef_default_alloc_func();
|
data/lib/ratomic/counter.rb
CHANGED
|
@@ -1,8 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ratomic
|
|
4
|
-
#
|
|
5
|
-
|
|
4
|
+
# A Ractor-shareable atomic counter.
|
|
5
|
+
#
|
|
6
|
+
# Counter stores an unsigned integer in native Rust atomics and can be shared
|
|
7
|
+
# safely across Ractors.
|
|
8
|
+
#
|
|
9
|
+
# @example Count work across Ractors
|
|
10
|
+
# counter = Ratomic::Counter.new
|
|
11
|
+
# counter.increment(1)
|
|
12
|
+
# counter.read # => 1
|
|
13
|
+
#
|
|
14
|
+
# @!method self.new
|
|
15
|
+
# Create a counter initialized to zero.
|
|
16
|
+
#
|
|
17
|
+
# @return [Ratomic::Counter]
|
|
18
|
+
#
|
|
19
|
+
# @!method read
|
|
20
|
+
# Read the current counter value.
|
|
21
|
+
#
|
|
22
|
+
# @return [Integer]
|
|
23
|
+
#
|
|
24
|
+
# @!method increment(amt)
|
|
25
|
+
# Increment the counter by +amt+.
|
|
26
|
+
#
|
|
27
|
+
# @param amt [Integer]
|
|
28
|
+
# @return [void]
|
|
29
|
+
#
|
|
30
|
+
# @!method decrement(amt)
|
|
31
|
+
# Decrement the counter by +amt+.
|
|
32
|
+
#
|
|
33
|
+
# @param amt [Integer]
|
|
34
|
+
# @return [void]
|
|
35
|
+
class Counter
|
|
6
36
|
# Read the current counter value.
|
|
7
37
|
#
|
|
8
38
|
# @return [Integer]
|
|
@@ -23,29 +53,5 @@ module Ratomic
|
|
|
23
53
|
def zero?
|
|
24
54
|
read.zero?
|
|
25
55
|
end
|
|
26
|
-
|
|
27
|
-
# Increment the counter.
|
|
28
|
-
#
|
|
29
|
-
# @param amt [Integer] amount to add
|
|
30
|
-
# @raise [ArgumentError] if +amt+ is negative
|
|
31
|
-
# @return [void]
|
|
32
|
-
def inc(amt = 1)
|
|
33
|
-
raise ArgumentError, "amount must be positive: #{amt}" if amt.negative?
|
|
34
|
-
|
|
35
|
-
increment(amt)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Decrement the counter.
|
|
39
|
-
#
|
|
40
|
-
# @param amt [Integer] amount to subtract
|
|
41
|
-
# @raise [ArgumentError] if +amt+ is negative
|
|
42
|
-
# @return [void]
|
|
43
|
-
def dec(amt = 1)
|
|
44
|
-
raise ArgumentError, "amount must be positive: #{amt}" if amt.negative?
|
|
45
|
-
|
|
46
|
-
decrement(amt)
|
|
47
|
-
end
|
|
48
56
|
end
|
|
49
|
-
|
|
50
|
-
Counter.prepend(CounterMethods)
|
|
51
57
|
end
|
data/lib/ratomic/map.rb
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ratomic
|
|
4
|
-
# A Ractor-shareable concurrent
|
|
4
|
+
# A Ractor-shareable concurrent Hash backed by Rust's DashMap.
|
|
5
5
|
#
|
|
6
|
-
# Map
|
|
7
|
-
# for runtime state with shareable keys and values
|
|
8
|
-
# from multiple Ractors, such as counters or
|
|
6
|
+
# Map gives Ruby code a small, Ruby-shaped API over DashMap's concurrent
|
|
7
|
+
# storage. It is suitable for runtime state with shareable keys and values
|
|
8
|
+
# that are safe to access from multiple Ractors, such as integer counters or
|
|
9
|
+
# immutable offsets.
|
|
9
10
|
#
|
|
10
11
|
# This is not a full Hash replacement. Iteration and arbitrary mutable object
|
|
11
12
|
# borrowing are intentionally absent.
|
|
@@ -14,10 +15,171 @@ module Ratomic
|
|
|
14
15
|
# OFFSETS = Ratomic::Map.new
|
|
15
16
|
# OFFSETS[:source_a] = 42
|
|
16
17
|
# OFFSETS[:source_a] # => 42
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
#
|
|
20
|
-
|
|
18
|
+
#
|
|
19
|
+
# @!method get(key)
|
|
20
|
+
# Read a value by +key+.
|
|
21
|
+
#
|
|
22
|
+
# Missing keys return nil, so use #key? or #fetch when stored nil values
|
|
23
|
+
# need to be distinguished from missing entries.
|
|
24
|
+
#
|
|
25
|
+
# @param key [Object]
|
|
26
|
+
# @return [Object, nil]
|
|
27
|
+
#
|
|
28
|
+
# @!method set(key, value)
|
|
29
|
+
# Set a value for +key+.
|
|
30
|
+
#
|
|
31
|
+
# @param key [Object]
|
|
32
|
+
# @param value [Object]
|
|
33
|
+
# @return [void]
|
|
34
|
+
#
|
|
35
|
+
# @!method key?(key)
|
|
36
|
+
# Check whether +key+ currently exists in the map.
|
|
37
|
+
#
|
|
38
|
+
# Unlike #get and #[], this distinguishes missing keys from stored nil
|
|
39
|
+
# values.
|
|
40
|
+
#
|
|
41
|
+
# @param key [Object]
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
#
|
|
44
|
+
# @!method delete(key)
|
|
45
|
+
# Remove +key+ and return its previous value.
|
|
46
|
+
#
|
|
47
|
+
# Missing keys return nil. Stored nil values also return nil; use #key?
|
|
48
|
+
# before deleting if that distinction matters.
|
|
49
|
+
#
|
|
50
|
+
# @param key [Object]
|
|
51
|
+
# @return [Object, nil]
|
|
52
|
+
#
|
|
53
|
+
# @!method clear
|
|
54
|
+
# Remove all entries from the map.
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
#
|
|
58
|
+
# @!method size
|
|
59
|
+
# Return the current number of entries.
|
|
60
|
+
#
|
|
61
|
+
# Since this is a concurrent map, the value is a moment-in-time observation.
|
|
62
|
+
#
|
|
63
|
+
# @return [Integer]
|
|
64
|
+
#
|
|
65
|
+
# @!method fetch_and_modify(key)
|
|
66
|
+
# Replace the existing value for +key+ with the block return value.
|
|
67
|
+
#
|
|
68
|
+
# TODO: Revisit this name once the Map API settles. Prefer public method
|
|
69
|
+
# names that stay as close as possible to Ruby Hash semantics.
|
|
70
|
+
#
|
|
71
|
+
# The key must already exist. The operation is atomic for the key. The block
|
|
72
|
+
# runs while the map entry is locked, so avoid using this method for
|
|
73
|
+
# Ractor-hot loops or calling back into the same map from inside the block.
|
|
74
|
+
# If the block raises, the previous value is preserved.
|
|
75
|
+
#
|
|
76
|
+
# @param key [Object]
|
|
77
|
+
# @yieldparam value [Object] current value
|
|
78
|
+
# @return [void]
|
|
79
|
+
# @raise [LocalJumpError] if no block is given
|
|
80
|
+
# @raise [Exception] any exception raised by the block
|
|
81
|
+
#
|
|
82
|
+
# @!method compute(key)
|
|
83
|
+
# Atomically compute and store a value for +key+.
|
|
84
|
+
#
|
|
85
|
+
# If +key+ exists, yields the current value. If +key+ is missing, yields
|
|
86
|
+
# nil. The block return value is stored and returned.
|
|
87
|
+
#
|
|
88
|
+
# The operation is atomic for the key. The block runs while the map entry is
|
|
89
|
+
# locked, so avoid using this method for Ractor-hot loops or calling back
|
|
90
|
+
# into the same map from inside the block. Prefer native update helpers such
|
|
91
|
+
# as #increment when they fit the workflow.
|
|
92
|
+
#
|
|
93
|
+
# If the block raises, the previous value is preserved. If the key was
|
|
94
|
+
# missing, no entry is inserted.
|
|
95
|
+
#
|
|
96
|
+
# @param key [Object]
|
|
97
|
+
# @yieldparam value [Object, nil] current value, or nil if missing
|
|
98
|
+
# @return [Object] the newly stored value
|
|
99
|
+
# @raise [LocalJumpError] if no block is given
|
|
100
|
+
# @raise [Exception] any exception raised by the block
|
|
101
|
+
#
|
|
102
|
+
# @!method fetch_or_store(key)
|
|
103
|
+
# Return the existing value for +key+, or atomically store the block result.
|
|
104
|
+
#
|
|
105
|
+
# If +key+ exists, returns the current value and does not yield. If +key+
|
|
106
|
+
# is missing, yields once, stores the block return value, and returns it.
|
|
107
|
+
#
|
|
108
|
+
# The operation is atomic for the key. Under contention, only one stored
|
|
109
|
+
# value wins for a missing key. The block runs while the map entry is locked,
|
|
110
|
+
# so avoid using this method for Ractor-hot loops or calling back into the
|
|
111
|
+
# same map from inside the block.
|
|
112
|
+
#
|
|
113
|
+
# If the block raises, no entry is inserted.
|
|
114
|
+
#
|
|
115
|
+
# @param key [Object]
|
|
116
|
+
# @return [Object] the existing or newly stored value
|
|
117
|
+
# @raise [LocalJumpError] if no block is given
|
|
118
|
+
# @raise [Exception] any exception raised by the block
|
|
119
|
+
#
|
|
120
|
+
# @!method upsert(key, initial)
|
|
121
|
+
# Atomically insert +initial+ for a missing key, or update an existing value.
|
|
122
|
+
#
|
|
123
|
+
# If +key+ is missing, stores and returns +initial+ without yielding. If
|
|
124
|
+
# +key+ exists, yields the current value, stores the block return value, and
|
|
125
|
+
# returns it.
|
|
126
|
+
#
|
|
127
|
+
# The operation is atomic for the key. The block runs while the map entry is
|
|
128
|
+
# locked, so avoid using this method for Ractor-hot loops or calling back
|
|
129
|
+
# into the same map from inside the block. Prefer native update helpers such
|
|
130
|
+
# as #increment when they fit the workflow.
|
|
131
|
+
#
|
|
132
|
+
# If the block raises, the previous value is preserved.
|
|
133
|
+
#
|
|
134
|
+
# @param key [Object]
|
|
135
|
+
# @param initial [Object]
|
|
136
|
+
# @yieldparam value [Object, nil] current value
|
|
137
|
+
# @return [Object] the inserted or newly stored value
|
|
138
|
+
# @raise [LocalJumpError] if no block is given
|
|
139
|
+
# @raise [Exception] any exception raised by the block
|
|
140
|
+
#
|
|
141
|
+
# @!method increment(key, by = 1)
|
|
142
|
+
# Atomically increment the numeric value for +key+.
|
|
143
|
+
#
|
|
144
|
+
# Missing keys start at zero. Existing non-numeric values raise TypeError
|
|
145
|
+
# and are left unchanged. This uses a native update path and is the preferred
|
|
146
|
+
# counter primitive for Ractor-heavy workloads.
|
|
147
|
+
#
|
|
148
|
+
# @param key [Object]
|
|
149
|
+
# @param by [Numeric]
|
|
150
|
+
# @return [Numeric] the newly stored value
|
|
151
|
+
# @raise [TypeError] if +by+ or the existing value is not numeric
|
|
152
|
+
#
|
|
153
|
+
# @!method decrement(key, by = 1)
|
|
154
|
+
# Atomically decrement the numeric value for +key+.
|
|
155
|
+
#
|
|
156
|
+
# Missing keys start at zero.
|
|
157
|
+
#
|
|
158
|
+
# @param key [Object]
|
|
159
|
+
# @param by [Numeric]
|
|
160
|
+
# @return [Numeric] the newly stored value
|
|
161
|
+
# @raise [TypeError] if +by+ or the existing value is not numeric
|
|
162
|
+
#
|
|
163
|
+
# @!method append(key, value)
|
|
164
|
+
# Atomically append +value+ to an Array bucket for +key+.
|
|
165
|
+
#
|
|
166
|
+
# The stored Array is replaced rather than mutated in place.
|
|
167
|
+
#
|
|
168
|
+
# @param key [Object]
|
|
169
|
+
# @param value [Object]
|
|
170
|
+
# @return [Array] the newly stored frozen Array
|
|
171
|
+
# @raise [TypeError] if the existing value is not an Array
|
|
172
|
+
#
|
|
173
|
+
# @!method add_to_set(key, value)
|
|
174
|
+
# Atomically add +value+ to a Set bucket for +key+.
|
|
175
|
+
#
|
|
176
|
+
# The stored Set is replaced rather than mutated in place.
|
|
177
|
+
#
|
|
178
|
+
# @param key [Object]
|
|
179
|
+
# @param value [Object]
|
|
180
|
+
# @return [Set] the newly stored frozen Set
|
|
181
|
+
# @raise [TypeError] if the existing value is not a Set
|
|
182
|
+
class Map
|
|
21
183
|
# Set a value for +key+.
|
|
22
184
|
#
|
|
23
185
|
# @param key [Object]
|
|
@@ -37,6 +199,101 @@ module Ratomic
|
|
|
37
199
|
get(key)
|
|
38
200
|
end
|
|
39
201
|
|
|
202
|
+
# Fetch a value by +key+.
|
|
203
|
+
#
|
|
204
|
+
# Unlike #[], this distinguishes missing keys from explicit nil values.
|
|
205
|
+
#
|
|
206
|
+
# @param key [Object]
|
|
207
|
+
# @param default [Object]
|
|
208
|
+
# @yieldparam key [Object]
|
|
209
|
+
# @return [Object]
|
|
210
|
+
# @raise [KeyError] if +key+ is missing and no default or block is provided
|
|
211
|
+
def fetch(key, default = UNDEFINED)
|
|
212
|
+
return get(key) if key?(key)
|
|
213
|
+
return yield key if block_given?
|
|
214
|
+
return default unless default.equal?(UNDEFINED)
|
|
215
|
+
|
|
216
|
+
raise KeyError, "key not found: #{key.inspect}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Atomically increment the numeric value for +key+.
|
|
220
|
+
#
|
|
221
|
+
# Missing keys start at zero. Existing non-numeric values raise TypeError
|
|
222
|
+
# and are left unchanged. This uses a native update path and is the preferred
|
|
223
|
+
# counter primitive for Ractor-heavy workloads.
|
|
224
|
+
#
|
|
225
|
+
# @param key [Object]
|
|
226
|
+
# @param by [Numeric]
|
|
227
|
+
# @return [Numeric] the newly stored value
|
|
228
|
+
# @raise [TypeError] if +by+ or the existing value is not numeric
|
|
229
|
+
def increment(key, by = 1)
|
|
230
|
+
raise TypeError, "amount must be numeric: #{by.inspect}" unless by.is_a?(Numeric)
|
|
231
|
+
|
|
232
|
+
__increment_numeric(key, by)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Atomically decrement the numeric value for +key+.
|
|
236
|
+
#
|
|
237
|
+
# Missing keys start at zero.
|
|
238
|
+
#
|
|
239
|
+
# @param key [Object]
|
|
240
|
+
# @param by [Numeric]
|
|
241
|
+
# @return [Numeric] the newly stored value
|
|
242
|
+
# @raise [TypeError] if +by+ or the existing value is not numeric
|
|
243
|
+
def decrement(key, by = 1)
|
|
244
|
+
raise TypeError, "amount must be numeric: #{by.inspect}" unless by.is_a?(Numeric)
|
|
245
|
+
|
|
246
|
+
increment(key, -by)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Atomically append +value+ to an Array bucket for +key+.
|
|
250
|
+
#
|
|
251
|
+
# The stored Array is replaced rather than mutated in place.
|
|
252
|
+
#
|
|
253
|
+
# @param key [Object]
|
|
254
|
+
# @param value [Object]
|
|
255
|
+
# @return [Array] the newly stored frozen Array
|
|
256
|
+
# @raise [TypeError] if the existing value is not an Array
|
|
257
|
+
def append(key, value)
|
|
258
|
+
missing = !key?(key)
|
|
259
|
+
compute(key) do |old_value|
|
|
260
|
+
if old_value.nil? && missing
|
|
261
|
+
[value].freeze
|
|
262
|
+
else
|
|
263
|
+
unless old_value.is_a?(Array)
|
|
264
|
+
raise TypeError,
|
|
265
|
+
"existing value for #{key.inspect} must be an Array: #{old_value.inspect}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
(old_value + [value]).freeze
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Atomically add +value+ to a Set bucket for +key+.
|
|
274
|
+
#
|
|
275
|
+
# The stored Set is replaced rather than mutated in place.
|
|
276
|
+
#
|
|
277
|
+
# @param key [Object]
|
|
278
|
+
# @param value [Object]
|
|
279
|
+
# @return [Set] the newly stored frozen Set
|
|
280
|
+
# @raise [TypeError] if the existing value is not a Set
|
|
281
|
+
def add_to_set(key, value)
|
|
282
|
+
missing = !key?(key)
|
|
283
|
+
compute(key) do |old_value|
|
|
284
|
+
if old_value.nil? && missing
|
|
285
|
+
Set[value].freeze
|
|
286
|
+
else
|
|
287
|
+
unless old_value.is_a?(Set)
|
|
288
|
+
raise TypeError,
|
|
289
|
+
"existing value for #{key.inspect} must be a Set: #{old_value.inspect}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
(old_value | [value]).freeze
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
40
297
|
# Alias for #size.
|
|
41
298
|
#
|
|
42
299
|
# @return [Integer]
|
|
@@ -50,7 +307,14 @@ module Ratomic
|
|
|
50
307
|
def empty?
|
|
51
308
|
size.zero?
|
|
52
309
|
end
|
|
53
|
-
end
|
|
54
310
|
|
|
55
|
-
|
|
311
|
+
# Alias for #key?.
|
|
312
|
+
#
|
|
313
|
+
# @param key [Object]
|
|
314
|
+
# @return [Boolean]
|
|
315
|
+
def include?(key)
|
|
316
|
+
key?(key)
|
|
317
|
+
end
|
|
318
|
+
alias member? include?
|
|
319
|
+
end
|
|
56
320
|
end
|
data/lib/ratomic/pool.rb
CHANGED
data/lib/ratomic/queue.rb
CHANGED
|
@@ -1,8 +1,64 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ratomic
|
|
4
|
-
#
|
|
5
|
-
|
|
4
|
+
# A Ractor-shareable multi-producer, multi-consumer queue.
|
|
5
|
+
#
|
|
6
|
+
# Queue stores Ruby objects in a fixed-size native ring buffer. Push blocks
|
|
7
|
+
# when the queue is full; pop blocks when the queue is empty.
|
|
8
|
+
#
|
|
9
|
+
# @example Push and pop work
|
|
10
|
+
# queue = Ratomic::Queue.new(128)
|
|
11
|
+
# queue << "job"
|
|
12
|
+
# queue.pop # => "job"
|
|
13
|
+
#
|
|
14
|
+
# @!method self.new(capacity)
|
|
15
|
+
# Create a queue with a fixed capacity.
|
|
16
|
+
#
|
|
17
|
+
# @param capacity [Integer]
|
|
18
|
+
# @return [Ratomic::Queue]
|
|
19
|
+
# @raise [ArgumentError] if capacity is outside the supported range
|
|
20
|
+
# @raise [TypeError] if capacity is not an Integer
|
|
21
|
+
#
|
|
22
|
+
# @!method push(item)
|
|
23
|
+
# Push an item, blocking until space is available.
|
|
24
|
+
#
|
|
25
|
+
# @param item [Object]
|
|
26
|
+
# @return [void]
|
|
27
|
+
#
|
|
28
|
+
# @!method pop
|
|
29
|
+
# Pop an item, blocking until one is available.
|
|
30
|
+
#
|
|
31
|
+
# @return [Object]
|
|
32
|
+
#
|
|
33
|
+
# @!method peek
|
|
34
|
+
# Return the next item without removing it.
|
|
35
|
+
#
|
|
36
|
+
# Since this is a concurrent queue, the value is a moment-in-time
|
|
37
|
+
# observation.
|
|
38
|
+
#
|
|
39
|
+
# @return [Object, nil]
|
|
40
|
+
#
|
|
41
|
+
# @!method empty?
|
|
42
|
+
# Check whether the queue currently appears empty.
|
|
43
|
+
#
|
|
44
|
+
# Since this is a concurrent queue, the value is a moment-in-time
|
|
45
|
+
# observation.
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
#
|
|
49
|
+
# @!method size
|
|
50
|
+
# Return the current queue size.
|
|
51
|
+
#
|
|
52
|
+
# Since this is a concurrent queue, the value is a moment-in-time
|
|
53
|
+
# observation.
|
|
54
|
+
#
|
|
55
|
+
# @return [Integer]
|
|
56
|
+
#
|
|
57
|
+
# @!method length
|
|
58
|
+
# Alias for #size.
|
|
59
|
+
#
|
|
60
|
+
# @return [Integer]
|
|
61
|
+
class Queue
|
|
6
62
|
# Push an item and return the queue for chaining.
|
|
7
63
|
#
|
|
8
64
|
# @param item [Object]
|
|
@@ -12,6 +68,4 @@ module Ratomic
|
|
|
12
68
|
self
|
|
13
69
|
end
|
|
14
70
|
end
|
|
15
|
-
|
|
16
|
-
Queue.prepend(QueueMethods)
|
|
17
71
|
end
|
data/lib/ratomic/version.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Ratomic provides Ractor-friendly mutable data structures backed by native
|
|
4
|
+
# Rust concurrency primitives.
|
|
5
|
+
#
|
|
6
|
+
# The public API currently includes {Counter}, {Map}, {Queue}, and {Pool}.
|
|
3
7
|
module Ratomic
|
|
4
|
-
|
|
8
|
+
# Current gem version.
|
|
9
|
+
VERSION = "0.3.0"
|
|
5
10
|
end
|
data/lib/ratomic.rb
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
|
|
5
|
-
require_relative "ratomic/version"
|
|
6
|
-
require_relative "ratomic/ratomic"
|
|
7
|
-
require_relative "ratomic/counter"
|
|
8
|
-
require_relative "ratomic/undefined"
|
|
9
|
-
require_relative "ratomic/pool"
|
|
10
|
-
require_relative "ratomic/map"
|
|
11
|
-
require_relative "ratomic/queue"
|
|
3
|
+
require "ratomic/ratomic"
|
|
4
|
+
require "ratomic/version"
|
|
12
5
|
|
|
13
6
|
module Ratomic
|
|
14
|
-
# Base error for Ratomic-specific runtime failures.
|
|
15
7
|
class Error < StandardError; end
|
|
16
8
|
end
|
|
9
|
+
|
|
10
|
+
require "ratomic/undefined"
|
|
11
|
+
require "ratomic/counter"
|
|
12
|
+
require "ratomic/map"
|
|
13
|
+
require "ratomic/queue"
|
|
14
|
+
require "ratomic/pool"
|
data/ratomic.gemspec
CHANGED
|
@@ -9,15 +9,16 @@ Gem::Specification.new do |spec|
|
|
|
9
9
|
spec.email = ["mike@perham.net"]
|
|
10
10
|
spec.metadata["maintainers"] = "Ken C. Demanawa"
|
|
11
11
|
|
|
12
|
-
spec.summary = "
|
|
13
|
-
spec.description =
|
|
14
|
-
|
|
12
|
+
spec.summary = "Ractor-safe concurrent data structures for Ruby"
|
|
13
|
+
spec.description = "Ractor-safe counters, maps, queues, and ownership-transfer pools " \
|
|
14
|
+
"backed by native Rust concurrency primitives."
|
|
15
|
+
spec.homepage = "https://mperham.github.io/ratomic"
|
|
15
16
|
spec.license = "MIT"
|
|
16
|
-
spec.required_ruby_version = ">= 4.0.
|
|
17
|
+
spec.required_ruby_version = [">= 4.0", "< 4.1.dev"]
|
|
17
18
|
|
|
18
19
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
19
20
|
spec.metadata["source_code_uri"] = "https://github.com/mperham/ratomic"
|
|
20
|
-
spec.metadata["changelog_uri"] = "https://github.com/mperham/ratomic"
|
|
21
|
+
spec.metadata["changelog_uri"] = "https://github.com/mperham/ratomic/blob/trunk/CHANGELOG.md"
|
|
21
22
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
22
23
|
|
|
23
24
|
# Specify which files should be added to the gem when it is released.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ratomic
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Perham
|
|
@@ -24,7 +24,8 @@ dependencies:
|
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: 0.9.128
|
|
27
|
-
description:
|
|
27
|
+
description: Ractor-safe counters, maps, queues, and ownership-transfer pools backed
|
|
28
|
+
by native Rust concurrency primitives.
|
|
28
29
|
email:
|
|
29
30
|
- mike@perham.net
|
|
30
31
|
executables: []
|
|
@@ -55,14 +56,14 @@ files:
|
|
|
55
56
|
- lib/ratomic/undefined.rb
|
|
56
57
|
- lib/ratomic/version.rb
|
|
57
58
|
- ratomic.gemspec
|
|
58
|
-
homepage: https://github.
|
|
59
|
+
homepage: https://mperham.github.io/ratomic
|
|
59
60
|
licenses:
|
|
60
61
|
- MIT
|
|
61
62
|
metadata:
|
|
62
63
|
maintainers: Ken C. Demanawa
|
|
63
|
-
homepage_uri: https://github.
|
|
64
|
+
homepage_uri: https://mperham.github.io/ratomic
|
|
64
65
|
source_code_uri: https://github.com/mperham/ratomic
|
|
65
|
-
changelog_uri: https://github.com/mperham/ratomic
|
|
66
|
+
changelog_uri: https://github.com/mperham/ratomic/blob/trunk/CHANGELOG.md
|
|
66
67
|
rubygems_mfa_required: 'true'
|
|
67
68
|
rdoc_options: []
|
|
68
69
|
require_paths:
|
|
@@ -71,7 +72,10 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
71
72
|
requirements:
|
|
72
73
|
- - ">="
|
|
73
74
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: 4.0
|
|
75
|
+
version: '4.0'
|
|
76
|
+
- - "<"
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: 4.1.dev
|
|
75
79
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
80
|
requirements:
|
|
77
81
|
- - ">="
|
|
@@ -80,5 +84,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
80
84
|
requirements: []
|
|
81
85
|
rubygems_version: 4.0.10
|
|
82
86
|
specification_version: 4
|
|
83
|
-
summary:
|
|
87
|
+
summary: Ractor-safe concurrent data structures for Ruby
|
|
84
88
|
test_files: []
|