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
data/lib/ticketing/protocol.rb
CHANGED
|
@@ -1,177 +1,103 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "digest"
|
|
4
4
|
|
|
5
5
|
module Ticketing
|
|
6
|
-
# Wire
|
|
7
|
-
#
|
|
6
|
+
# Wire protocol — byte-compatible with ticketing-server/docs/protocal_client.md.
|
|
7
|
+
# 와이어 프로토콜 — 서버 문서 protocal_client.md와 바이트 호환.
|
|
8
8
|
module Protocol
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
#
|
|
41
|
-
def
|
|
42
|
-
|
|
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
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
#
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
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 =
|
|
94
|
-
tail = body.byteslice(1, body.bytesize - 1) || "".b
|
|
45
|
+
op = frame[0]
|
|
95
46
|
case op
|
|
96
|
-
when "A"
|
|
97
|
-
return nil if
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
data/lib/ticketing/version.rb
CHANGED
data/lib/ticketing.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
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
|
|
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.
|
|
75
|
+
version: 3.2.0
|
|
80
76
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
81
77
|
requirements:
|
|
82
78
|
- - ">="
|
data/.idea/.gitignore
DELETED
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>
|
data/.idea/ticketing-ruby.iml
DELETED
|
@@ -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>
|