ticketing 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/PUBLISH.md +1 -1
- data/README.md +33 -1
- data/lib/ticketing/broker.rb +127 -153
- data/lib/ticketing/connection.rb +278 -187
- data/lib/ticketing/errors.rb +13 -56
- data/lib/ticketing/protocol.rb +70 -144
- data/lib/ticketing/version.rb +1 -1
- data/lib/ticketing.rb +10 -8
- data/ticketing.gemspec +1 -1
- metadata +2 -6
- data/.idea/.gitignore +0 -10
- data/.idea/modules.xml +0 -8
- data/.idea/ticketing-ruby.iml +0 -18
- data/.idea/vcs.xml +0 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6044be6ca9d80a6d99dda66c7e0d2540688030937fa651c481b7e8f0e1277a8
|
|
4
|
+
data.tar.gz: 65f891bc09973b994cb26618fee921fe8aa97bc0631a5ffae6486484288a443e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c968ea192e3d8152bd60015d9177a5a034c1c267c5b1d44b443e771ae1d977987802652ace2b00d021329b5ca5765d0c37531c735fa4b31dbf03da025abb3237
|
|
7
|
+
data.tar.gz: 55dee881c6c48527fee22c1f8a7442bce75e2720b790f81e27b52b27be6120d087d6eefa7f6f5ef63e9a9d6af99e0ea6e5c3f038b64bcfa4df2d3b7b0036aa1d
|
data/PUBLISH.md
CHANGED
data/README.md
CHANGED
|
@@ -1 +1,33 @@
|
|
|
1
|
-
|
|
1
|
+
# Ticketing
|
|
2
|
+
### Ticketing: A TCP-Based Lightweight Distributed Lock Service
|
|
3
|
+
|
|
4
|
+
### Document
|
|
5
|
+
- [What is Ticketing](https://ticketing.saro.me/intro)
|
|
6
|
+
- [Document](https://ticketing.saro.me/libs/gems-ticketing)
|
|
7
|
+
|
|
8
|
+
### Example
|
|
9
|
+
- [test_nc_bank.rb](https://github.com/saro-lab/ticketing/tree/master/ticketing-ruby/test/test_nc_bank.rb)
|
|
10
|
+
- [test_nc_bench.rb](https://github.com/saro-lab/ticketing/tree/master/ticketing-ruby/test/test_nc_bench.rb)
|
|
11
|
+
- [test_nc_cluster.rb](https://github.com/saro-lab/ticketing/tree/master/ticketing-ruby/test/test_nc_cluster.rb)
|
|
12
|
+
- [test_nc_example_simple.rb](https://github.com/saro-lab/ticketing/tree/master/ticketing-ruby/test/test_nc_example_simple.rb)
|
|
13
|
+
|
|
14
|
+
### Server
|
|
15
|
+
- [Server](https://github.com/saro-lab/ticketing/tree/master/ticketing-server)
|
|
16
|
+
- [Download](https://github.com/saro-lab/ticketing/releases)
|
|
17
|
+
|
|
18
|
+
### Client
|
|
19
|
+
- [Rust](https://github.com/saro-lab/ticketing/tree/master/ticketing-rust)
|
|
20
|
+
- [Java, Kotlin](https://github.com/saro-lab/ticketing/tree/master/ticketing-maven)
|
|
21
|
+
- [Javascript, Typescript](https://github.com/saro-lab/ticketing/tree/master/ticketing-npm)
|
|
22
|
+
- [C#](https://github.com/saro-lab/ticketing/tree/master/ticketing-nuget)
|
|
23
|
+
- [Python](https://github.com/saro-lab/ticketing/tree/master/ticketing-pypi)
|
|
24
|
+
- [Go](https://github.com/saro-lab/ticketing/tree/master/ticketing-go)
|
|
25
|
+
- [Ruby](https://github.com/saro-lab/ticketing/tree/master/ticketing-ruby)
|
|
26
|
+
- [C/C++ (Vcpkg)](https://github.com/saro-lab/ticketing/tree/master/ticketing-vcpkg)
|
|
27
|
+
|
|
28
|
+
### Performance
|
|
29
|
+
- mac mini m4 2024 basic (10 core), single local server, contended keys
|
|
30
|
+
```
|
|
31
|
+
assert OK: all 4 counters reached 10000
|
|
32
|
+
4 workers x 10000 ops = 40000 total in 1288.9ms
|
|
33
|
+
```
|
data/lib/ticketing/broker.rb
CHANGED
|
@@ -1,214 +1,188 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'logger'
|
|
4
|
-
|
|
5
|
-
require_relative 'errors'
|
|
6
|
-
require_relative 'protocol'
|
|
7
|
-
require_relative 'connection'
|
|
8
|
-
|
|
9
3
|
module Ticketing
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
4
|
+
# Shared handle to the lock service. Declare once (e.g. a constant) and share
|
|
5
|
+
# it across threads — the client is fully thread-safe, and connection
|
|
6
|
+
# failures self-heal without ever breaking this object. All time values are
|
|
7
|
+
# **seconds** — the wire protocol is second-granular.
|
|
8
|
+
# 락 서비스 공유 핸들 — 상수로 한 번 선언해 스레드들이 공유한다. 클라이언트는
|
|
9
|
+
# 스레드 안전하며 연결 오류는 자가 치유되고 이 객체를 망가뜨리지 않는다.
|
|
10
|
+
# 시간 값은 전부 **초 단위**.
|
|
16
11
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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 ...
|
|
12
|
+
# BROKER = Ticketing::TicketBroker.connect("127.0.0.1:5225")
|
|
13
|
+
# ticket = BROKER.acquire("order-42", 5, 30)
|
|
14
|
+
# # ... critical section — 임계 구역 ...
|
|
23
15
|
# ticket.release
|
|
24
|
-
class
|
|
16
|
+
class TicketBroker
|
|
17
|
+
ACQUIRE_RETRY = 0.1
|
|
25
18
|
RELEASE_RETRY_WINDOW = 5.0
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
list = addrs.
|
|
37
|
-
|
|
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
|
|
19
|
+
RELEASE_RETRY = 0.2
|
|
20
|
+
|
|
21
|
+
# Plaintext, empty-token broker. Never fails. 평문·빈 토큰 — 실패 불가.
|
|
22
|
+
def self.connect(*addrs) = new(addrs)
|
|
23
|
+
|
|
24
|
+
# token/tls/logger are keyword options; tls accepts nil(off) |
|
|
25
|
+
# 'system-roots' | 'insecure-skip-verify' | CA PEM path.
|
|
26
|
+
# 토큰/TLS/로거는 키워드 옵션.
|
|
27
|
+
def initialize(addrs, token: "", tls: nil, logger: nil)
|
|
28
|
+
ssl_context, verify_host = Tls.build(tls)
|
|
29
|
+
list = addrs.map(&:to_s)
|
|
30
|
+
list *= 2 if list.size == 1 # one address held with two connections. 단일 주소는 연결 2개.
|
|
31
|
+
@topo = Topology.new(list)
|
|
55
32
|
@rr = 0
|
|
56
|
-
@
|
|
33
|
+
@conns = list.each_with_index.map do |addr, index|
|
|
34
|
+
Conn.new(addr, index, token, ssl_context, verify_host, @topo, logger)
|
|
35
|
+
end
|
|
57
36
|
end
|
|
58
37
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
38
|
+
# Acquires the lock `key`, waiting up to `wait` seconds (0 = forever).
|
|
39
|
+
# `lease` bounds how long the server holds it if this client dies.
|
|
40
|
+
# Failovers (dead leader, election, redirect) are retried invisibly.
|
|
41
|
+
# 락 획득 — wait 초(0=무한) 대기, lease는 사망 대비 서버 보유 상한.
|
|
42
|
+
# 장애조치는 내부에서 투명하게 재시도된다.
|
|
43
|
+
def acquire(key, wait, lease)
|
|
44
|
+
Protocol.validate_key!(key)
|
|
45
|
+
frame = Protocol.acquire_frame(key, wait, lease)
|
|
46
|
+
deadline = wait.positive? ? monotime + wait : nil
|
|
47
|
+
|
|
64
48
|
loop do
|
|
65
|
-
|
|
66
|
-
|
|
49
|
+
conn = pick
|
|
50
|
+
raise TicketError.new(:not_connected, "no connected server") if conn.nil?
|
|
67
51
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
52
|
+
reply = conn.request(Protocol::OP_ACQUIRE, key, frame).pop
|
|
53
|
+
if reply.is_a?(TicketError)
|
|
54
|
+
raise reply unless reply.aborted?
|
|
55
|
+
raise TicketError.new(:timeout, "timed out waiting to acquire the lock") if deadline && monotime >= deadline
|
|
71
56
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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)
|
|
57
|
+
sleep(ACQUIRE_RETRY)
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
kind, token = reply
|
|
61
|
+
case kind
|
|
62
|
+
when :acquired then return Ticket.new(self, conn, key, token)
|
|
63
|
+
when :timed_out then raise TicketError.new(:timeout, "timed out waiting to acquire the lock")
|
|
64
|
+
else raise TicketError.new(:protocol, "unexpected reply for acquire")
|
|
99
65
|
end
|
|
100
66
|
end
|
|
101
|
-
raise NotConnectedError
|
|
102
67
|
end
|
|
103
68
|
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
69
|
+
# Waits up to `timeout` seconds for any connection to come up.
|
|
70
|
+
# 연결 1개가 올라올 때까지 대기(기동 편의).
|
|
71
|
+
def wait_ready(timeout)
|
|
72
|
+
deadline = monotime + timeout
|
|
73
|
+
loop do
|
|
74
|
+
return true unless pick.nil?
|
|
75
|
+
return false if monotime >= deadline
|
|
76
|
+
|
|
77
|
+
sleep(0.02)
|
|
78
|
+
end
|
|
107
79
|
end
|
|
108
80
|
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
def release_lock(granted, key)
|
|
81
|
+
# Prefer the granting connection, else any usable one; retry across
|
|
82
|
+
# failover so a held lock is not stranded.
|
|
83
|
+
# 부여 연결 우선 — 장애조치를 넘겨 재시도해 보유 락이 방치되지 않게 한다.
|
|
84
|
+
def release_loop(granted, key)
|
|
114
85
|
frame = Protocol.release_frame(key)
|
|
115
|
-
deadline =
|
|
116
|
-
last_error = NotConnectedError.new
|
|
86
|
+
deadline = monotime + RELEASE_RETRY_WINDOW
|
|
117
87
|
loop do
|
|
118
88
|
conn = granted.usable? ? granted : pick
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
89
|
+
unless conn.nil?
|
|
90
|
+
reply = conn.request(Protocol::OP_RELEASE, key, frame).pop
|
|
91
|
+
if reply.is_a?(TicketError)
|
|
92
|
+
raise reply unless reply.aborted?
|
|
93
|
+
else
|
|
94
|
+
case reply.first
|
|
95
|
+
when :released then return true
|
|
96
|
+
when :not_found then return false
|
|
97
|
+
else raise TicketError.new(:protocol, "unexpected reply for release")
|
|
126
98
|
end
|
|
127
|
-
rescue IoError => e
|
|
128
|
-
raise e unless e.cause_error.is_a?(ConnectionClosed)
|
|
129
|
-
|
|
130
|
-
last_error = Ticketing.closed_error
|
|
131
99
|
end
|
|
132
100
|
end
|
|
133
|
-
raise
|
|
101
|
+
raise TicketError.new(:not_connected, "no connected server") if monotime >= deadline
|
|
134
102
|
|
|
135
|
-
sleep
|
|
103
|
+
sleep(RELEASE_RETRY)
|
|
136
104
|
end
|
|
137
105
|
end
|
|
138
106
|
|
|
107
|
+
# Fire-and-forget release (single attempt) — the lease cleans up if it is
|
|
108
|
+
# lost in a failover. 단발 백그라운드 해제 — 유실 시 lease가 정리.
|
|
109
|
+
def release_forget(granted, key)
|
|
110
|
+
conn = granted.usable? ? granted : pick
|
|
111
|
+
conn&.request(Protocol::OP_RELEASE, key, Protocol.release_frame(key))
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
139
115
|
private
|
|
140
116
|
|
|
117
|
+
def monotime = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
118
|
+
|
|
119
|
+
# Leader hint first, then round-robin over usable connections; if all are
|
|
120
|
+
# drained, revive and try once more.
|
|
121
|
+
# 리더 힌트 우선 → 라운드로빈 — 전부 드레인이면 부활 후 재시도.
|
|
141
122
|
def pick
|
|
142
|
-
|
|
143
|
-
return
|
|
123
|
+
leader = @topo.leader
|
|
124
|
+
return @conns[leader] if leader >= 0 && leader < @conns.size && @conns[leader].usable?
|
|
125
|
+
|
|
126
|
+
count = @conns.size
|
|
127
|
+
return nil if count.zero?
|
|
144
128
|
|
|
145
|
-
start = @
|
|
146
|
-
|
|
147
|
-
@
|
|
148
|
-
|
|
129
|
+
start = (@rr += 1)
|
|
130
|
+
count.times do |i|
|
|
131
|
+
conn = @conns[(start + i) % count]
|
|
132
|
+
return conn if conn.usable?
|
|
149
133
|
end
|
|
150
|
-
|
|
151
|
-
|
|
134
|
+
@conns.each(&:revive)
|
|
135
|
+
count.times do |i|
|
|
136
|
+
conn = @conns[(start + i) % count]
|
|
152
137
|
return conn if conn.usable?
|
|
153
138
|
end
|
|
154
139
|
nil
|
|
155
140
|
end
|
|
156
|
-
|
|
157
|
-
def monotonic
|
|
158
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
159
|
-
end
|
|
160
141
|
end
|
|
161
142
|
|
|
162
|
-
# An acquired lock guard
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
# the ticket is {#close}d. If the client dies before releasing, the server's
|
|
166
|
-
# lease eventually frees it.
|
|
143
|
+
# An acquired lock guard — release explicitly, or close for a background
|
|
144
|
+
# release. If the client dies, the server's lease frees it.
|
|
145
|
+
# 획득한 락 가드 — 명시 해제 또는 close(백그라운드 해제), 사망 시 lease가 정리.
|
|
167
146
|
class Ticket
|
|
168
|
-
# The locked key.
|
|
147
|
+
# The locked key. 잠긴 키.
|
|
169
148
|
attr_reader :key
|
|
170
|
-
|
|
171
|
-
#
|
|
172
|
-
# every acquisition of any key. Pass it to the resource you protect and
|
|
173
|
-
# reject stale (lower) tokens.
|
|
149
|
+
# Fencing token — strictly increases with every grant of any key.
|
|
150
|
+
# 펜싱 토큰 — 모든 grant마다 단조 증가. 자원 측에서 낮은 토큰 거부.
|
|
174
151
|
attr_reader :token
|
|
175
152
|
|
|
176
|
-
def initialize(broker, conn, key, token
|
|
153
|
+
def initialize(broker, conn, key, token)
|
|
177
154
|
@broker = broker
|
|
178
155
|
@conn = conn
|
|
179
156
|
@key = key
|
|
180
157
|
@token = token
|
|
181
|
-
@logger = logger
|
|
182
158
|
@released = false
|
|
183
159
|
@mutex = Mutex.new
|
|
184
160
|
end
|
|
185
161
|
|
|
186
|
-
# Releases the lock
|
|
187
|
-
# it had already expired.
|
|
162
|
+
# Releases the lock; true if the server still held it. 해제 — 보유 중이었으면 true.
|
|
188
163
|
def release
|
|
189
|
-
|
|
190
|
-
|
|
164
|
+
return false if take_released!
|
|
165
|
+
|
|
166
|
+
@broker.release_loop(@conn, @key)
|
|
191
167
|
end
|
|
192
168
|
|
|
193
|
-
#
|
|
169
|
+
# Background release (fire-and-forget), like rust's drop. 백그라운드 해제.
|
|
194
170
|
def close
|
|
195
|
-
|
|
196
|
-
@mutex.synchronize do
|
|
197
|
-
next false if @released
|
|
171
|
+
return if take_released!
|
|
198
172
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
end
|
|
202
|
-
return unless should_release
|
|
173
|
+
@broker.release_forget(@conn, @key)
|
|
174
|
+
end
|
|
203
175
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
176
|
+
def to_s = "Ticket(key=#{@key}, token=#{@token})"
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def take_released!
|
|
181
|
+
@mutex.synchronize do
|
|
182
|
+
already = @released
|
|
183
|
+
@released = true
|
|
184
|
+
already
|
|
210
185
|
end
|
|
211
|
-
nil
|
|
212
186
|
end
|
|
213
187
|
end
|
|
214
188
|
end
|