lease_machines 0.1.6-arm64-darwin
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 +43 -0
- data/LICENSE.txt +21 -0
- data/README.md +236 -0
- data/ext/lease_machines_native/core/CHANGELOG.md +36 -0
- data/ext/lease_machines_native/core/README.md +172 -0
- data/ext/lease_machines_native/extconf.rb +2 -0
- data/lib/lease_machines/version.rb +4 -0
- data/lib/lease_machines.rb +28 -0
- data/lib/lease_machines_native/lease_machines_native.bundle +0 -0
- data/sig/lease_machines.rbs +30 -0
- metadata +61 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2f90fe701a26dbc261d30647d22cd88507bd2dffb395a5214d2045c9528b1d8d
|
|
4
|
+
data.tar.gz: dc61fed9e79d550408c4326febc6585543efee86e4693cdb61b060038797eaba
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 28e03c9e26527d26ddca4d68eab26ea0f4566bfcb05c14f627bac374eccf2d91949f9d502bbd732661200b0876e8308ad88e62406d4e38fcc5f31f1aa3343f82
|
|
7
|
+
data.tar.gz: 15dafb7956ab385e32b9825698b01cf6c84cca2f5f7e116b306a01d5b9b52ac9a554af07d9f256be6a65c62a0c27c36c0067ce98f6c2525c348c846019d142ca
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.6](https://github.com/seuros/lease_machines/compare/lease_machines/v0.1.5...lease_machines/v0.1.6) (2026-11-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* share lease renew/release checks in authority ([3c44626](https://github.com/seuros/lease_machines/commit/3c446267e004228b03df335e577f35b19b0c645f))
|
|
9
|
+
|
|
10
|
+
## [0.1.5](https://github.com/seuros/lease_machines/compare/lease_machines/v0.1.4...lease_machines/v0.1.5) (2026-11-06)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* rebuild authorities from storage and model the holder side ([5fe2199](https://github.com/seuros/lease_machines/commit/5fe2199af3efca8666f5015511649c802d95b77f))
|
|
16
|
+
|
|
17
|
+
## [0.1.4](https://github.com/seuros/lease_machines/compare/lease_machines/v0.1.3...lease_machines/v0.1.4) (2026-11-06)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* crates.io keywords and categories for the core ([c4f147f](https://github.com/seuros/lease_machines/commit/c4f147f9cb2d69bac209d2d1c50e851f5137ff80))
|
|
23
|
+
|
|
24
|
+
## [0.1.3](https://github.com/seuros/lease_machines/compare/lease_machines/v0.1.2...lease_machines/v0.1.3) (2026-11-06)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* keep test sources out of the packaged gem and crate ([6f0b979](https://github.com/seuros/lease_machines/commit/6f0b979351f1fe456d7ae922203f1ec69274d70d))
|
|
30
|
+
|
|
31
|
+
## [0.1.2](https://github.com/seuros/lease_machines/compare/lease_machines/v0.1.1...lease_machines/v0.1.2) (2026-11-06)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Bug Fixes
|
|
35
|
+
|
|
36
|
+
* cross builds no longer compile for the host ([e7571a9](https://github.com/seuros/lease_machines/commit/e7571a988886534052055c05b31d97a686aaeca7))
|
|
37
|
+
|
|
38
|
+
## [0.1.1](https://github.com/seuros/lease_machines/compare/lease_machines-v0.1.0...lease_machines/v0.1.1) (2026-11-06)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
### Features
|
|
42
|
+
|
|
43
|
+
* initial extraction ([243e8f6](https://github.com/seuros/lease_machines/commit/243e8f642f63cc2dd61a61524de0f45cd858ab96))
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 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,236 @@
|
|
|
1
|
+
# LeaseMachines
|
|
2
|
+
|
|
3
|
+
> Expiry tells the next worker it may start. The fence tells the last one it must stop. 🔑
|
|
4
|
+
|
|
5
|
+
Time-limited ownership for Ruby. One `Authority` decides who holds a resource and until when. Every grant comes with a token that only ever goes up. A `Fence` at the resource turns away any token older than the last one it saw. That's how a worker that fell asleep holding the key finds out, on waking, that it's no longer the captain.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem 'lease_machines'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
authority = LeaseMachines::Authority.new
|
|
15
|
+
|
|
16
|
+
grant = authority.acquire(owner: worker.id, now: clock.now, ttl: 30.0)
|
|
17
|
+
grant.status # => :granted
|
|
18
|
+
grant.lease.token # => 1
|
|
19
|
+
|
|
20
|
+
# ... do the work, renewing before the ttl runs out ...
|
|
21
|
+
authority.renew(owner: worker.id, token: grant.lease.token, now: clock.now, ttl: 30.0)
|
|
22
|
+
|
|
23
|
+
# ... and hand it back when done.
|
|
24
|
+
authority.release(owner: worker.id, token: grant.lease.token, now: clock.now)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
At the resource, keep a fence and show it every token before you touch anything:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
fence = LeaseMachines::Fence.new
|
|
31
|
+
|
|
32
|
+
fence.accept(1) # => true, first key seen
|
|
33
|
+
fence.accept(2) # => true, newer key
|
|
34
|
+
fence.accept(1) # => false, that captain is retired
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## A Message to the Resistance
|
|
38
|
+
|
|
39
|
+
So you put a lock in Redis. `SET nightly_report_lock worker-7 NX EX 30`. One worker runs the report, the others back off, everyone goes home.
|
|
40
|
+
|
|
41
|
+
Then worker 7 hits a stop-the-world GC pause for forty seconds. The lock expires. Worker 12 takes it and starts writing the report. Worker 7 wakes up, has no idea anything happened, and finishes writing *its* report over the top of 12's.
|
|
42
|
+
|
|
43
|
+
The lock did its job. It expired on time. The problem is that expiry only tells the *next* owner it can start. It never tells the *old* owner it has to stop, because the old owner isn't listening. It's busy.
|
|
44
|
+
|
|
45
|
+
The only thing that works is a number. Every grant gets a bigger number than the last one, and the thing being protected refuses any number smaller than the biggest it has seen. Worker 7 shows up with token 1. The report store has already seen token 2. Door stays shut.
|
|
46
|
+
|
|
47
|
+
This gem is that number, and the authority that hands it out.
|
|
48
|
+
|
|
49
|
+
## Chapter 1: Stardate 2025.186, the AMS Cheriton
|
|
50
|
+
|
|
51
|
+
> **AMS Cheriton**, NCC-4211, *Cheriton*-class authority tender.
|
|
52
|
+
> An old captain's key must not open the engine room after a new command takes over.
|
|
53
|
+
|
|
54
|
+
The tender doesn't guard the engine room. It issues keys, each stamped with a higher serial than the last, each valid until a stated time. The engine room hatch keeps its own record of the highest serial it has ever accepted. A captain who went out of radio range and came back still holding serial 1 finds a hatch that has already seen serial 2.
|
|
55
|
+
|
|
56
|
+
Somewhere in the fleet, an ensign proposes:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
def run_nightly_report
|
|
60
|
+
return unless redis.set("lock", id, nx: true, ex: 30)
|
|
61
|
+
generate_report # takes "about 20 seconds", usually
|
|
62
|
+
redis.del("lock")
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
"That," says the quartermaster, "is how two captains end up writing the same log."
|
|
67
|
+
|
|
68
|
+
## The Flight Plan
|
|
69
|
+
|
|
70
|
+
```mermaid
|
|
71
|
+
stateDiagram-v2
|
|
72
|
+
[*] --> vacant
|
|
73
|
+
vacant --> held: acquire, granted (token n)
|
|
74
|
+
held --> held: renew by owner with current token
|
|
75
|
+
held --> vacant: release by owner with current token
|
|
76
|
+
held --> vacant: acquire by anyone after expiry (token n+1)
|
|
77
|
+
held --> held: acquire by another owner before expiry, occupied
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
And the night worker 7 fell asleep:
|
|
81
|
+
|
|
82
|
+
```mermaid
|
|
83
|
+
sequenceDiagram
|
|
84
|
+
participant W7 as Worker 7
|
|
85
|
+
participant A as Authority
|
|
86
|
+
participant W12 as Worker 12
|
|
87
|
+
participant F as Fence at report store
|
|
88
|
+
|
|
89
|
+
W7->>A: acquire(owner 7, ttl 30)
|
|
90
|
+
A-->>W7: granted, token 1
|
|
91
|
+
W7->>F: accept(1)
|
|
92
|
+
F-->>W7: true
|
|
93
|
+
Note over W7: starts writing... then stalls for 40s
|
|
94
|
+
|
|
95
|
+
W12->>A: acquire(owner 12) at t=35
|
|
96
|
+
A-->>W12: granted, token 2 (lease 1 expired)
|
|
97
|
+
W12->>F: accept(2)
|
|
98
|
+
F-->>W12: true
|
|
99
|
+
Note over W12: writes the report
|
|
100
|
+
|
|
101
|
+
Note over W7: wakes up, still holding token 1
|
|
102
|
+
W7->>F: accept(1)
|
|
103
|
+
F-->>W7: false
|
|
104
|
+
Note over F: highest seen is 2. Door stays shut.
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## How Authority Works
|
|
108
|
+
|
|
109
|
+
### Acquire
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
authority = LeaseMachines::Authority.new
|
|
113
|
+
|
|
114
|
+
first = authority.acquire(owner: 1, now: 0.0, ttl: 2.0)
|
|
115
|
+
first.status # => :granted
|
|
116
|
+
first.lease.owner # => 1
|
|
117
|
+
first.lease.token # => 1
|
|
118
|
+
first.lease.expires_at # => 2.0
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Every call returns a `Decision` with `status`, `lease`, and `reason`. A grant has a lease and no reason. A rejection has a reason and no lease.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
authority.acquire(owner: 2, now: 1.0, ttl: 2.0).reason # => :occupied
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Owner 2 asked at second 1. Owner 1's lease runs to second 2. Occupied. Ask again after it lapses and the answer changes:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
later = authority.acquire(owner: 2, now: 2.0, ttl: 2.0)
|
|
131
|
+
later.status # => :granted
|
|
132
|
+
later.lease.token # => 2
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Token 2. Always bigger. Never reused.
|
|
136
|
+
|
|
137
|
+
### Renew
|
|
138
|
+
|
|
139
|
+
The holder extends the lease by presenting its token. The new expiry is counted from `now`, not from the old expiry.
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
authority = LeaseMachines::Authority.new
|
|
143
|
+
grant = authority.acquire(owner: 1, now: 0.0, ttl: 2.0)
|
|
144
|
+
|
|
145
|
+
renewed = authority.renew(owner: 1, token: 1, now: 1.0, ttl: 3.0)
|
|
146
|
+
renewed.status # => :renewed
|
|
147
|
+
renewed.lease.expires_at # => 4.0
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Renew with a token the authority didn't issue, or with the wrong owner, and you get a reason instead:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
authority.renew(owner: 1, token: 2, now: 1.0, ttl: 1.0).reason # => :stale_token
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Release
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
authority.release(owner: 1, token: 1, now: 2.0).status # => :released
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Release after the lease already lapsed and the authority tells you so:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
authority = LeaseMachines::Authority.new
|
|
166
|
+
authority.acquire(owner: 1, now: 0.0, ttl: 1.0)
|
|
167
|
+
authority.release(owner: 1, token: 1, now: 3.0).reason # => :expired
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Time only moves forward
|
|
171
|
+
|
|
172
|
+
You supply `now`. The authority remembers the latest `now` it has seen and refuses to go backwards.
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
authority = LeaseMachines::Authority.new
|
|
176
|
+
authority.acquire(owner: 1, now: 2.0, ttl: 1.0)
|
|
177
|
+
authority.acquire(owner: 2, now: 1.0, ttl: 1.0).reason # => :clock_regressed
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Feed it a monotonic clock, not the wall clock. A `ttl` of zero is `:invalid_ttl`.
|
|
181
|
+
|
|
182
|
+
## The Fence
|
|
183
|
+
|
|
184
|
+
The authority decides who *should* have the resource. The fence is what the resource actually checks. Keep one next to the thing you're protecting and call `accept` with the token before every mutation. `highest` tells you the high-water mark it's enforcing.
|
|
185
|
+
|
|
186
|
+
Restarted the resource and remembered that mark? Start the fence from there:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
fence = LeaseMachines::Fence.new(highest: 2)
|
|
190
|
+
fence.accept(2) # => true, equal is fine
|
|
191
|
+
fence.accept(1) # => false
|
|
192
|
+
fence.highest # => 2
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`accept` says nothing about expiry or authenticity. It only answers one question: have I seen a newer key than this one? That question is the whole point.
|
|
196
|
+
|
|
197
|
+
## Checkpoints
|
|
198
|
+
|
|
199
|
+
An authority can hand you a `Checkpoint` and later be rebuilt from it. The rebuilt authority remembers the highest token it ever issued, so the serial numbers keep climbing across the restart.
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
authority = LeaseMachines::Authority.new
|
|
203
|
+
authority.acquire(owner: 1, now: 0.0, ttl: 1.0)
|
|
204
|
+
|
|
205
|
+
checkpoint = authority.checkpoint
|
|
206
|
+
checkpoint.last_issued # => 1
|
|
207
|
+
|
|
208
|
+
restored = LeaseMachines::Authority.restore(checkpoint)
|
|
209
|
+
restored.acquire(owner: 2, now: 1.0, ttl: 1.0).lease.token # => 2
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Stop the old authority before you start the one you restored. Two authorities for one resource is two captains.
|
|
213
|
+
|
|
214
|
+
## The Rules of the Tender
|
|
215
|
+
|
|
216
|
+
- **Owners and tokens are Integers** in `0..2**64-1`. Tokens start at 1. Floats raise `TypeError`, out-of-range raises `RangeError`.
|
|
217
|
+
- **Times are Floats** in seconds from a monotonic origin you choose. Negative, infinite, or NaN raise `ArgumentError`.
|
|
218
|
+
- **Rejections don't raise.** Wrong owner, stale token, expired lease, clock regression, bad ttl: all come back as a `Decision` with a `reason`. Only malformed input raises.
|
|
219
|
+
- **Frozen means frozen.** `freeze` an authority or a fence and every mutation raises `FrozenError`.
|
|
220
|
+
|
|
221
|
+
## What You Think You're Doing vs Reality
|
|
222
|
+
|
|
223
|
+
### You Think: "The lock has a TTL, so stale holders can't hurt anyone."
|
|
224
|
+
### Reality: A stale holder doesn't know it's stale.
|
|
225
|
+
|
|
226
|
+
Expiry frees the resource for the next owner. It does nothing to the previous one, who may be mid-write, paused, or partitioned, and will finish whatever it was doing the moment it can. The fence is what stops that write. Without it the TTL is a courtesy, not a guarantee.
|
|
227
|
+
|
|
228
|
+
## What's Yours
|
|
229
|
+
|
|
230
|
+
The authority is a decision engine, not a server. Run one per resource, make sure its decisions are written down before anyone acts on them, and feed it a clock that never runs backwards. Put the fence as close to the protected data as you can get it, ideally in the same transaction as the write.
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MIT License. See [LICENSE.txt](LICENSE.txt) for details.
|
|
235
|
+
|
|
236
|
+
**Remember: Worker 7 is still out there, still holding token 1, still sure it's the captain.**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.3.1](https://github.com/seuros/lease_machines/compare/lease-machines-v0.3.0...lease-machines-v0.3.1) (2026-11-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* share lease renew/release checks in authority ([3c44626](https://github.com/seuros/lease_machines/commit/3c446267e004228b03df335e577f35b19b0c645f))
|
|
9
|
+
|
|
10
|
+
## [0.3.0](https://github.com/seuros/lease_machines/compare/lease-machines-v0.2.2...lease-machines-v0.3.0) (2026-11-06)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* rebuild authorities from storage and model the holder side ([5fe2199](https://github.com/seuros/lease_machines/commit/5fe2199af3efca8666f5015511649c802d95b77f))
|
|
16
|
+
|
|
17
|
+
## [0.2.2](https://github.com/seuros/lease_machines/compare/lease-machines-v0.2.1...lease-machines-v0.2.2) (2026-11-06)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* crates.io keywords and categories for the core ([c4f147f](https://github.com/seuros/lease_machines/commit/c4f147f9cb2d69bac209d2d1c50e851f5137ff80))
|
|
23
|
+
|
|
24
|
+
## [0.2.1](https://github.com/seuros/lease_machines/compare/lease-machines-v0.2.0...lease-machines-v0.2.1) (2026-11-06)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* keep test sources out of the packaged gem and crate ([6f0b979](https://github.com/seuros/lease_machines/commit/6f0b979351f1fe456d7ae922203f1ec69274d70d))
|
|
30
|
+
|
|
31
|
+
## [0.2.0](https://github.com/seuros/lease_machines/compare/lease-machines-v0.1.0...lease-machines-v0.2.0) (2026-11-06)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Features
|
|
35
|
+
|
|
36
|
+
* initial extraction ([243e8f6](https://github.com/seuros/lease_machines/commit/243e8f642f63cc2dd61a61524de0f45cd858ab96))
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# lease-machines
|
|
2
|
+
|
|
3
|
+
> **AMS Cheriton**, NCC-4211, *Cheriton*-class authority tender.
|
|
4
|
+
|
|
5
|
+
Engine-room flight manual: model expiring command keys and resource-side fencing.
|
|
6
|
+
|
|
7
|
+
A deterministic **single-authority decision model**, not a distributed lock,
|
|
8
|
+
failure detector, consensus protocol, or storage backend.
|
|
9
|
+
|
|
10
|
+
## Lease Lifecycle
|
|
11
|
+
|
|
12
|
+
```rust
|
|
13
|
+
use core::time::Duration;
|
|
14
|
+
use lease_machines::{Decision, LeaseAuthority, OwnerId, Rejection};
|
|
15
|
+
|
|
16
|
+
let mut authority = LeaseAuthority::new();
|
|
17
|
+
let first = authority.acquire(
|
|
18
|
+
OwnerId::new(1), Duration::ZERO, Duration::from_secs(5),
|
|
19
|
+
);
|
|
20
|
+
let Decision::Granted(lease) = first else {
|
|
21
|
+
panic!("a fresh authority should grant");
|
|
22
|
+
};
|
|
23
|
+
assert_eq!(
|
|
24
|
+
authority.acquire(OwnerId::new(2), Duration::from_secs(1), Duration::from_secs(5)),
|
|
25
|
+
Decision::Rejected(Rejection::Occupied),
|
|
26
|
+
);
|
|
27
|
+
assert_eq!(
|
|
28
|
+
authority.release(OwnerId::new(1), lease.token(), Duration::from_secs(2)),
|
|
29
|
+
Decision::Released,
|
|
30
|
+
);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- Each authority instance models **one resource and one token namespace**.
|
|
34
|
+
- Every acquisition, even by the same owner after release/expiry, gets a strictly
|
|
35
|
+
greater token. Renewal retains the token and never shortens its expiration.
|
|
36
|
+
- A lease expires at `now >= expires_at`. Renewal cannot resurrect an expired
|
|
37
|
+
lease. Release and renewal require both the current owner and current token.
|
|
38
|
+
- Supply durations from one monotonic origin. Regressing observations are rejected.
|
|
39
|
+
Non-regressing observations advance the clock even when the request is rejected.
|
|
40
|
+
- Zero TTL and deadline overflow are rejected. Exhausted token space fails closed;
|
|
41
|
+
tokens never wrap.
|
|
42
|
+
- `reacquire` is `acquire` plus one exception: the current owner may rotate its own
|
|
43
|
+
unexpired grant to a new token. The old token is stale from then on, so anything
|
|
44
|
+
still holding it is fenced. Another owner's live grant is still `Occupied`.
|
|
45
|
+
|
|
46
|
+
## Rebuilding an Authority from Storage
|
|
47
|
+
|
|
48
|
+
```rust
|
|
49
|
+
use core::time::Duration;
|
|
50
|
+
use lease_machines::{Checkpoint, Decision, FencingToken, Lease, LeaseAuthority, OwnerId};
|
|
51
|
+
|
|
52
|
+
# fn example() -> Option<()> {
|
|
53
|
+
// One row per resource: owner id, token, expires_at (ms), token high-water.
|
|
54
|
+
let (owner, token, expires_at_ms, last_issued) = (7_u64, 3_u64, 9_000_u64, 3_u64);
|
|
55
|
+
let lease = Lease::new(
|
|
56
|
+
OwnerId::new(owner), FencingToken::new(token)?, Duration::from_millis(expires_at_ms),
|
|
57
|
+
);
|
|
58
|
+
let committed = Checkpoint::from_parts(FencingToken::new(last_issued), Some(lease), Duration::ZERO)?;
|
|
59
|
+
let mut authority = LeaseAuthority::restore(committed);
|
|
60
|
+
let Decision::Granted(next) = authority.acquire(
|
|
61
|
+
OwnerId::new(8), Duration::from_millis(9_000), Duration::from_secs(5),
|
|
62
|
+
) else {
|
|
63
|
+
panic!("the previous grant expired");
|
|
64
|
+
};
|
|
65
|
+
assert_eq!(next.token().value(), 4);
|
|
66
|
+
# Some(())
|
|
67
|
+
# }
|
|
68
|
+
# assert_eq!(example(), Some(()));
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `OwnerId` and `FencingToken` are plain `u64` on the wire: `new()` in, `value()` out.
|
|
72
|
+
String owners hash to a `u64` on the caller's side; the authority only compares.
|
|
73
|
+
- `from_parts` refuses a lease whose token the history never issued. Keep the
|
|
74
|
+
high-water column even after release; it is what makes the next token greater.
|
|
75
|
+
- `observed` is the clock watermark. Pass `Duration::ZERO` when storage keeps none,
|
|
76
|
+
and the authority trusts whatever `now` the next request brings.
|
|
77
|
+
|
|
78
|
+
## Clocks
|
|
79
|
+
|
|
80
|
+
Time is a `Duration` from an origin the caller picks: a monotonic start, or the Unix
|
|
81
|
+
epoch when the clock is a database's wall clock in milliseconds. One origin per
|
|
82
|
+
authority history.
|
|
83
|
+
|
|
84
|
+
By default an observation earlier than the last one is `Rejection::ClockRegressed`
|
|
85
|
+
and nothing changes. `with_clock_policy(ClockPolicy::Clamp)` evaluates such a request
|
|
86
|
+
at the last observation instead: expiry checks and new deadlines use the later time,
|
|
87
|
+
so a backwards step can never revive a lease or shorten one.
|
|
88
|
+
|
|
89
|
+
## Fencing at the Protected Resource
|
|
90
|
+
|
|
91
|
+
```rust
|
|
92
|
+
use lease_machines::{Fence, FencingToken};
|
|
93
|
+
|
|
94
|
+
# fn example() -> Option<()> {
|
|
95
|
+
let old = FencingToken::new(7)?;
|
|
96
|
+
let current = FencingToken::new(8)?;
|
|
97
|
+
let mut fence = Fence::new(Some(old));
|
|
98
|
+
assert!(fence.accept(current));
|
|
99
|
+
assert!(!fence.accept(old));
|
|
100
|
+
assert!(fence.accept(current)); // Multiple operations in one lease are allowed.
|
|
101
|
+
# Some(())
|
|
102
|
+
# }
|
|
103
|
+
# assert_eq!(example(), Some(()));
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Fencing rejects tokens lower than the highest token the resource has accepted.
|
|
107
|
+
Equal tokens remain valid for repeated operations in the same lease.
|
|
108
|
+
It does **not** prove current lease validity, authorize an owner, authenticate a
|
|
109
|
+
token, or deduplicate operations. A resource cannot reject a newly stale owner on
|
|
110
|
+
fencing grounds until it has observed a greater token. If immediate revocation is
|
|
111
|
+
required, add an authority/expiry check under an appropriate protocol.
|
|
112
|
+
|
|
113
|
+
## Holding a Lease
|
|
114
|
+
|
|
115
|
+
```rust
|
|
116
|
+
use core::time::Duration;
|
|
117
|
+
use lease_machines::{Cadence, FencingToken, Holder, HolderState};
|
|
118
|
+
|
|
119
|
+
# fn example() -> Option<()> {
|
|
120
|
+
let secs = Duration::from_secs;
|
|
121
|
+
let cadence = Cadence::new(secs(30), secs(10), secs(5))?;
|
|
122
|
+
let mut holder = Holder::new(FencingToken::new(1)?, secs(0), cadence);
|
|
123
|
+
assert!(!holder.renewal_due(secs(9)));
|
|
124
|
+
assert!(holder.renewal_due(secs(10)));
|
|
125
|
+
assert!(holder.refresh(secs(10)));
|
|
126
|
+
assert_eq!(holder.state(), HolderState::Unconfirmed);
|
|
127
|
+
assert!(holder.confirm(FencingToken::new(1)?, secs(11)));
|
|
128
|
+
assert_eq!(holder.state(), HolderState::Confirmed);
|
|
129
|
+
assert!(holder.at_risk(secs(36)));
|
|
130
|
+
assert!(holder.expire());
|
|
131
|
+
assert!(holder.reacquire(FencingToken::new(2)?, secs(40)));
|
|
132
|
+
holder.fence();
|
|
133
|
+
assert_eq!(holder.warning_deadline(), None);
|
|
134
|
+
# Some(())
|
|
135
|
+
# }
|
|
136
|
+
# assert_eq!(example(), Some(()));
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The holder is the other half of the conversation: it decides when to renew, how
|
|
140
|
+
nervous to be, and when to stop writing. It runs on the holder's own clock and
|
|
141
|
+
never compares instants with the authority.
|
|
142
|
+
|
|
143
|
+
- `Confirmed` -> `refresh` -> `Unconfirmed`, or `AtRisk` once past the warning
|
|
144
|
+
deadline (`ttl - warn_before` after the last confirmation).
|
|
145
|
+
- `confirm(token, at)` closes an attempt and reschedules. A response with an older
|
|
146
|
+
token than the current one is a stale reply and is ignored; the same or a newer
|
|
147
|
+
token is accepted.
|
|
148
|
+
- `expire` means the authority said so; only `reacquire` with a strictly newer token
|
|
149
|
+
recovers. `fence` is terminal and idempotent; every predicate answers accordingly.
|
|
150
|
+
- `renewal_due` is true whenever a renewal is outstanding, so a failed attempt
|
|
151
|
+
retries on the next poll without any extra state.
|
|
152
|
+
|
|
153
|
+
## Required Runtime and Persistence Contract
|
|
154
|
+
|
|
155
|
+
1. There must be one serialized authoritative history per resource. A local
|
|
156
|
+
mutable instance is not enough to arbitrate across partitioned servers.
|
|
157
|
+
2. Commit authority state, including token high-water mark, active lease, and
|
|
158
|
+
releases/renewals, **before exposing successful decisions**. On uncertain
|
|
159
|
+
persistence, stop serving and recover; do not continue from ambiguous state.
|
|
160
|
+
3. Resource adapters must authenticate issued tokens and atomically persist
|
|
161
|
+
their fencing high-water mark **with the protected mutation**. A separate
|
|
162
|
+
`accept()` call followed by an unguarded write is not safe under concurrency.
|
|
163
|
+
4. Restore only the newest committed authority checkpoint, with the same clock
|
|
164
|
+
origin. Never roll back token state or start `new()` over an existing namespace.
|
|
165
|
+
A reboot or clock-origin change requires an external recovery protocol;
|
|
166
|
+
replaying old monotonic durations is not sufficient.
|
|
167
|
+
5. Fence state must also survive resource restarts. Token comparisons are only
|
|
168
|
+
meaningful within the same resource namespace.
|
|
169
|
+
|
|
170
|
+
`checkpoint()` / `restore()` provide an opaque typed state transfer for a host
|
|
171
|
+
or deterministic simulation. There is no wire format, storage adapter, distributed
|
|
172
|
+
replication, or crash-safe persistence implementation in this initial version.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative 'lease_machines/version'
|
|
3
|
+
require 'lease_machines_native/lease_machines_native'
|
|
4
|
+
|
|
5
|
+
module LeaseMachines
|
|
6
|
+
Decision = Data.define(:status, :lease, :reason)
|
|
7
|
+
|
|
8
|
+
class Authority
|
|
9
|
+
def acquire(owner:, now:, ttl:)
|
|
10
|
+
Decision.new(*_acquire(owner, now, ttl))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def renew(owner:, token:, now:, ttl:)
|
|
14
|
+
Decision.new(*_renew(owner, token, now, ttl))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def release(owner:, token:, now:)
|
|
18
|
+
Decision.new(*_release(owner, token, now))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private :_acquire, :_renew, :_release
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class Fence
|
|
25
|
+
def self.new(highest: nil) = _new(highest)
|
|
26
|
+
private_class_method :_new
|
|
27
|
+
end
|
|
28
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module LeaseMachines
|
|
2
|
+
VERSION: String
|
|
3
|
+
class Lease
|
|
4
|
+
def owner: () -> Integer
|
|
5
|
+
def token: () -> Integer
|
|
6
|
+
def expires_at: () -> Float
|
|
7
|
+
end
|
|
8
|
+
class Checkpoint
|
|
9
|
+
def last_issued: () -> Integer?
|
|
10
|
+
def observed: () -> Float
|
|
11
|
+
end
|
|
12
|
+
class Decision < Data
|
|
13
|
+
attr_reader status: Symbol
|
|
14
|
+
attr_reader lease: Lease?
|
|
15
|
+
attr_reader reason: Symbol?
|
|
16
|
+
end
|
|
17
|
+
class Authority
|
|
18
|
+
def self.new: () -> instance
|
|
19
|
+
def self.restore: (Checkpoint) -> instance
|
|
20
|
+
def acquire: (owner: Integer, now: Float, ttl: Float) -> Decision
|
|
21
|
+
def renew: (owner: Integer, token: Integer, now: Float, ttl: Float) -> Decision
|
|
22
|
+
def release: (owner: Integer, token: Integer, now: Float) -> Decision
|
|
23
|
+
def checkpoint: () -> Checkpoint
|
|
24
|
+
end
|
|
25
|
+
class Fence
|
|
26
|
+
def self.new: (?highest: Integer?) -> instance
|
|
27
|
+
def accept: (Integer) -> bool
|
|
28
|
+
def highest: () -> Integer?
|
|
29
|
+
end
|
|
30
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lease_machines
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.6
|
|
5
|
+
platform: arm64-darwin
|
|
6
|
+
authors:
|
|
7
|
+
- Abdelkader Boudih
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-06 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Thin Ruby bindings to the standalone lease-machines core; durability,
|
|
14
|
+
clocks, and resource enforcement belong to the caller.
|
|
15
|
+
email:
|
|
16
|
+
- oss@seuros.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE.txt
|
|
23
|
+
- README.md
|
|
24
|
+
- ext/lease_machines_native/core/CHANGELOG.md
|
|
25
|
+
- ext/lease_machines_native/core/README.md
|
|
26
|
+
- ext/lease_machines_native/extconf.rb
|
|
27
|
+
- lib/lease_machines.rb
|
|
28
|
+
- lib/lease_machines/version.rb
|
|
29
|
+
- lib/lease_machines_native/lease_machines_native.bundle
|
|
30
|
+
- sig/lease_machines.rbs
|
|
31
|
+
homepage: https://github.com/seuros/lease_machines
|
|
32
|
+
licenses:
|
|
33
|
+
- MIT
|
|
34
|
+
metadata:
|
|
35
|
+
source_code_uri: https://github.com/seuros/lease_machines
|
|
36
|
+
rubygems_mfa_required: 'true'
|
|
37
|
+
cargo_crate_name: lease_machines_native
|
|
38
|
+
cargo_manifest_path: ext/lease_machines_native/ffi/Cargo.toml
|
|
39
|
+
post_install_message:
|
|
40
|
+
rdoc_options: []
|
|
41
|
+
require_paths:
|
|
42
|
+
- lib
|
|
43
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '4.0'
|
|
48
|
+
- - "<"
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: 4.1.dev
|
|
51
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '0'
|
|
56
|
+
requirements: []
|
|
57
|
+
rubygems_version: 3.5.23
|
|
58
|
+
signing_key:
|
|
59
|
+
specification_version: 4
|
|
60
|
+
summary: Native lease lifecycle and fencing decisions for Ruby
|
|
61
|
+
test_files: []
|