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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7aaf975c3d88996a5ce4214840a98e1d70b3b992a5a29c529553aa2477658c7
4
- data.tar.gz: 227933f5143a9bd7303bd28316e5ca2496b61d4dd4bb8f1b2f82da19c561cea0
3
+ metadata.gz: b6044be6ca9d80a6d99dda66c7e0d2540688030937fa651c481b7e8f0e1277a8
4
+ data.tar.gz: 65f891bc09973b994cb26618fee921fe8aa97bc0631a5ffae6486484288a443e
5
5
  SHA512:
6
- metadata.gz: 714750e33051582fb9df8920f5ca2080a20dee8891235dbd45a560964c172d21fd1832ca4d56a943d0ed47b2190d07cc62d07c047ea4722d4b0ece56edacbd27
7
- data.tar.gz: e052e39de9d7417b04b7acc578a6d9bc815ddf460102d8a4e2089248c5629b52cdbf887a9604d133ce9aa3014c021c6f82a98cc2d9820d650d99664a22a555ee
6
+ metadata.gz: c968ea192e3d8152bd60015d9177a5a034c1c267c5b1d44b443e771ae1d977987802652ace2b00d021329b5ca5765d0c37531c735fa4b31dbf03da025abb3237
7
+ data.tar.gz: 55dee881c6c48527fee22c1f8a7442bce75e2720b790f81e27b52b27be6120d087d6eefa7f6f5ef63e9a9d6af99e0ea6e5c3f038b64bcfa4df2d3b7b0036aa1d
data/PUBLISH.md CHANGED
@@ -8,7 +8,7 @@ bundle install
8
8
  ```
9
9
  gem build ticketing.gemspec
10
10
  gem signin
11
- gem push ticketing-0.0.2.gem
11
+ gem push ticketing-0.1.0.gem
12
12
  ```
13
13
 
14
14
  ## install
data/README.md CHANGED
@@ -1 +1,33 @@
1
- https://ticketing.saro.me/
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
+ ```
@@ -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
- # 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.
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
- # 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 ...
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 Broker
16
+ class TicketBroker
17
+ ACQUIRE_RETRY = 0.1
25
18
  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
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
- @rr_mutex = Mutex.new
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
- # 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
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
- return true if pick
66
- return false if monotonic >= deadline
49
+ conn = pick
50
+ raise TicketError.new(:not_connected, "no connected server") if conn.nil?
67
51
 
68
- sleep 0.02
69
- end
70
- end
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
- # 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)
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
- # Cancels the background connections. The broker is unusable afterwards.
105
- def close
106
- @conns.each(&:stop)
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
- # 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)
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 = monotonic + RELEASE_RETRY_WINDOW
116
- last_error = NotConnectedError.new
86
+ deadline = monotime + RELEASE_RETRY_WINDOW
117
87
  loop do
118
88
  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"
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 last_error if monotonic >= deadline
101
+ raise TicketError.new(:not_connected, "no connected server") if monotime >= deadline
134
102
 
135
- sleep RELEASE_RETRY_INTERVAL
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
- n = @conns.size
143
- return nil if n.zero?
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 = @rr_mutex.synchronize do
146
- s = @rr
147
- @rr += 1
148
- s
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
- n.times do |i|
151
- conn = @conns[(start + i) % n]
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
- # 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.
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
- # 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.
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, logger)
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. Returns +true+ if the server still held it, +false+ if
187
- # it had already expired.
162
+ # Releases the lock; true if the server still held it. 해제 — 보유 중이었으면 true.
188
163
  def release
189
- @mutex.synchronize { @released = true }
190
- @broker.release_lock(@conn, @key)
164
+ return false if take_released!
165
+
166
+ @broker.release_loop(@conn, @key)
191
167
  end
192
168
 
193
- # Releases the lock in the background if it was not already released.
169
+ # Background release (fire-and-forget), like rust's drop. 백그라운드 해제.
194
170
  def close
195
- should_release =
196
- @mutex.synchronize do
197
- next false if @released
171
+ return if take_released!
198
172
 
199
- @released = true
200
- true
201
- end
202
- return unless should_release
173
+ @broker.release_forget(@conn, @key)
174
+ end
203
175
 
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
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