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.
@@ -1,256 +1,347 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'socket'
4
- require 'logger'
5
-
6
- require_relative 'errors'
7
- require_relative 'protocol'
3
+ require "socket"
4
+ require "openssl"
8
5
 
9
6
  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)
7
+ # One-way TLS: the client verifies the server, never the reverse.
8
+ # 단방향 TLS: 클라이언트가 서버만 검증한다.
9
+ module Tls
10
+ module_function
11
+
12
+ # tls: nil(off) | 'system-roots' | 'insecure-skip-verify' | CA PEM path.
13
+ # Returns [ssl_context, verify_host]. 빌드 시 1회 결정.
14
+ def build(tls)
15
+ case tls
16
+ when nil, "off"
17
+ [nil, false]
18
+ when "system-roots"
19
+ context = OpenSSL::SSL::SSLContext.new
20
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER
21
+ context.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
22
+ [context, true]
23
+ when "insecure-skip-verify"
24
+ context = OpenSSL::SSL::SSLContext.new
25
+ context.verify_mode = OpenSSL::SSL::VERIFY_NONE
26
+ [context, false]
27
+ else
28
+ context = OpenSSL::SSL::SSLContext.new
29
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER
30
+ store = OpenSSL::X509::Store.new
31
+ begin
32
+ store.add_file(tls.to_s)
33
+ rescue OpenSSL::X509::StoreError => e
34
+ raise TicketError.new(:io, "TLS config load failed: #{tls} (#{e.message})")
35
+ end
36
+ context.cert_store = store
37
+ [context, true]
38
+ end
19
39
  end
20
40
  end
21
41
 
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
42
+ # Shared cluster view: addresses + leader hint. Plain ivars are safe under
43
+ # the GVL. 공유 클러스터 뷰(주소 + 리더 힌트) — GVL 하에서 단순 대입은 안전.
44
+ class Topology
45
+ attr_reader :addrs, :leader
28
46
 
29
- def complete(reply)
30
- @queue.push([:ok, reply])
47
+ def initialize(addrs)
48
+ @addrs = addrs
49
+ @leader = -1
31
50
  end
32
51
 
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
52
+ # Map a `M`(Moved) address to a leader hint. M 주소 → 리더 힌트.
53
+ def set_leader(addr) = @leader = @addrs.index(addr) || -1
40
54
 
41
- value
55
+ def clear_leader_if(index)
56
+ @leader = -1 if @leader == index
42
57
  end
43
58
  end
44
59
 
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).
60
+ # One server connection: request queue + batching writer thread + reader,
61
+ # with self-healing reconnect. 서버 연결 하나 요청 + 배칭 writer 스레드 +
62
+ # reader, 자가 치유 재접속.
49
63
  class Conn
50
- RECONNECT_INTERVAL = 3.0
64
+ STATUS_DOWN = 0
65
+ STATUS_UP = 1
66
+ STATUS_DRAINING = 2
67
+
68
+ REQ_QUEUE = 1024
69
+ WRITE_BATCH = 64
70
+ BACKOFF_MIN = 0.1
71
+ BACKOFF_MAX = 3.2
72
+ # A session shorter than this is a failure (auth/crash loop) — back off.
73
+ # 이보다 짧은 세션은 실패로 간주 — 백오프 적용.
74
+ SHORT_SESSION = 1.0
75
+ CONNECT_TIMEOUT = 3
76
+
77
+ DRAIN_REASONS = %w[not_active auth_failed no_leader].freeze
51
78
 
52
79
  attr_reader :addr
53
80
 
54
- def initialize(addr, logger)
81
+ def initialize(addr, index, token, ssl_context, verify_host, topo, logger)
55
82
  @addr = addr
83
+ @index = index
84
+ @token = token
85
+ @ssl_context = ssl_context
86
+ @verify_host = verify_host
87
+ @topo = topo
56
88
  @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 = {}
89
+ @status = STATUS_DOWN
90
+ @session = nil
91
+ @ever_connected = false
92
+ Thread.new { run_loop }
67
93
  end
68
94
 
69
- # True while at least one reply can currently be sent and received.
70
- def usable?
71
- @status == :up
95
+ def usable? = @status == STATUS_UP
96
+
97
+ # Give a drained (non-leader) connection another chance. 드레인 연결 부활.
98
+ def revive
99
+ @status = STATUS_UP if @status == STATUS_DRAINING
72
100
  end
73
101
 
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.
102
+ # Send one request; pop the returned queue for the reply (array) or a
103
+ # TicketError. 요청 전송 반환 큐를 pop해 응답/오류 수신.
77
104
  def request(op, key, frame)
78
- waiter = Waiter.new
79
- @writer_mutex.synchronize do
80
- w = @writer or raise Ticketing.closed_error
105
+ resolver = Queue.new
106
+ session = @session
107
+ if session.nil?
108
+ resolver << TicketError.aborted
109
+ return resolver
110
+ end
111
+ begin
112
+ session.queue.push([op, key, frame, resolver]) # blocking push = backpressure. 백프레셔.
113
+ rescue ClosedQueueError
114
+ resolver << TicketError.aborted
115
+ return resolver
116
+ end
117
+ session.abort_queued if session.closed
118
+ resolver
119
+ end
81
120
 
82
- @pending_mutex.synchronize do
83
- (waiters(op)[key] ||= []).push(waiter)
84
- end
121
+ private
122
+
123
+ def logf(message) = @logger&.warn(message)
85
124
 
125
+ def run_loop
126
+ backoff = BACKOFF_MIN
127
+ loop do
128
+ healthy = false
86
129
  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?
130
+ socket = dial
131
+ begin
132
+ parser = Protocol::FrameParser.new
133
+ handshake(socket, parser)
134
+ logf("reconnected: #{@addr}") if @ever_connected
135
+ @ever_connected = true
136
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
+ run_session(socket, parser)
138
+ healthy = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started >= SHORT_SESSION
139
+ logf("connection lost: #{@addr}")
140
+ ensure
141
+ begin
142
+ socket.close
143
+ rescue StandardError
144
+ nil
95
145
  end
96
146
  end
97
- raise IoError.new(e)
147
+ rescue StandardError => e
148
+ logf("connect failed: #{@addr} (#{e.message}) — retry in #{(backoff * 1000).to_i}ms")
149
+ end
150
+ if healthy
151
+ backoff = BACKOFF_MIN
152
+ else
153
+ sleep(backoff)
154
+ backoff = [backoff * 2, BACKOFF_MAX].min
98
155
  end
99
156
  end
100
- waiter.await
101
157
  end
102
158
 
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?
159
+ def dial
160
+ host, _, port = @addr.rpartition(":")
161
+ raise TicketError.new(:invalid_input, "address must be host:port — #{@addr}") if host.empty?
110
162
 
111
- @socket = sock
112
- @writer = sock
113
- @status = :up
114
- @logger.warn("reconnected: #{@addr}") if ever_connected
115
- ever_connected = true
163
+ socket = Socket.tcp(host, Integer(port), connect_timeout: CONNECT_TIMEOUT)
164
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
165
+ return socket if @ssl_context.nil?
116
166
 
117
- begin
118
- read_loop(sock)
119
- rescue StandardError
120
- nil
121
- end
167
+ ssl = OpenSSL::SSL::SSLSocket.new(socket, @ssl_context)
168
+ ssl.hostname = host
169
+ ssl.sync_close = true
170
+ ssl.connect
171
+ ssl.post_connection_check(host) if @verify_host
172
+ ssl
173
+ end
122
174
 
123
- teardown(sock)
124
- @logger.warn("connection lost: #{@addr} — reconnecting every #{RECONNECT_INTERVAL}s")
125
- sleep RECONNECT_INTERVAL if @running
126
- end
175
+ # Answer the `"<hash> <nonce>"` challenge. challenge 응답.
176
+ def handshake(socket, parser)
177
+ line = nil
178
+ while line.nil?
179
+ parser.feed(socket.readpartial(4096))
180
+ line = parser.next_line
127
181
  end
182
+ space = line.index(" ")
183
+ raise TicketError.new(:protocol, "bad challenge") if space.nil? || space.zero?
184
+
185
+ socket.write(Protocol.auth_digest(@token, line.byteslice(space + 1..)) + "\n")
128
186
  end
129
187
 
130
- # Stops the background thread, closes the socket, and fails all waiters.
131
- def stop
132
- @running = false
133
- @status = :down
134
- thread = @thread
188
+ # One live session. Teardown aborts every in-flight request so the broker
189
+ # retries elsewhere. 세션 하나 — 종료 시 진행 중 요청 전부 중단.
190
+ def run_session(socket, parser)
191
+ session = Session.new
192
+ @session = session
193
+ @status = STATUS_UP
194
+ writer = Thread.new { write_loop(session, socket) }
195
+ read_loop(session, socket, parser)
196
+ ensure
197
+ session.closed = true
198
+ @session = nil
199
+ @status = STATUS_DOWN
200
+ @topo.clear_leader_if(@index)
201
+ session.queue.close
135
202
  begin
136
- @socket&.close
203
+ socket.close
137
204
  rescue StandardError
138
205
  nil
139
206
  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
207
+ writer&.join(1)
208
+ session.abort_queued
209
+ session.fail_all
158
210
  end
159
211
 
160
- def read_loop(sock)
212
+ # Register pending then write, in queue order — same-key FIFO holds because
213
+ # this is the single consumer. 단일 소비자가 등록→쓰기 순서 보장(같은 키 FIFO).
214
+ def write_loop(session, socket)
215
+ batch = []
161
216
  loop do
162
- frame =
163
- begin
164
- Protocol.read_frame(sock) { |op| Protocol.reply_fixed(op) }
165
- rescue IOError, SystemCallError
166
- return
217
+ first = session.queue.pop
218
+ return if first.nil? # closed queue. 큐 닫힘.
219
+
220
+ batch.clear
221
+ batch << first
222
+ while batch.size < WRITE_BATCH
223
+ item = begin
224
+ session.queue.pop(true)
225
+ rescue ThreadError
226
+ break
167
227
  end
228
+ break if item.nil?
168
229
 
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)
230
+ batch << item
231
+ end
232
+ data = +"".b
233
+ batch.each do |op, key, frame, resolver|
234
+ session.register(op, key, resolver)
235
+ data << frame
183
236
  end
237
+ socket.write(data)
238
+ end
239
+ rescue StandardError
240
+ begin
241
+ socket.close
242
+ rescue StandardError
243
+ nil
184
244
  end
185
245
  end
186
246
 
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
247
+ def read_loop(session, socket, parser)
248
+ loop do
249
+ while (frame = parser.next_frame)
250
+ handle_reply(session, frame) unless frame.empty?
205
251
  end
252
+ parser.feed(socket.readpartial(65_536))
206
253
  end
254
+ rescue EOFError, IOError, SystemCallError, OpenSSL::SSL::SSLError
255
+ nil
207
256
  end
208
257
 
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
258
+ def handle_reply(session, frame)
259
+ op, text, token = Protocol.parse_reply(frame)
260
+ if op.nil?
261
+ logf("unrecognized reply from #{@addr}")
262
+ return
263
+ end
264
+ case op
265
+ when "A" then session.resolve(Protocol::OP_ACQUIRE, text, [:acquired, token])
266
+ when "T" then session.resolve(Protocol::OP_ACQUIRE, text, [:timed_out, 0])
267
+ when "R" then session.resolve(Protocol::OP_RELEASE, text, [:released, 0])
268
+ when "N" then session.resolve(Protocol::OP_RELEASE, text, [:not_found, 0])
269
+ when "M"
270
+ logf("not leader: #{@addr} (leader is #{text})")
271
+ @topo.set_leader(text)
272
+ drain(session)
273
+ when "E"
274
+ logf("server error from #{@addr}: #{text}")
275
+ if DRAIN_REASONS.include?(text)
276
+ @topo.clear_leader_if(@index) if text == "no_leader"
277
+ drain(session)
219
278
  end
220
- waiter&.complete(reply)
279
+ end
221
280
  end
222
281
 
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
282
+ # Stop routing here; abort in-flight so the broker retries elsewhere.
283
+ # 라우팅 중지 + 진행 중 요청 중단 → 브로커가 다른 노드로 재시도.
284
+ def drain(session)
285
+ @status = STATUS_DRAINING
286
+ session.fail_all
232
287
  end
233
288
 
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
289
+ # In-flight requests per (op, key), FIFO — matches server reply order.
290
+ # (op, key)별 FIFO 대기 — 서버 응답 순서와 일치.
291
+ class Session
292
+ attr_reader :queue
293
+ attr_accessor :closed
294
+
295
+ def initialize
296
+ @queue = SizedQueue.new(REQ_QUEUE)
297
+ @closed = false
298
+ @mutex = Mutex.new
299
+ @acquire = {}
300
+ @release = {}
301
+ end
302
+
303
+ def map(op) = op == Protocol::OP_ACQUIRE ? @acquire : @release
304
+
305
+ def register(op, key, resolver)
306
+ @mutex.synchronize { (map(op)[key] ||= []) << resolver }
307
+ end
308
+
309
+ def resolve(op, key, reply)
310
+ resolver = @mutex.synchronize do
311
+ pending = map(op)[key]
312
+ next nil if pending.nil? || pending.empty?
313
+
314
+ first = pending.shift
315
+ map(op).delete(key) if pending.empty?
316
+ first
243
317
  end
244
- orphans.each { |waiter| waiter.fail(Ticketing.closed_error) }
245
- end
318
+ resolver << reply unless resolver.nil?
319
+ end
246
320
 
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}'"
321
+ # Abort every in-flight request so the broker retries elsewhere.
322
+ # 진행 중 요청 전부 중단 → 브로커가 다른 노드로 재시도.
323
+ def fail_all
324
+ victims = @mutex.synchronize do
325
+ all = (@acquire.values + @release.values).flatten
326
+ @acquire.clear
327
+ @release.clear
328
+ all
329
+ end
330
+ victims.each { |resolver| resolver << TicketError.aborted }
251
331
  end
252
332
 
253
- [addr[0...i], addr[(i + 1)..].to_i]
333
+ def abort_queued
334
+ loop do
335
+ item = begin
336
+ @queue.pop(true)
337
+ rescue ThreadError
338
+ break
339
+ end
340
+ break if item.nil?
341
+
342
+ item[3] << TicketError.aborted
343
+ end
344
+ end
254
345
  end
255
346
  end
256
347
  end
@@ -1,64 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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}")
4
+ # Client error with a machine-checkable kind. 종류를 코드로 판별할 수 있는 오류.
5
+ #
6
+ # Kinds — :timeout (wait 안에 획득 실패) / :not_connected (연결 없음) /
7
+ # :auth (토큰 거부) / :invalid_input (잘못된 입력) / :protocol (규격 외 응답) /
8
+ # :io (전송 오류) / :connection_aborted (장애조치 중단 — 내부 재시도).
9
+ class TicketError < StandardError
10
+ attr_reader :kind
11
+
12
+ def initialize(kind, message)
13
+ super(message)
14
+ @kind = kind
28
15
  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
16
 
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
17
+ def self.aborted = new(:connection_aborted, "connection closed")
59
18
 
60
- # An {IoError} standing for a closed connection.
61
- def self.closed_error
62
- IoError.new(ConnectionClosed.new)
19
+ def aborted? = kind == :connection_aborted
63
20
  end
64
21
  end