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 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
@@ -0,0 +1,17 @@
1
+
2
+ /.bundle/
3
+ /vendor/bundle/
4
+
5
+ /Gemfile.lock
6
+ /ticketing-*.gem
7
+
8
+ /pkg/
9
+ /doc/
10
+ .yardoc/
11
+
12
+
13
+ ### VS Code ###
14
+ .vscode/
15
+
16
+ ### Mac OS ###
17
+ .DS_Store
data/.idea/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
6
+ # Ignored default folder with query files
7
+ /queries/
8
+ # Datasource local storage ignored files
9
+ /dataSources/
10
+ /dataSources.local.xml
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
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
5
+ </component>
6
+ </project>
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.5
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ticketing
4
+ VERSION = "0.0.2"
5
+ 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: []