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,177 +1,103 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'errors'
3
+ require "digest"
4
4
 
5
5
  module Ticketing
6
- # Wire encoding and decoding for the binary ticket-lock protocol. The byte
7
- # layout matches every other client exactly.
6
+ # Wire protocol byte-compatible with ticketing-server/docs/protocal_client.md.
7
+ # 와이어 프로토콜 서버 문서 protocal_client.md와 바이트 호환.
8
8
  module Protocol
9
- module_function
9
+ MAX_KEY_LEN = 128
10
+ MAX_LINE_LEN = 192
11
+ MAX_HANDSHAKE_LINE = 256
10
12
 
11
13
  OP_ACQUIRE = "A".ord
12
14
  OP_RELEASE = "R".ord
13
15
  NL = "\n".ord
14
16
 
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)
17
+ module_function
34
18
 
35
- def self.data(bytes)
36
- Data.new(bytes)
37
- end
19
+ # base64url(SHA-256(token ∥ nonce)). 인증 응답 다이제스트.
20
+ def auth_digest(token, nonce)
21
+ [Digest::SHA256.digest(token.b + nonce)].pack("m0").tr("+/", "-_").delete("=")
38
22
  end
39
23
 
40
- # Number of fixed bytes that follow the op byte of a reply.
41
- def reply_fixed(op)
42
- op == OP_ACQUIRE ? 8 : 0
24
+ # Key: 1..=128 UTF-8 bytes, no space/newline. 규칙 검증.
25
+ def validate_key!(key)
26
+ bytes = key.bytesize
27
+ raise TicketError.new(:invalid_input, "key must be 1..=128 bytes") if bytes.zero? || bytes > MAX_KEY_LEN
28
+ raise TicketError.new(:invalid_input, "key must not contain spaces or newlines") if key.match?(/[ \n\r]/)
43
29
  end
44
30
 
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
31
+ # `A[wait:u16][lease:u16]key\n` seconds rounded up, lease 1s. 올림, lease 최소 1초.
32
+ def acquire_frame(key, wait_secs, lease_secs)
33
+ [OP_ACQUIRE, ceil_secs_u16(wait_secs, 0), ceil_secs_u16(lease_secs, 1)].pack("CS>S>") + key.b + "\n".b
58
34
  end
59
35
 
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
36
+ # `Rkey\n`
37
+ def release_frame(key) = "R".b + key.b + "\n".b
77
38
 
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
39
+ def ceil_secs_u16(secs, min) = secs.ceil.clamp(min, 0xFFFF)
87
40
 
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?
41
+ # A parsed server reply: [op, text, token]. 파싱된 서버 응답.
42
+ def parse_reply(frame)
43
+ return nil if frame.empty?
92
44
 
93
- op = body.getbyte(0)
94
- tail = body.byteslice(1, body.bytesize - 1) || "".b
45
+ op = frame[0]
95
46
  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))
47
+ when "A"
48
+ return nil if frame.bytesize < 9
49
+
50
+ [op, frame.byteslice(9..).force_encoding(Encoding::UTF_8), frame.byteslice(1, 8).unpack1("Q>")]
51
+ when "T", "R", "N", "M", "E"
52
+ [op, frame.byteslice(1..).force_encoding(Encoding::UTF_8), 0]
106
53
  end
107
54
  end
108
55
 
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
56
+ # Incremental frame parser fed by socket chunks. The `A` fixed header
57
+ # (8-byte token) is consumed by count because it may contain 0x0a;
58
+ # over-long frames are skipped. 소켓 청크를 먹는 증분 파서 A 8바이트
59
+ # 토큰은 개수 기준(0x0a 포함 가능), 초과 프레임 스킵.
60
+ class FrameParser
61
+ def initialize
62
+ @buf = +"".b
130
63
  end
131
64
 
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
65
+ def feed(chunk) = @buf << chunk
138
66
 
139
- unless too_long
140
- tail << b
141
- too_long = true if head.bytesize + tail.bytesize > MAX_LINE_LEN
67
+ # Pops the handshake line without `\n`, or nil if incomplete. 핸드셰이크 한 줄.
68
+ def next_line
69
+ nl = @buf.index("\n")
70
+ if nl.nil?
71
+ raise TicketError.new(:protocol, "handshake line too long") if @buf.bytesize > MAX_HANDSHAKE_LINE
72
+
73
+ return nil
142
74
  end
75
+ line = @buf.byteslice(0, nl)
76
+ @buf = @buf.byteslice(nl + 1..)
77
+ line
143
78
  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
79
 
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
80
+ # Pops the next complete frame (without `\n`), "" for keep-alive, or nil
81
+ # if incomplete. 다음 프레임 — keep-alive는 "", 미완성은 nil.
82
+ def next_frame
83
+ loop do
84
+ return nil if @buf.empty?
85
+
86
+ if @buf.getbyte(0) == NL
87
+ @buf = @buf.byteslice(1..)
88
+ return "".b
89
+ end
90
+ fixed = @buf.getbyte(0) == OP_ACQUIRE ? 8 : 0
91
+ nl = @buf.index("\n", 1 + fixed)
92
+ return nil if nl.nil?
93
+
94
+ frame = @buf.byteslice(0, nl)
95
+ @buf = @buf.byteslice(nl + 1..)
96
+ next if frame.bytesize > 1 + fixed + MAX_LINE_LEN # skip over-long. 초과 스킵.
97
+
98
+ return frame
99
+ end
100
+ end
175
101
  end
176
102
  end
177
103
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ticketing
4
- VERSION = "0.0.2"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/ticketing.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
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'
3
+ # Client for a network distributed lock system. Create one TicketBroker,
4
+ # share it across threads, and call acquire. Connections self-heal in the
5
+ # background — a broken connection never breaks the broker itself.
6
+ # 네트워크 분산 락 시스템 클라이언트. TicketBroker 하나를 스레드들이 공유하고
7
+ # acquire를 호출한다. 연결은 자가 치유되며 브로커 객체를 망가뜨리지 않는다.
8
8
 
9
- # Client for a network distributed-lock ("ticket") service. See {Ticketing::Broker}.
10
- module Ticketing
11
- end
9
+ require_relative "ticketing/version"
10
+ require_relative "ticketing/errors"
11
+ require_relative "ticketing/protocol"
12
+ require_relative "ticketing/connection"
13
+ require_relative "ticketing/broker"
data/ticketing.gemspec CHANGED
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.description = "Distributed ticket lock client"
13
13
  spec.homepage = "https://ticketing.saro.me"
14
14
  spec.license = "MIT"
15
- spec.required_ruby_version = ">= 2.7.0"
15
+ spec.required_ruby_version = ">= 3.2.0"
16
16
 
17
17
  spec.metadata["homepage_uri"] = spec.homepage
18
18
  spec.metadata["source_code_uri"] = "https://github.com/saro-lab/ticketing"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ticketing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - marker
@@ -45,10 +45,6 @@ extensions: []
45
45
  extra_rdoc_files: []
46
46
  files:
47
47
  - ".gitignore"
48
- - ".idea/.gitignore"
49
- - ".idea/modules.xml"
50
- - ".idea/ticketing-ruby.iml"
51
- - ".idea/vcs.xml"
52
48
  - ".ruby-version"
53
49
  - Gemfile
54
50
  - LICENSE
@@ -76,7 +72,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
76
72
  requirements:
77
73
  - - ">="
78
74
  - !ruby/object:Gem::Version
79
- version: 2.7.0
75
+ version: 3.2.0
80
76
  required_rubygems_version: !ruby/object:Gem::Requirement
81
77
  requirements:
82
78
  - - ">="
data/.idea/.gitignore DELETED
@@ -1,10 +0,0 @@
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 DELETED
@@ -1,8 +0,0 @@
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>
@@ -1,18 +0,0 @@
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 DELETED
@@ -1,6 +0,0 @@
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>