merge_machines 0.2.0-x86_64-linux
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 +7 -0
- data/CHANGELOG.md +57 -0
- data/LICENSE.txt +21 -0
- data/README.md +252 -0
- data/ext/merge_machines_native/extconf.rb +3 -0
- data/lib/merge_machines/version.rb +5 -0
- data/lib/merge_machines.rb +8 -0
- data/lib/merge_machines_native/merge_machines_native.so +0 -0
- data/sig/merge_machines.rbs +238 -0
- metadata +64 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2d0658ea6e6338e7e3e562c25a7252c24e1f1decf3d2e65672eefce6d8df8f76
|
|
4
|
+
data.tar.gz: cb093d38e65a6cdaa206a1bc6ae7c85eea3917a1fb54ff831ff8897d1d1fea9e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2f62e616091582604a54cc1324b29b04bd2b9570db407b70080ed64b10d96d7a424a9df76652c8146565b3c53930f7b987b953e63a9926acb2f922d96bd46347
|
|
7
|
+
data.tar.gz: 6d8559d9f9cdba3dd029f5b4ba0d7dac389ac46d71fb0d82b29f333c310a3936442cf622e9b3cdb4ac4084b9ef52251f94db124ca345fd8fec58fbc917f599e9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0](https://github.com/seuros/merge_machines/compare/merge_machines-v0.1.0...merge_machines/v0.2.0) (2026-11-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* hybrid timestamps cross the boundary as nanoseconds, not Float seconds
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* CRDTs and the clocks they need ([0cf6b7f](https://github.com/seuros/merge_machines/commit/0cf6b7f8de95fc40b5cdb6f05a1bf4bcafe6e7af))
|
|
13
|
+
* pass a rubygems OTP through the environment ([6834db9](https://github.com/seuros/merge_machines/commit/6834db97472a0028a6fa837d2cb3b845620d97e6))
|
|
14
|
+
* Ruby gem, magnus FFI, and a codec for every state ([6be0bda](https://github.com/seuros/merge_machines/commit/6be0bdaea6317b2bc02087db28eb374c0eea88af))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* a crowded reading let the hybrid clock reissue one timestamp ([4c4d139](https://github.com/seuros/merge_machines/commit/4c4d13942d5ccb3b70a5666f0b7306d1487d367b))
|
|
20
|
+
* a merge that overran capacity kept the names of the keys it turned down ([fe5ac2a](https://github.com/seuros/merge_machines/commit/fe5ac2aaec8bf9db35e2131fb67951dcaf5c4c1b))
|
|
21
|
+
* a PNCounter balance was the difference of two clamped totals ([dde63bc](https://github.com/seuros/merge_machines/commit/dde63bca70683ed8ba57deb962f980fab854b776))
|
|
22
|
+
* a register that got its value by merging took a stale write ([ae393ef](https://github.com/seuros/merge_machines/commit/ae393efc1fdaf8bf3980d70e7ef0508a3296c36d))
|
|
23
|
+
* a single observed timestamp stopped a Lamport clock for good ([48f7788](https://github.com/seuros/merge_machines/commit/48f77882dffb7bdd6952c1175612ca60e69c0aae))
|
|
24
|
+
* a source install can build, and a failed gem push fails the release ([bf37071](https://github.com/seuros/merge_machines/commit/bf37071c1398ecc81d4185f24d89cd89e8a8abe0))
|
|
25
|
+
* a truncated register blob loaded as an empty one ([1aa9391](https://github.com/seuros/merge_machines/commit/1aa939148ba7f5161158b033a0a08c658ad3d9fa))
|
|
26
|
+
* an OrSet add vanished when a replica's tag counter ran out ([8f81ce8](https://github.com/seuros/merge_machines/commit/8f81ce8240ce9070bb50f685bd6d54750674089b))
|
|
27
|
+
* collect the built gems whatever depth the download puts them at ([29ccbdd](https://github.com/seuros/merge_machines/commit/29ccbddfbaee366919c2833925cfa989a5fb2a00))
|
|
28
|
+
* hybrid timestamps cross the boundary as nanoseconds, not Float seconds ([01c9d86](https://github.com/seuros/merge_machines/commit/01c9d8602d07be6bdfcb659ae90b8f42706130e4))
|
|
29
|
+
* keep the name table consistent when a merge overruns capacity ([3f34aa5](https://github.com/seuros/merge_machines/commit/3f34aa5c24bae838a1a3421f64ae061c5a6afb5b))
|
|
30
|
+
* refuse a fractional stamp instead of truncating it into a tie ([6d3be65](https://github.com/seuros/merge_machines/commit/6d3be65af48918f890544e8547cfeb0eb03b45a2))
|
|
31
|
+
* ship the core's test files so the packaged crate builds ([4651644](https://github.com/seuros/merge_machines/commit/4651644a58306aac0b6fd0e890f1f033b89adfa9))
|
|
32
|
+
* the hash let a short key collide with a longer zero-padded one ([0a53206](https://github.com/seuros/merge_machines/commit/0a532065246def302a6510f082779f7981a61ff3))
|
|
33
|
+
* the release tag carries the gem name, and the gate uses the aggregate output ([d048d8c](https://github.com/seuros/merge_machines/commit/d048d8cf92bf29b7a284a2f475c4e6f94280563d))
|
|
34
|
+
* two unrescuable panics, a non-commutative merge, and a lost first write ([6951483](https://github.com/seuros/merge_machines/commit/6951483b917a731359ac2b4d94c0b326439f0882))
|
|
35
|
+
|
|
36
|
+
## 0.1.0
|
|
37
|
+
|
|
38
|
+
- Initial release.
|
|
39
|
+
- `MergeMachines::GCounter` and `PNCounter`: one count per replica, summed for
|
|
40
|
+
a value that only grows, and two of those for one that moves both ways.
|
|
41
|
+
- `MergeMachines::LwwRegister`: one value, held by whichever write was stamped
|
|
42
|
+
last, with the replica id as the tie-break, stamped by either clock.
|
|
43
|
+
- `MergeMachines::GSet` and `OrSet`: a set nothing leaves, and one where a
|
|
44
|
+
remove only takes out the adds it has seen, so a concurrent add survives it.
|
|
45
|
+
- `MergeMachines::LogicalClock` and `HybridClock`: Lamport's counter, and a
|
|
46
|
+
wall-clock reading paired with a counter that breaks its ties. The hybrid
|
|
47
|
+
clock reads `Process::CLOCK_REALTIME`, which may step backwards, and no
|
|
48
|
+
deadline may be derived from it.
|
|
49
|
+
- Keys and register values are Ruby strings, carried as 64-bit hashes with a
|
|
50
|
+
side table that `dump` and `load` round-trip.
|
|
51
|
+
- Replica counts and set capacities are const generics in the crate, so each
|
|
52
|
+
size is a separately compiled type and anything off the menu raises
|
|
53
|
+
`ArgumentError`.
|
|
54
|
+
- `merge-machines` core crate: the states and the clocks in `no_std` Rust with
|
|
55
|
+
zero dependencies, plus a fixed-length codec per state. Builds and lints
|
|
56
|
+
clean on bare metal.
|
|
57
|
+
- RBS signatures for the whole public API.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Abdelkader Boudih
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# MergeMachines 🔀
|
|
2
|
+
|
|
3
|
+
> **Two ships, one log, no argument.** The tender that lets separated crews write
|
|
4
|
+
> their own records and still agree when the fleet regroups.
|
|
5
|
+
|
|
6
|
+
Conflict-free replicated data types for Ruby, and the clocks that stamp them.
|
|
7
|
+
|
|
8
|
+
Two processes that both edited the same thing have to agree on the result
|
|
9
|
+
without asking each other. These states merge commutatively, associatively and
|
|
10
|
+
idempotently, so a replica converges on the same value whatever order the
|
|
11
|
+
updates arrived in and however many times one of them arrived. **AMS Lamport**
|
|
12
|
+
runs under comms blackout by design: it never calls home, it reconciles on
|
|
13
|
+
contact.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 🚀 Launch Sequence
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require 'merge_machines'
|
|
21
|
+
|
|
22
|
+
edge = MergeMachines::GCounter.new(replicas: 8)
|
|
23
|
+
origin = MergeMachines::GCounter.new(replicas: 8)
|
|
24
|
+
|
|
25
|
+
edge.increment(replica: 1, by: 3)
|
|
26
|
+
origin.increment(replica: 2, by: 7)
|
|
27
|
+
|
|
28
|
+
edge.merge(origin).value # => 10
|
|
29
|
+
edge.merge(origin).value # => 10, the same state again is absorbed
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Nothing here talks to a network. The caller owns the transport, the replica ids
|
|
33
|
+
and every clock reading.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 🔩 The Native Extension Is Required
|
|
38
|
+
|
|
39
|
+
There is no pure-Ruby fallback and no `DISABLE_*` switch. The gem is a binding
|
|
40
|
+
to the `merge-machines` crate in `ext/merge_machines_native/core`, and it needs
|
|
41
|
+
Ruby 4.0 and a Rust toolchain (or a platform gem). A second implementation
|
|
42
|
+
would mean two sets of convergence tests and two byte formats to keep in step.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 📐 Sizes Are a Fixed Menu
|
|
47
|
+
|
|
48
|
+
The crate sizes every state by a const generic, so each size is a separately
|
|
49
|
+
compiled type. The extension carries these and raises `ArgumentError` for
|
|
50
|
+
anything else:
|
|
51
|
+
|
|
52
|
+
| Class | Keyword | Values |
|
|
53
|
+
| ------------- | ----------- | ---------------- |
|
|
54
|
+
| `GCounter` | `replicas:` | 4, 8, 16, 32 |
|
|
55
|
+
| `PNCounter` | `replicas:` | 4, 8, 16, 32 |
|
|
56
|
+
| `GSet` | `capacity:` | 64, 256, 1024 |
|
|
57
|
+
| `OrSet` | `capacity:` | 64, 256, 1024 |
|
|
58
|
+
| `LwwRegister` | `stamp:` | `:logical`, `:hybrid` |
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
MergeMachines::GSet.new(capacity: 100)
|
|
62
|
+
# ArgumentError: capacity 100 is not supported, expected one of [64, 256, 1024]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A state with no free slot left raises `MergeMachines::Full` rather than
|
|
66
|
+
growing. That is the deal a fixed size buys: a replica's memory is known at
|
|
67
|
+
construction time.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 🔢 Counters
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
counter = MergeMachines::GCounter.new(replicas: 8)
|
|
75
|
+
counter.increment(replica: 1, by: 3)
|
|
76
|
+
counter.increment(replica: 1) # by: defaults to 1
|
|
77
|
+
counter.count(replica: 1) # => 4
|
|
78
|
+
counter.value # => 4, every replica's count
|
|
79
|
+
counter.merge(other) # exact union, idempotent
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
One shared number cannot be merged: two replicas that both saw 5 and both added
|
|
83
|
+
1 have no way to tell 6 from 7. One count per replica can, because no replica
|
|
84
|
+
ever writes another's count, which is why `increment` needs a `replica:`.
|
|
85
|
+
|
|
86
|
+
`PNCounter` is two of those, one for what went up and one for what came down,
|
|
87
|
+
so its value may be negative.
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
counter = MergeMachines::PNCounter.new(replicas: 8)
|
|
91
|
+
counter.increment(replica: 1, by: 10)
|
|
92
|
+
counter.decrement(replica: 2, by: 4)
|
|
93
|
+
counter.value # => 6
|
|
94
|
+
counter.added # => 10
|
|
95
|
+
counter.removed # => 4
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 🗂️ Sets
|
|
101
|
+
|
|
102
|
+
Keys are strings and must be UTF-8. The crate's elements are `Copy + Eq`, so a
|
|
103
|
+
key crosses as a 64-bit hash of its bytes and the extension keeps the string
|
|
104
|
+
each hash came from; `dump` carries that table along, so `load` hands the real
|
|
105
|
+
strings back.
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
set = MergeMachines::GSet.new(capacity: 64)
|
|
109
|
+
set.add('user-1')
|
|
110
|
+
set.include?('user-1') # => true
|
|
111
|
+
set.members # => ['user-1']
|
|
112
|
+
set.merge(other) # union
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`GSet` only grows, so merging is the union and there is nothing to resolve.
|
|
116
|
+
`OrSet` tags every add and tombstones the tags a remove has seen, so a remove
|
|
117
|
+
races a concurrent add and loses, and re-adding afterwards works.
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
set = MergeMachines::OrSet.new(capacity: 64)
|
|
121
|
+
set.add('user-1', replica: 1)
|
|
122
|
+
set.remove('user-1')
|
|
123
|
+
set.include?('user-1') # => false
|
|
124
|
+
|
|
125
|
+
set.add('user-1', replica: 1)
|
|
126
|
+
set.include?('user-1') # => true
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`capacity:` bounds the adds and the tombstones separately. A removed key keeps
|
|
130
|
+
its add slot, so a set that churns runs out.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 🏁 One Value, Last Writer Wins
|
|
135
|
+
|
|
136
|
+
Stamps order the writes and the replica id breaks the ties. Both sides of a
|
|
137
|
+
concurrent write pick the same winner, which is the whole point, and the
|
|
138
|
+
loser's value is gone, which is the price.
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
register = MergeMachines::LwwRegister.new
|
|
142
|
+
register.set('http://origin', replica: 1, stamp: 7)
|
|
143
|
+
register.value # => 'http://origin'
|
|
144
|
+
register.stamp # => 7
|
|
145
|
+
register.replica # => 1
|
|
146
|
+
register.merge(other)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A register nobody has written holds `nil`. `stamp:` is whichever clock you
|
|
150
|
+
picked at construction: an Integer for `:logical`, a `Timestamp` for
|
|
151
|
+
`:hybrid`.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 🕰️ Which Chronometer
|
|
156
|
+
|
|
157
|
+
`LogicalClock` is Lamport's counter. It advances on every local event and jumps
|
|
158
|
+
past any stamp it is shown, so a receive is always stamped after the send it
|
|
159
|
+
answers. No clock is read.
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
clock = MergeMachines::LogicalClock.new(replica: 1)
|
|
163
|
+
sent_at = clock.tick # => 1
|
|
164
|
+
clock.observe(42) # => 43, past both the local history and the send
|
|
165
|
+
clock.now # => 43
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
A counter with nothing left raises `MergeMachines::Exhausted` rather than
|
|
169
|
+
handing out a stamp it already gave away, and the clock stays where it was.
|
|
170
|
+
|
|
171
|
+
`HybridClock` pairs a wall-clock reading with a counter: close to physical
|
|
172
|
+
time, still totally ordered, and still monotonic when the host's clock is
|
|
173
|
+
corrected backwards.
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
clock = MergeMachines::HybridClock.new(replica: 1)
|
|
177
|
+
clock.tick # Timestamp, reading from the realtime clock
|
|
178
|
+
clock.tick(wall_ns: 1_700_000_000_500_000_000) # or a reading you supply
|
|
179
|
+
clock.observe(wall_ns: 1_500_000_000, counter: 3) # a timestamp that arrived
|
|
180
|
+
clock.now # the last one issued
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
It raises `MergeMachines::Exhausted` too, when the reading has not advanced and
|
|
184
|
+
the counter that separates two events at one reading has nothing left. A
|
|
185
|
+
reading that really did advance resets that counter.
|
|
186
|
+
|
|
187
|
+
### ⚠️ The Reading It Wants Is Not a Monotonic One
|
|
188
|
+
|
|
189
|
+
`wall_ns:` is physical time two machines can compare, in nanoseconds, and
|
|
190
|
+
defaults to
|
|
191
|
+
`Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)`. That clock
|
|
192
|
+
**steps backwards** when the host's is corrected, which is exactly why the
|
|
193
|
+
counter exists.
|
|
194
|
+
|
|
195
|
+
**No deadline, timeout or budget may be derived from it.** Measuring how long
|
|
196
|
+
something took belongs to the monotonic clock, which this gem neither reads nor
|
|
197
|
+
models. `deadline_machines` owns that, and **AMS Layland** is the ship you hail
|
|
198
|
+
for it.
|
|
199
|
+
|
|
200
|
+
A reading crosses this boundary as seconds in a Float, so a realtime reading
|
|
201
|
+
round-trips to about 256 nanoseconds. Two timestamps that land inside the same
|
|
202
|
+
window are separated by the counter, which is what it is for.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## 📦 Putting State on a Wire
|
|
207
|
+
|
|
208
|
+
`dump` returns a binary string and `load` reads one back.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
blob = set.dump # String, Encoding::BINARY
|
|
212
|
+
MergeMachines::GSet.load(blob)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
A blob is the crate's own fixed-length frame, led by a tag byte naming the
|
|
216
|
+
structure and a field naming the shape, followed by whatever the Ruby surface
|
|
217
|
+
needs that the frame does not hold: the key table for the sets and the
|
|
218
|
+
register, the replica id for the clocks. `load` raises `ArgumentError` on a
|
|
219
|
+
truncated blob, on one written by another structure, and on one written at a
|
|
220
|
+
shape this build does not carry.
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
MergeMachines::GSet.load(MergeMachines::OrSet.new(capacity: 64).dump)
|
|
224
|
+
# ArgumentError: codec: wrong structure tag
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## 🚫 Rules of Engagement
|
|
230
|
+
|
|
231
|
+
- It is not a transport. There is no gossip, no anti-entropy loop, no peer
|
|
232
|
+
list. You ship the blobs.
|
|
233
|
+
- It is not a timer. The hybrid clock reads a wall clock, never a monotonic
|
|
234
|
+
one, and nothing here measures elapsed time.
|
|
235
|
+
- It is not a state machine, and it does not retry, back off, break a circuit
|
|
236
|
+
or shed load. Other machines in this family do those.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## 📡 Credits
|
|
241
|
+
|
|
242
|
+
**AMS Lamport**, NCC-1178, *Lamport*-class tender.
|
|
243
|
+
|
|
244
|
+
Named for Leslie Lamport's *Time, Clocks, and the Ordering of Events in a
|
|
245
|
+
Distributed System* (1978), which showed that order between separated machines
|
|
246
|
+
is something you stamp, not something you read off a clock.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
MIT.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'merge_machines/version'
|
|
4
|
+
|
|
5
|
+
# GCounter, PNCounter, LwwRegister, GSet, OrSet, LogicalClock and HybridClock
|
|
6
|
+
# are defined by the native extension; there is no pure-Ruby implementation to
|
|
7
|
+
# fall back to.
|
|
8
|
+
require 'merge_machines_native/merge_machines_native'
|
|
Binary file
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Conflict-free replicated data types and the clocks that stamp them. Every
|
|
2
|
+
# class here is defined by the native extension, which binds the
|
|
3
|
+
# `merge-machines` crate.
|
|
4
|
+
#
|
|
5
|
+
# Every state merges commutatively, associatively and idempotently, so replicas
|
|
6
|
+
# converge without a coordinator and without ordering their messages. Nothing
|
|
7
|
+
# here talks to a network: the caller owns the transport and the replica ids.
|
|
8
|
+
module MergeMachines
|
|
9
|
+
VERSION: String
|
|
10
|
+
|
|
11
|
+
# Nanoseconds in a second. Wall-clock readings cross this boundary as seconds
|
|
12
|
+
# and move through the clocks as nanoseconds.
|
|
13
|
+
NS_PER_SECOND: Integer
|
|
14
|
+
|
|
15
|
+
class Error < StandardError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Raised when a state needs a slot and every one of them is taken.
|
|
19
|
+
class Full < Error
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Raised when a clock has issued the last stamp its counter can hold, by
|
|
23
|
+
# either clock.
|
|
24
|
+
class Exhausted < Error
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A wall-clock reading in nanoseconds and the counter that breaks its ties.
|
|
28
|
+
class Timestamp < ::Data
|
|
29
|
+
attr_reader wall_ns: Integer
|
|
30
|
+
attr_reader counter: Integer
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Which clock stamps a register's writes.
|
|
34
|
+
type stamp_kind = :logical | :hybrid
|
|
35
|
+
|
|
36
|
+
# A counter that only grows, counted separately by each replica.
|
|
37
|
+
class GCounter
|
|
38
|
+
# `replicas` is 4, 8, 16 or 32.
|
|
39
|
+
def self.new: (replicas: Integer) -> instance
|
|
40
|
+
|
|
41
|
+
def self.load: (String blob) -> instance
|
|
42
|
+
|
|
43
|
+
def replicas: () -> Integer
|
|
44
|
+
|
|
45
|
+
# Adds `by` to `replica`'s own count. Raises `Full` when `replica` has no
|
|
46
|
+
# slot yet and every one of them is taken.
|
|
47
|
+
def increment: (replica: Integer, ?by: Integer) -> nil
|
|
48
|
+
|
|
49
|
+
# What `replica` has added, zero if it has no slot here.
|
|
50
|
+
def count: (replica: Integer) -> Integer
|
|
51
|
+
|
|
52
|
+
# Every replica's count.
|
|
53
|
+
def value: () -> Integer
|
|
54
|
+
|
|
55
|
+
# Folds `other` in. Raises `ArgumentError` on a different replica count.
|
|
56
|
+
def merge: (GCounter other) -> self
|
|
57
|
+
|
|
58
|
+
def dump: () -> String
|
|
59
|
+
def inspect: () -> String
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A counter replicas can both raise and lower, from two that only grow.
|
|
63
|
+
class PNCounter
|
|
64
|
+
# `replicas` is 4, 8, 16 or 32.
|
|
65
|
+
def self.new: (replicas: Integer) -> instance
|
|
66
|
+
|
|
67
|
+
def self.load: (String blob) -> instance
|
|
68
|
+
|
|
69
|
+
def replicas: () -> Integer
|
|
70
|
+
|
|
71
|
+
def increment: (replica: Integer, ?by: Integer) -> nil
|
|
72
|
+
|
|
73
|
+
def decrement: (replica: Integer, ?by: Integer) -> nil
|
|
74
|
+
|
|
75
|
+
# Everything added, minus everything taken away, which may be negative.
|
|
76
|
+
def value: () -> Integer
|
|
77
|
+
|
|
78
|
+
def added: () -> Integer
|
|
79
|
+
def removed: () -> Integer
|
|
80
|
+
|
|
81
|
+
# Folds `other` in. Raises `ArgumentError` on a different replica count.
|
|
82
|
+
def merge: (PNCounter other) -> self
|
|
83
|
+
|
|
84
|
+
def dump: () -> String
|
|
85
|
+
def inspect: () -> String
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# One value, held by whichever write was stamped last, with the replica id as
|
|
89
|
+
# the tie-break.
|
|
90
|
+
class LwwRegister
|
|
91
|
+
# `stamp` picks which clock stamps the writes, defaulting to `:logical`.
|
|
92
|
+
def self.new: (?stamp: stamp_kind) -> instance
|
|
93
|
+
|
|
94
|
+
def self.load: (String blob) -> instance
|
|
95
|
+
|
|
96
|
+
def stamp_kind: () -> stamp_kind
|
|
97
|
+
|
|
98
|
+
# Writes `value` if `stamp` and `replica` beat the write already held.
|
|
99
|
+
# `stamp` is an Integer on a logical register and a `Timestamp` on a hybrid
|
|
100
|
+
# one. `value` must be UTF-8.
|
|
101
|
+
def set: (String value, replica: Integer, stamp: Integer | Timestamp) -> nil
|
|
102
|
+
|
|
103
|
+
# The value being held, `nil` until the first write.
|
|
104
|
+
def value: () -> String?
|
|
105
|
+
|
|
106
|
+
# The stamp of the write being held.
|
|
107
|
+
def stamp: () -> (Integer | Timestamp)
|
|
108
|
+
|
|
109
|
+
# The replica that wrote what is being held.
|
|
110
|
+
def replica: () -> Integer
|
|
111
|
+
|
|
112
|
+
# Folds `other` in. Raises `ArgumentError` on a register stamped by the
|
|
113
|
+
# other clock.
|
|
114
|
+
def merge: (LwwRegister other) -> self
|
|
115
|
+
|
|
116
|
+
def dump: () -> String
|
|
117
|
+
def inspect: () -> String
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A set nothing is ever taken out of.
|
|
121
|
+
class GSet
|
|
122
|
+
# `capacity` is 64, 256 or 1024.
|
|
123
|
+
def self.new: (capacity: Integer) -> instance
|
|
124
|
+
|
|
125
|
+
def self.load: (String blob) -> instance
|
|
126
|
+
|
|
127
|
+
def capacity: () -> Integer
|
|
128
|
+
|
|
129
|
+
# Adds `key`, which is a no-op if it is already in. Raises `Full` when
|
|
130
|
+
# `key` is new and every slot is taken, `EncodingError` when it is not
|
|
131
|
+
# UTF-8.
|
|
132
|
+
def add: (String key) -> nil
|
|
133
|
+
|
|
134
|
+
def include?: (String key) -> bool
|
|
135
|
+
|
|
136
|
+
# The keys in the set, in no particular order.
|
|
137
|
+
def members: () -> Array[String]
|
|
138
|
+
|
|
139
|
+
def size: () -> Integer
|
|
140
|
+
def empty?: () -> bool
|
|
141
|
+
|
|
142
|
+
# Unions `other` in. Raises `ArgumentError` on a different capacity.
|
|
143
|
+
def merge: (GSet other) -> self
|
|
144
|
+
|
|
145
|
+
def dump: () -> String
|
|
146
|
+
def inspect: () -> String
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A set keys can leave, where a concurrent add survives a remove.
|
|
150
|
+
class OrSet
|
|
151
|
+
# `capacity` is 64, 256 or 1024, and bounds the adds and the tombstones
|
|
152
|
+
# separately.
|
|
153
|
+
def self.new: (capacity: Integer) -> instance
|
|
154
|
+
|
|
155
|
+
def self.load: (String blob) -> instance
|
|
156
|
+
|
|
157
|
+
def capacity: () -> Integer
|
|
158
|
+
|
|
159
|
+
# Adds `key` under a tag of `replica` and the next counter that replica has
|
|
160
|
+
# not used here. Raises `Full` when every add slot is taken, `Exhausted`
|
|
161
|
+
# when `replica` has no counter left, `EncodingError` when `key` is not
|
|
162
|
+
# UTF-8.
|
|
163
|
+
def add: (String key, replica: Integer) -> nil
|
|
164
|
+
|
|
165
|
+
# Tombstones every tag this set has seen for `key`. Raises `Full` when the
|
|
166
|
+
# tombstones do not fit.
|
|
167
|
+
def remove: (String key) -> nil
|
|
168
|
+
|
|
169
|
+
def include?: (String key) -> bool
|
|
170
|
+
|
|
171
|
+
# The keys still in the set, each once, in no particular order.
|
|
172
|
+
def members: () -> Array[String]
|
|
173
|
+
|
|
174
|
+
def size: () -> Integer
|
|
175
|
+
def empty?: () -> bool
|
|
176
|
+
|
|
177
|
+
# Folds `other` in. Raises `ArgumentError` on a different capacity.
|
|
178
|
+
def merge: (OrSet other) -> self
|
|
179
|
+
|
|
180
|
+
def dump: () -> String
|
|
181
|
+
def inspect: () -> String
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Lamport's counter: ordering events without reading a clock.
|
|
185
|
+
class LogicalClock
|
|
186
|
+
def self.new: (replica: Integer) -> instance
|
|
187
|
+
|
|
188
|
+
def self.load: (String blob) -> instance
|
|
189
|
+
|
|
190
|
+
# The replica whose events this clock stamps.
|
|
191
|
+
def replica: () -> Integer
|
|
192
|
+
|
|
193
|
+
# The last stamp this clock issued.
|
|
194
|
+
def now: () -> Integer
|
|
195
|
+
|
|
196
|
+
# Stamps a local event. Raises `Exhausted` when the counter holds its
|
|
197
|
+
# highest value, which leaves the clock where it was.
|
|
198
|
+
def tick: () -> Integer
|
|
199
|
+
|
|
200
|
+
# Stamps the receipt of `observed`, a stamp from another replica. Raises
|
|
201
|
+
# `Exhausted` when it cannot be stamped after `observed`.
|
|
202
|
+
def observe: (Integer observed) -> Integer
|
|
203
|
+
|
|
204
|
+
def dump: () -> String
|
|
205
|
+
def inspect: () -> String
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# A wall-clock reading paired with a counter that breaks its ties, which never
|
|
209
|
+
# goes backwards.
|
|
210
|
+
#
|
|
211
|
+
# The reading comes from `Process::CLOCK_REALTIME`, which may step backwards.
|
|
212
|
+
# No deadline may be derived from it.
|
|
213
|
+
class HybridClock
|
|
214
|
+
def self.new: (replica: Integer) -> instance
|
|
215
|
+
|
|
216
|
+
def self.load: (String blob) -> instance
|
|
217
|
+
|
|
218
|
+
# The replica whose events this clock stamps.
|
|
219
|
+
def replica: () -> Integer
|
|
220
|
+
|
|
221
|
+
# The last timestamp this clock issued.
|
|
222
|
+
def now: () -> Timestamp
|
|
223
|
+
|
|
224
|
+
# Stamps a local event at `wall_ns`, defaulting to the realtime clock's
|
|
225
|
+
# reading. Raises `Exhausted` when the reading has not advanced and the
|
|
226
|
+
# counter that separates two events at one reading holds its highest value,
|
|
227
|
+
# which leaves the clock where it was.
|
|
228
|
+
def tick: (?wall_ns: Integer) -> Timestamp
|
|
229
|
+
|
|
230
|
+
# Stamps the receipt of the timestamp `wall_ns` and `counter` name, at the
|
|
231
|
+
# realtime clock's reading. Raises `Exhausted` when it cannot be stamped
|
|
232
|
+
# after the timestamp observed.
|
|
233
|
+
def observe: (wall_ns: Integer, counter: Integer) -> Timestamp
|
|
234
|
+
|
|
235
|
+
def dump: () -> String
|
|
236
|
+
def inspect: () -> String
|
|
237
|
+
end
|
|
238
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: merge_machines
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: x86_64-linux
|
|
6
|
+
authors:
|
|
7
|
+
- Abdelkader Boudih
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-01 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Counters, a last-writer-wins register, grow-only and observed-remove
|
|
14
|
+
sets, and the Lamport and hybrid logical clocks that stamp them, bound to Ruby from
|
|
15
|
+
the no_std merge-machines crate. Every state merges commutatively, associatively
|
|
16
|
+
and idempotently, and encodes to a byte string other languages reading the same
|
|
17
|
+
crate can decode.
|
|
18
|
+
email:
|
|
19
|
+
- oss@seuros.com
|
|
20
|
+
executables: []
|
|
21
|
+
extensions: []
|
|
22
|
+
extra_rdoc_files: []
|
|
23
|
+
files:
|
|
24
|
+
- CHANGELOG.md
|
|
25
|
+
- LICENSE.txt
|
|
26
|
+
- README.md
|
|
27
|
+
- ext/merge_machines_native/extconf.rb
|
|
28
|
+
- lib/merge_machines.rb
|
|
29
|
+
- lib/merge_machines/version.rb
|
|
30
|
+
- lib/merge_machines_native/merge_machines_native.so
|
|
31
|
+
- sig/merge_machines.rbs
|
|
32
|
+
homepage: https://github.com/seuros/merge_machines
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
source_code_uri: https://github.com/seuros/merge_machines
|
|
37
|
+
changelog_uri: https://github.com/seuros/merge_machines/blob/main/CHANGELOG.md
|
|
38
|
+
bug_tracker_uri: https://github.com/seuros/merge_machines/issues
|
|
39
|
+
rubygems_mfa_required: 'true'
|
|
40
|
+
cargo_crate_name: merge_machines_native
|
|
41
|
+
cargo_manifest_path: ext/merge_machines_native/ffi/Cargo.toml
|
|
42
|
+
post_install_message:
|
|
43
|
+
rdoc_options: []
|
|
44
|
+
require_paths:
|
|
45
|
+
- lib
|
|
46
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '4.0'
|
|
51
|
+
- - "<"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 4.1.dev
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 3.5.23
|
|
61
|
+
signing_key:
|
|
62
|
+
specification_version: 4
|
|
63
|
+
summary: Conflict-free replicated data types for Ruby
|
|
64
|
+
test_files: []
|