ticketing 0.0.2
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/.gitignore +17 -0
- data/.idea/.gitignore +10 -0
- data/.idea/modules.xml +8 -0
- data/.idea/ticketing-ruby.iml +18 -0
- data/.idea/vcs.xml +6 -0
- data/.ruby-version +1 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/PUBLISH.md +37 -0
- data/README.md +1 -0
- data/lib/ticketing/broker.rb +214 -0
- data/lib/ticketing/connection.rb +256 -0
- data/lib/ticketing/errors.rb +64 -0
- data/lib/ticketing/protocol.rb +177 -0
- data/lib/ticketing/version.rb +5 -0
- data/lib/ticketing.rb +11 -0
- data/ticketing.gemspec +35 -0
- metadata +89 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d7aaf975c3d88996a5ce4214840a98e1d70b3b992a5a29c529553aa2477658c7
|
|
4
|
+
data.tar.gz: 227933f5143a9bd7303bd28316e5ca2496b61d4dd4bb8f1b2f82da19c561cea0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 714750e33051582fb9df8920f5ca2080a20dee8891235dbd45a560964c172d21fd1832ca4d56a943d0ed47b2190d07cc62d07c047ea4722d4b0ece56edacbd27
|
|
7
|
+
data.tar.gz: e052e39de9d7417b04b7acc578a6d9bc815ddf460102d8a4e2089248c5629b52cdbf887a9604d133ce9aa3014c021c6f82a98cc2d9820d650d99664a22a555ee
|
data/.gitignore
ADDED
data/.idea/.gitignore
ADDED
data/.idea/modules.xml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="ProjectModuleManager">
|
|
4
|
+
<modules>
|
|
5
|
+
<module fileurl="file://$PROJECT_DIR$/.idea/ticketing-ruby.iml" filepath="$PROJECT_DIR$/.idea/ticketing-ruby.iml" />
|
|
6
|
+
</modules>
|
|
7
|
+
</component>
|
|
8
|
+
</project>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<module type="RUBY_MODULE" version="4">
|
|
3
|
+
<component name="ModuleRunConfigurationManager">
|
|
4
|
+
<shared />
|
|
5
|
+
</component>
|
|
6
|
+
<component name="NewModuleRootManager">
|
|
7
|
+
<content url="file://$MODULE_DIR$">
|
|
8
|
+
<sourceFolder url="file://$MODULE_DIR$/features" isTestSource="true" />
|
|
9
|
+
<sourceFolder url="file://$MODULE_DIR$/spec" isTestSource="true" />
|
|
10
|
+
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
|
|
11
|
+
</content>
|
|
12
|
+
<orderEntry type="jdk" jdkName="rbenv: 4.0.5" jdkType="RUBY_SDK" />
|
|
13
|
+
<orderEntry type="sourceFolder" forTests="false" />
|
|
14
|
+
<orderEntry type="library" scope="PROVIDED" name="bundler (v4.0.12, rbenv: 4.0.5) [gem]" level="application" />
|
|
15
|
+
<orderEntry type="library" scope="PROVIDED" name="logger (v1.7.0, rbenv: 4.0.5) [gem]" level="application" />
|
|
16
|
+
<orderEntry type="library" scope="PROVIDED" name="minitest (v5.27.0, rbenv: 4.0.5) [gem]" level="application" />
|
|
17
|
+
</component>
|
|
18
|
+
</module>
|
data/.idea/vcs.xml
ADDED
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.5
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 SARO Lab
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/PUBLISH.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
## sync
|
|
2
|
+
```shell
|
|
3
|
+
rm -rf Gemfile.lock
|
|
4
|
+
bundle install
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
## publish
|
|
8
|
+
```
|
|
9
|
+
gem build ticketing.gemspec
|
|
10
|
+
gem signin
|
|
11
|
+
gem push ticketing-0.0.2.gem
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## install
|
|
15
|
+
```
|
|
16
|
+
# install
|
|
17
|
+
brew install rbenv ruby-build
|
|
18
|
+
|
|
19
|
+
# mac -
|
|
20
|
+
echo 'eval "$(rbenv init -)"' >> ~/.zshrc
|
|
21
|
+
source ~/.zshrc
|
|
22
|
+
# - mac
|
|
23
|
+
|
|
24
|
+
rbenv install -l
|
|
25
|
+
rbenv install 4.0.5
|
|
26
|
+
rbenv local 4.0.5
|
|
27
|
+
rbenv rehash
|
|
28
|
+
|
|
29
|
+
ruby -v
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## test
|
|
33
|
+
```
|
|
34
|
+
bundle exec rake test
|
|
35
|
+
# or
|
|
36
|
+
ruby -Ilib -Itest test/test_protocol.rb
|
|
37
|
+
```
|
data/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
https://ticketing.saro.me/
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'protocol'
|
|
7
|
+
require_relative 'connection'
|
|
8
|
+
|
|
9
|
+
module Ticketing
|
|
10
|
+
# A shared handle to a network distributed-lock service.
|
|
11
|
+
#
|
|
12
|
+
# A broker opens one persistent TCP connection per address and load-balances
|
|
13
|
+
# requests across them round-robin. Connections are managed in the background:
|
|
14
|
+
# creation never blocks or fails, and a dropped connection is retried
|
|
15
|
+
# automatically without disturbing the others.
|
|
16
|
+
#
|
|
17
|
+
# Create it once and share it for the whole process — it is safe to use from
|
|
18
|
+
# many threads at once.
|
|
19
|
+
#
|
|
20
|
+
# broker = Ticketing::Broker.connect("127.0.0.1:5225", "127.0.0.1:5226")
|
|
21
|
+
# ticket = broker.acquire("order-42", wait: 5, lease: 30)
|
|
22
|
+
# # ... critical section, guarded by ticket.token at the resource ...
|
|
23
|
+
# ticket.release
|
|
24
|
+
class Broker
|
|
25
|
+
RELEASE_RETRY_WINDOW = 5.0
|
|
26
|
+
RELEASE_RETRY_INTERVAL = 0.2
|
|
27
|
+
|
|
28
|
+
# Builds a broker for the given server addresses (as +"host:port"+ strings,
|
|
29
|
+
# varargs or an array) and starts connecting in the background.
|
|
30
|
+
#
|
|
31
|
+
# One address means a single node; two or more means a cluster, and the
|
|
32
|
+
# broker fails over between them. A single address is internally held with
|
|
33
|
+
# two connections to the same node, so a transient drop on one socket does
|
|
34
|
+
# not interrupt service.
|
|
35
|
+
def self.connect(*addrs)
|
|
36
|
+
list = addrs.flatten.map(&:to_s)
|
|
37
|
+
raise ArgumentError, "at least one address is required" if list.empty?
|
|
38
|
+
|
|
39
|
+
list = [list[0], list[0]] if list.size == 1
|
|
40
|
+
logger = default_logger
|
|
41
|
+
conns = list.map { |a| Conn.new(a, logger) }
|
|
42
|
+
conns.each(&:start_connector)
|
|
43
|
+
new(conns, logger)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.default_logger
|
|
47
|
+
logger = Logger.new($stderr)
|
|
48
|
+
logger.level = Logger::WARN
|
|
49
|
+
logger
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(conns, logger)
|
|
53
|
+
@conns = conns
|
|
54
|
+
@logger = logger
|
|
55
|
+
@rr = 0
|
|
56
|
+
@rr_mutex = Mutex.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Waits up to +timeout+ seconds for at least one connection to come up.
|
|
60
|
+
# Returns +true+ if the broker is ready, +false+ if the timeout elapsed
|
|
61
|
+
# first. Best-effort convenience for startup; the broker works without it.
|
|
62
|
+
def wait_ready(timeout)
|
|
63
|
+
deadline = monotonic + timeout
|
|
64
|
+
loop do
|
|
65
|
+
return true if pick
|
|
66
|
+
return false if monotonic >= deadline
|
|
67
|
+
|
|
68
|
+
sleep 0.02
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Acquires the lock named +key+, waiting up to +wait+ seconds for it to
|
|
73
|
+
# become free.
|
|
74
|
+
#
|
|
75
|
+
# - +wait+: how long to wait for the lock. +0+ waits indefinitely. The wait
|
|
76
|
+
# is enforced by the server, so giving up never leaves a lock stranded.
|
|
77
|
+
# Sub-second values are rounded up to whole seconds.
|
|
78
|
+
# - +lease+: how long the server keeps the lock before auto-releasing it if
|
|
79
|
+
# this client dies without releasing (a safety net, not the expected hold
|
|
80
|
+
# time). Rounded up to whole seconds, minimum one second.
|
|
81
|
+
#
|
|
82
|
+
# On success returns a {Ticket} carrying a monotonic fencing token. Raises
|
|
83
|
+
# {TimeoutError} if +wait+ elapses first.
|
|
84
|
+
def acquire(key, wait:, lease:)
|
|
85
|
+
Protocol.validate_key(key)
|
|
86
|
+
frame = Protocol.acquire_frame(key, wait, lease)
|
|
87
|
+
[@conns.size, 1].max.times do
|
|
88
|
+
conn = pick or raise NotConnectedError
|
|
89
|
+
|
|
90
|
+
begin
|
|
91
|
+
reply = conn.request(Protocol::OP_ACQUIRE, key, frame)
|
|
92
|
+
case reply
|
|
93
|
+
when Reply::Acquired then return Ticket.new(self, conn, key, reply.token, @logger)
|
|
94
|
+
when Reply::TIMED_OUT then raise TimeoutError
|
|
95
|
+
else raise ProtocolError, "unexpected reply for acquire"
|
|
96
|
+
end
|
|
97
|
+
rescue IoError => e
|
|
98
|
+
raise e unless e.cause_error.is_a?(ConnectionClosed)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
raise NotConnectedError
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Cancels the background connections. The broker is unusable afterwards.
|
|
105
|
+
def close
|
|
106
|
+
@conns.each(&:stop)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Releases +key+ on the connection that granted it, retrying within
|
|
110
|
+
# {RELEASE_RETRY_WINDOW} seconds. Returns +true+ if the server still held
|
|
111
|
+
# the lock, +false+ if it had already expired. Intended for internal use by
|
|
112
|
+
# {Ticket}.
|
|
113
|
+
def release_lock(granted, key)
|
|
114
|
+
frame = Protocol.release_frame(key)
|
|
115
|
+
deadline = monotonic + RELEASE_RETRY_WINDOW
|
|
116
|
+
last_error = NotConnectedError.new
|
|
117
|
+
loop do
|
|
118
|
+
conn = granted.usable? ? granted : pick
|
|
119
|
+
if conn
|
|
120
|
+
begin
|
|
121
|
+
reply = conn.request(Protocol::OP_RELEASE, key, frame)
|
|
122
|
+
case reply
|
|
123
|
+
when Reply::RELEASED then return true
|
|
124
|
+
when Reply::NOT_FOUND then return false
|
|
125
|
+
else raise ProtocolError, "unexpected reply for release"
|
|
126
|
+
end
|
|
127
|
+
rescue IoError => e
|
|
128
|
+
raise e unless e.cause_error.is_a?(ConnectionClosed)
|
|
129
|
+
|
|
130
|
+
last_error = Ticketing.closed_error
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
raise last_error if monotonic >= deadline
|
|
134
|
+
|
|
135
|
+
sleep RELEASE_RETRY_INTERVAL
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def pick
|
|
142
|
+
n = @conns.size
|
|
143
|
+
return nil if n.zero?
|
|
144
|
+
|
|
145
|
+
start = @rr_mutex.synchronize do
|
|
146
|
+
s = @rr
|
|
147
|
+
@rr += 1
|
|
148
|
+
s
|
|
149
|
+
end
|
|
150
|
+
n.times do |i|
|
|
151
|
+
conn = @conns[(start + i) % n]
|
|
152
|
+
return conn if conn.usable?
|
|
153
|
+
end
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def monotonic
|
|
158
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# An acquired lock guard.
|
|
163
|
+
#
|
|
164
|
+
# The lock is released when {#release} is called, or in the background when
|
|
165
|
+
# the ticket is {#close}d. If the client dies before releasing, the server's
|
|
166
|
+
# lease eventually frees it.
|
|
167
|
+
class Ticket
|
|
168
|
+
# The locked key.
|
|
169
|
+
attr_reader :key
|
|
170
|
+
|
|
171
|
+
# The fencing token for this grant — a value that strictly increases with
|
|
172
|
+
# every acquisition of any key. Pass it to the resource you protect and
|
|
173
|
+
# reject stale (lower) tokens.
|
|
174
|
+
attr_reader :token
|
|
175
|
+
|
|
176
|
+
def initialize(broker, conn, key, token, logger)
|
|
177
|
+
@broker = broker
|
|
178
|
+
@conn = conn
|
|
179
|
+
@key = key
|
|
180
|
+
@token = token
|
|
181
|
+
@logger = logger
|
|
182
|
+
@released = false
|
|
183
|
+
@mutex = Mutex.new
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Releases the lock. Returns +true+ if the server still held it, +false+ if
|
|
187
|
+
# it had already expired.
|
|
188
|
+
def release
|
|
189
|
+
@mutex.synchronize { @released = true }
|
|
190
|
+
@broker.release_lock(@conn, @key)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Releases the lock in the background if it was not already released.
|
|
194
|
+
def close
|
|
195
|
+
should_release =
|
|
196
|
+
@mutex.synchronize do
|
|
197
|
+
next false if @released
|
|
198
|
+
|
|
199
|
+
@released = true
|
|
200
|
+
true
|
|
201
|
+
end
|
|
202
|
+
return unless should_release
|
|
203
|
+
|
|
204
|
+
Thread.new do
|
|
205
|
+
begin
|
|
206
|
+
@broker.release_lock(@conn, @key)
|
|
207
|
+
rescue StandardError
|
|
208
|
+
@logger.error("auto-release #{@key} failed — lease will expire it")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'logger'
|
|
5
|
+
|
|
6
|
+
require_relative 'errors'
|
|
7
|
+
require_relative 'protocol'
|
|
8
|
+
|
|
9
|
+
module Ticketing
|
|
10
|
+
# The outcome of a single request, matched back to its waiter by (op, key).
|
|
11
|
+
module Reply
|
|
12
|
+
TIMED_OUT = :timed_out
|
|
13
|
+
RELEASED = :released
|
|
14
|
+
NOT_FOUND = :not_found
|
|
15
|
+
Acquired = Struct.new(:token)
|
|
16
|
+
|
|
17
|
+
def self.acquired(token)
|
|
18
|
+
Acquired.new(token)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A one-shot channel a caller blocks on until its reply (or a failure)
|
|
23
|
+
# arrives from the reader thread.
|
|
24
|
+
class Waiter
|
|
25
|
+
def initialize
|
|
26
|
+
@queue = Queue.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def complete(reply)
|
|
30
|
+
@queue.push([:ok, reply])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fail(error)
|
|
34
|
+
@queue.push([:err, error])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def await
|
|
38
|
+
kind, value = @queue.pop
|
|
39
|
+
raise value if kind == :err
|
|
40
|
+
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One persistent TCP connection to a lock server, managed on a background
|
|
46
|
+
# thread. A dropped connection is retried automatically without disturbing
|
|
47
|
+
# others. Requests are pipelined: a reply is matched back to its waiter by
|
|
48
|
+
# (op, key).
|
|
49
|
+
class Conn
|
|
50
|
+
RECONNECT_INTERVAL = 3.0
|
|
51
|
+
|
|
52
|
+
attr_reader :addr
|
|
53
|
+
|
|
54
|
+
def initialize(addr, logger)
|
|
55
|
+
@addr = addr
|
|
56
|
+
@logger = logger
|
|
57
|
+
@host, @port = parse_addr(addr)
|
|
58
|
+
@status = :down
|
|
59
|
+
@writer = nil
|
|
60
|
+
@socket = nil
|
|
61
|
+
@running = true
|
|
62
|
+
@thread = nil
|
|
63
|
+
@writer_mutex = Mutex.new
|
|
64
|
+
@pending_mutex = Mutex.new
|
|
65
|
+
@acquire_waiters = {}
|
|
66
|
+
@release_waiters = {}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# True while at least one reply can currently be sent and received.
|
|
70
|
+
def usable?
|
|
71
|
+
@status == :up
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Enqueues a waiter under (op, key), writes the frame, then blocks until the
|
|
75
|
+
# reader thread resolves it. Raises {IoError} if the write fails or the
|
|
76
|
+
# connection is not up.
|
|
77
|
+
def request(op, key, frame)
|
|
78
|
+
waiter = Waiter.new
|
|
79
|
+
@writer_mutex.synchronize do
|
|
80
|
+
w = @writer or raise Ticketing.closed_error
|
|
81
|
+
|
|
82
|
+
@pending_mutex.synchronize do
|
|
83
|
+
(waiters(op)[key] ||= []).push(waiter)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
w.write(frame)
|
|
88
|
+
w.flush
|
|
89
|
+
rescue IOError, SystemCallError => e
|
|
90
|
+
@pending_mutex.synchronize do
|
|
91
|
+
queue = waiters(op)[key]
|
|
92
|
+
if queue
|
|
93
|
+
queue.pop
|
|
94
|
+
waiters(op).delete(key) if queue.empty?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
raise IoError.new(e)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
waiter.await
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Starts the background connect/read/reconnect loop.
|
|
104
|
+
def start_connector
|
|
105
|
+
@thread = Thread.new do
|
|
106
|
+
ever_connected = false
|
|
107
|
+
while @running
|
|
108
|
+
sock = connect_socket
|
|
109
|
+
next if sock.nil?
|
|
110
|
+
|
|
111
|
+
@socket = sock
|
|
112
|
+
@writer = sock
|
|
113
|
+
@status = :up
|
|
114
|
+
@logger.warn("reconnected: #{@addr}") if ever_connected
|
|
115
|
+
ever_connected = true
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
read_loop(sock)
|
|
119
|
+
rescue StandardError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
teardown(sock)
|
|
124
|
+
@logger.warn("connection lost: #{@addr} — reconnecting every #{RECONNECT_INTERVAL}s")
|
|
125
|
+
sleep RECONNECT_INTERVAL if @running
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Stops the background thread, closes the socket, and fails all waiters.
|
|
131
|
+
def stop
|
|
132
|
+
@running = false
|
|
133
|
+
@status = :down
|
|
134
|
+
thread = @thread
|
|
135
|
+
begin
|
|
136
|
+
@socket&.close
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
thread&.kill
|
|
141
|
+
clear_pending
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def waiters(op)
|
|
147
|
+
op == Protocol::OP_ACQUIRE ? @acquire_waiters : @release_waiters
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def connect_socket
|
|
151
|
+
sock = TCPSocket.new(@host, @port)
|
|
152
|
+
sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
153
|
+
sock
|
|
154
|
+
rescue StandardError => e
|
|
155
|
+
@logger.warn("connect failed: #{@addr} (#{e.message}) — retrying in #{RECONNECT_INTERVAL}s")
|
|
156
|
+
sleep RECONNECT_INTERVAL if @running
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def read_loop(sock)
|
|
161
|
+
loop do
|
|
162
|
+
frame =
|
|
163
|
+
begin
|
|
164
|
+
Protocol.read_frame(sock) { |op| Protocol.reply_fixed(op) }
|
|
165
|
+
rescue IOError, SystemCallError
|
|
166
|
+
return
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
if frame.equal?(Protocol::Frame::EOF)
|
|
170
|
+
return
|
|
171
|
+
elsif frame.equal?(Protocol::Frame::TOO_LONG)
|
|
172
|
+
next
|
|
173
|
+
else
|
|
174
|
+
bytes = frame.bytes
|
|
175
|
+
next if bytes.bytesize.zero?
|
|
176
|
+
|
|
177
|
+
incoming = Protocol.parse_reply(bytes)
|
|
178
|
+
if incoming.nil?
|
|
179
|
+
@logger.error("unrecognized reply from #{@addr}")
|
|
180
|
+
next
|
|
181
|
+
end
|
|
182
|
+
dispatch(incoming)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def dispatch(incoming)
|
|
188
|
+
case incoming
|
|
189
|
+
when Protocol::Incoming::Acquired
|
|
190
|
+
resolve(Protocol::OP_ACQUIRE, incoming.key, Reply.acquired(incoming.token))
|
|
191
|
+
when Protocol::Incoming::TimedOut
|
|
192
|
+
resolve(Protocol::OP_ACQUIRE, incoming.key, Reply::TIMED_OUT)
|
|
193
|
+
when Protocol::Incoming::Released
|
|
194
|
+
resolve(Protocol::OP_RELEASE, incoming.key, Reply::RELEASED)
|
|
195
|
+
when Protocol::Incoming::NotFound
|
|
196
|
+
resolve(Protocol::OP_RELEASE, incoming.key, Reply::NOT_FOUND)
|
|
197
|
+
when Protocol::Incoming::Moved
|
|
198
|
+
@logger.warn("server draining: #{@addr} (moved to #{incoming.addr})")
|
|
199
|
+
@status = :draining
|
|
200
|
+
when Protocol::Incoming::Error
|
|
201
|
+
@logger.error("server error from #{@addr}: #{incoming.reason}")
|
|
202
|
+
if incoming.reason == "not_active"
|
|
203
|
+
@status = :draining
|
|
204
|
+
clear_pending
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def resolve(op, key, reply)
|
|
210
|
+
waiter =
|
|
211
|
+
@pending_mutex.synchronize do
|
|
212
|
+
map = waiters(op)
|
|
213
|
+
queue = map[key]
|
|
214
|
+
next nil if queue.nil?
|
|
215
|
+
|
|
216
|
+
w = queue.shift
|
|
217
|
+
map.delete(key) if queue.empty?
|
|
218
|
+
w
|
|
219
|
+
end
|
|
220
|
+
waiter&.complete(reply)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def teardown(sock)
|
|
224
|
+
@status = :down
|
|
225
|
+
@writer = nil
|
|
226
|
+
begin
|
|
227
|
+
sock.close
|
|
228
|
+
rescue StandardError
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
clear_pending
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def clear_pending
|
|
235
|
+
orphans =
|
|
236
|
+
@pending_mutex.synchronize do
|
|
237
|
+
all = []
|
|
238
|
+
@acquire_waiters.each_value { |queue| all.concat(queue) }
|
|
239
|
+
@release_waiters.each_value { |queue| all.concat(queue) }
|
|
240
|
+
@acquire_waiters.clear
|
|
241
|
+
@release_waiters.clear
|
|
242
|
+
all
|
|
243
|
+
end
|
|
244
|
+
orphans.each { |waiter| waiter.fail(Ticketing.closed_error) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def parse_addr(addr)
|
|
248
|
+
i = addr.rindex(":")
|
|
249
|
+
unless i && i.positive? && i < addr.length - 1
|
|
250
|
+
raise ArgumentError, "address must be host:port, got '#{addr}'"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
[addr[0...i], addr[(i + 1)..].to_i]
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ticketing
|
|
4
|
+
# Base class for every error raised by this library.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# The wait budget elapsed before the lock could be acquired.
|
|
8
|
+
class TimeoutError < Error
|
|
9
|
+
def initialize(message = "timed out waiting to acquire the lock")
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# No server connection was usable for the request.
|
|
15
|
+
class NotConnectedError < Error
|
|
16
|
+
def initialize(message = "no connected server")
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The key (or other argument) failed local validation before any network I/O.
|
|
22
|
+
class InvalidInput < Error
|
|
23
|
+
attr_reader :reason
|
|
24
|
+
|
|
25
|
+
def initialize(reason)
|
|
26
|
+
@reason = reason
|
|
27
|
+
super("invalid input: #{reason}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The server sent a reply that does not fit the request that was made.
|
|
32
|
+
class ProtocolError < Error
|
|
33
|
+
attr_reader :reason
|
|
34
|
+
|
|
35
|
+
def initialize(reason)
|
|
36
|
+
@reason = reason
|
|
37
|
+
super("protocol error: #{reason}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A socket read or write failed. {#cause_error} carries the underlying error;
|
|
42
|
+
# when it is a {ConnectionClosed} the broker treats the drop as retryable.
|
|
43
|
+
class IoError < Error
|
|
44
|
+
attr_reader :cause_error
|
|
45
|
+
|
|
46
|
+
def initialize(cause)
|
|
47
|
+
@cause_error = cause
|
|
48
|
+
super("io error: #{cause}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Marker for a connection that was lost or never established. Wrapped in an
|
|
53
|
+
# {IoError} so a dropped socket can be told apart from a real I/O fault.
|
|
54
|
+
class ConnectionClosed < StandardError
|
|
55
|
+
def initialize(message = "connection closed")
|
|
56
|
+
super
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# An {IoError} standing for a closed connection.
|
|
61
|
+
def self.closed_error
|
|
62
|
+
IoError.new(ConnectionClosed.new)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module Ticketing
|
|
6
|
+
# Wire encoding and decoding for the binary ticket-lock protocol. The byte
|
|
7
|
+
# layout matches every other client exactly.
|
|
8
|
+
module Protocol
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
OP_ACQUIRE = "A".ord
|
|
12
|
+
OP_RELEASE = "R".ord
|
|
13
|
+
NL = "\n".ord
|
|
14
|
+
|
|
15
|
+
MAX_KEY_LEN = 128
|
|
16
|
+
MAX_LINE_LEN = 192
|
|
17
|
+
|
|
18
|
+
# A decoded reply, one variant per server op.
|
|
19
|
+
module Incoming
|
|
20
|
+
Acquired = Struct.new(:token, :key)
|
|
21
|
+
TimedOut = Struct.new(:key)
|
|
22
|
+
Released = Struct.new(:key)
|
|
23
|
+
NotFound = Struct.new(:key)
|
|
24
|
+
Moved = Struct.new(:addr)
|
|
25
|
+
Error = Struct.new(:reason)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# One framed message read off the wire: {Data}, or the {EOF}/{TOO_LONG}
|
|
29
|
+
# sentinels.
|
|
30
|
+
module Frame
|
|
31
|
+
EOF = :eof
|
|
32
|
+
TOO_LONG = :too_long
|
|
33
|
+
Data = Struct.new(:bytes)
|
|
34
|
+
|
|
35
|
+
def self.data(bytes)
|
|
36
|
+
Data.new(bytes)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Number of fixed bytes that follow the op byte of a reply.
|
|
41
|
+
def reply_fixed(op)
|
|
42
|
+
op == OP_ACQUIRE ? 8 : 0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Rejects a key that is empty, over {MAX_KEY_LEN} bytes, or contains a
|
|
46
|
+
# space, carriage return, or newline.
|
|
47
|
+
def validate_key(key)
|
|
48
|
+
bytes = utf8_bytes(key)
|
|
49
|
+
if bytes.bytesize.zero? || bytes.bytesize > MAX_KEY_LEN
|
|
50
|
+
raise InvalidInput, "key must be 1..=128 bytes"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
bytes.each_byte do |b|
|
|
54
|
+
if b == 0x20 || b == 0x0A || b == 0x0D
|
|
55
|
+
raise InvalidInput, "key must not contain spaces or newlines"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Builds an ACQUIRE frame: 'A' + u16 wait + u16 lease (big-endian, whole
|
|
61
|
+
# seconds) + UTF-8 key + '\n'. Sub-second durations round up; lease is at
|
|
62
|
+
# least one second.
|
|
63
|
+
def acquire_frame(key, wait, lease)
|
|
64
|
+
kb = utf8_bytes(key)
|
|
65
|
+
w = clamp_u16(ceil_secs(wait))
|
|
66
|
+
l = clamp_u16([ceil_secs(lease), 1].max)
|
|
67
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
68
|
+
out << OP_ACQUIRE
|
|
69
|
+
out << ((w >> 8) & 0xFF)
|
|
70
|
+
out << (w & 0xFF)
|
|
71
|
+
out << ((l >> 8) & 0xFF)
|
|
72
|
+
out << (l & 0xFF)
|
|
73
|
+
out << kb
|
|
74
|
+
out << NL
|
|
75
|
+
out
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Builds a RELEASE frame: 'R' + UTF-8 key + '\n'.
|
|
79
|
+
def release_frame(key)
|
|
80
|
+
kb = utf8_bytes(key)
|
|
81
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
82
|
+
out << OP_RELEASE
|
|
83
|
+
out << kb
|
|
84
|
+
out << NL
|
|
85
|
+
out
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Decodes a reply body (op + fixed + tail, newline already stripped) into an
|
|
89
|
+
# {Incoming}, or +nil+ if the op is unknown or the body is malformed.
|
|
90
|
+
def parse_reply(body)
|
|
91
|
+
return nil if body.bytesize.zero?
|
|
92
|
+
|
|
93
|
+
op = body.getbyte(0)
|
|
94
|
+
tail = body.byteslice(1, body.bytesize - 1) || "".b
|
|
95
|
+
case op
|
|
96
|
+
when "A".ord
|
|
97
|
+
return nil if tail.bytesize < 8
|
|
98
|
+
|
|
99
|
+
token = be_long(tail, 0)
|
|
100
|
+
Incoming::Acquired.new(token, tail.byteslice(8, tail.bytesize - 8).to_s.force_encoding(Encoding::UTF_8))
|
|
101
|
+
when "T".ord then Incoming::TimedOut.new(tail.dup.force_encoding(Encoding::UTF_8))
|
|
102
|
+
when "R".ord then Incoming::Released.new(tail.dup.force_encoding(Encoding::UTF_8))
|
|
103
|
+
when "N".ord then Incoming::NotFound.new(tail.dup.force_encoding(Encoding::UTF_8))
|
|
104
|
+
when "M".ord then Incoming::Moved.new(tail.dup.force_encoding(Encoding::UTF_8))
|
|
105
|
+
when "E".ord then Incoming::Error.new(tail.dup.force_encoding(Encoding::UTF_8))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Reads one framed message from +input+: a one-byte op, then the fixed bytes
|
|
110
|
+
# for that op (via +fixed_for+), then a tail terminated by '\n'. Returns the
|
|
111
|
+
# bytes as +op + fixed + tail+ with the newline stripped. A lone '\n' is an
|
|
112
|
+
# empty heartbeat frame. Over-long lines are drained and reported as
|
|
113
|
+
# {Frame::TOO_LONG} without closing the connection.
|
|
114
|
+
def read_frame(input)
|
|
115
|
+
first = input.getbyte
|
|
116
|
+
return Frame::EOF if first.nil?
|
|
117
|
+
return Frame.data("".b) if first == NL
|
|
118
|
+
|
|
119
|
+
op = first
|
|
120
|
+
fixed = yield(op)
|
|
121
|
+
head = String.new(encoding: Encoding::BINARY)
|
|
122
|
+
head << op
|
|
123
|
+
got = 0
|
|
124
|
+
while got < fixed
|
|
125
|
+
chunk = input.read(fixed - got)
|
|
126
|
+
return Frame::EOF if chunk.nil? || chunk.bytesize.zero?
|
|
127
|
+
|
|
128
|
+
head << chunk
|
|
129
|
+
got += chunk.bytesize
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
tail = String.new(encoding: Encoding::BINARY)
|
|
133
|
+
too_long = head.bytesize > MAX_LINE_LEN
|
|
134
|
+
loop do
|
|
135
|
+
b = input.getbyte
|
|
136
|
+
return Frame::EOF if b.nil?
|
|
137
|
+
break if b == NL
|
|
138
|
+
|
|
139
|
+
unless too_long
|
|
140
|
+
tail << b
|
|
141
|
+
too_long = true if head.bytesize + tail.bytesize > MAX_LINE_LEN
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
return Frame::TOO_LONG if too_long
|
|
145
|
+
|
|
146
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
147
|
+
body << head
|
|
148
|
+
body << tail
|
|
149
|
+
Frame.data(body)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def utf8_bytes(str)
|
|
153
|
+
str.to_s.encode(Encoding::UTF_8).b
|
|
154
|
+
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
|
|
155
|
+
str.to_s.b
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def be_long(bytes, off)
|
|
159
|
+
v = 0
|
|
160
|
+
8.times { |i| v = (v << 8) | bytes.getbyte(off + i) }
|
|
161
|
+
v
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def ceil_secs(duration)
|
|
165
|
+
return 0 if duration.nil? || duration <= 0
|
|
166
|
+
|
|
167
|
+
duration.ceil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def clamp_u16(value)
|
|
171
|
+
return 0 if value < 0
|
|
172
|
+
return 0xFFFF if value > 0xFFFF
|
|
173
|
+
|
|
174
|
+
value
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
data/lib/ticketing.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ticketing/version'
|
|
4
|
+
require_relative 'ticketing/errors'
|
|
5
|
+
require_relative 'ticketing/protocol'
|
|
6
|
+
require_relative 'ticketing/connection'
|
|
7
|
+
require_relative 'ticketing/broker'
|
|
8
|
+
|
|
9
|
+
# Client for a network distributed-lock ("ticket") service. See {Ticketing::Broker}.
|
|
10
|
+
module Ticketing
|
|
11
|
+
end
|
data/ticketing.gemspec
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/ticketing/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "ticketing"
|
|
7
|
+
spec.version = Ticketing::VERSION
|
|
8
|
+
spec.authors = ["marker"]
|
|
9
|
+
spec.email = ["j@saro.me"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Distributed ticket lock client"
|
|
12
|
+
spec.description = "Distributed ticket lock client"
|
|
13
|
+
spec.homepage = "https://ticketing.saro.me"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 2.7.0"
|
|
16
|
+
|
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/saro-lab/ticketing"
|
|
19
|
+
spec.metadata["changelog_uri"] = "https://github.com/saro-lab/ticketing/blob/main/CHANGELOG.md"
|
|
20
|
+
|
|
21
|
+
spec.metadata["keywords"] = "ticketing, distributed, lock, mutex, fencing, token"
|
|
22
|
+
|
|
23
|
+
# Specify which files should be added to the gem when it is released.
|
|
24
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
25
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
26
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
|
27
|
+
end
|
|
28
|
+
spec.bindir = "exe"
|
|
29
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
30
|
+
spec.require_paths = ["lib"]
|
|
31
|
+
|
|
32
|
+
spec.add_dependency "logger"
|
|
33
|
+
|
|
34
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
35
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ticketing
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.2
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- marker
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: logger
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
description: Distributed ticket lock client
|
|
41
|
+
email:
|
|
42
|
+
- j@saro.me
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- ".gitignore"
|
|
48
|
+
- ".idea/.gitignore"
|
|
49
|
+
- ".idea/modules.xml"
|
|
50
|
+
- ".idea/ticketing-ruby.iml"
|
|
51
|
+
- ".idea/vcs.xml"
|
|
52
|
+
- ".ruby-version"
|
|
53
|
+
- Gemfile
|
|
54
|
+
- LICENSE
|
|
55
|
+
- PUBLISH.md
|
|
56
|
+
- README.md
|
|
57
|
+
- lib/ticketing.rb
|
|
58
|
+
- lib/ticketing/broker.rb
|
|
59
|
+
- lib/ticketing/connection.rb
|
|
60
|
+
- lib/ticketing/errors.rb
|
|
61
|
+
- lib/ticketing/protocol.rb
|
|
62
|
+
- lib/ticketing/version.rb
|
|
63
|
+
- ticketing.gemspec
|
|
64
|
+
homepage: https://ticketing.saro.me
|
|
65
|
+
licenses:
|
|
66
|
+
- MIT
|
|
67
|
+
metadata:
|
|
68
|
+
homepage_uri: https://ticketing.saro.me
|
|
69
|
+
source_code_uri: https://github.com/saro-lab/ticketing
|
|
70
|
+
changelog_uri: https://github.com/saro-lab/ticketing/blob/main/CHANGELOG.md
|
|
71
|
+
keywords: ticketing, distributed, lock, mutex, fencing, token
|
|
72
|
+
rdoc_options: []
|
|
73
|
+
require_paths:
|
|
74
|
+
- lib
|
|
75
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: 2.7.0
|
|
80
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: '0'
|
|
85
|
+
requirements: []
|
|
86
|
+
rubygems_version: 4.0.12
|
|
87
|
+
specification_version: 4
|
|
88
|
+
summary: Distributed ticket lock client
|
|
89
|
+
test_files: []
|