cent 3.0.0 → 5.0.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/CHANGELOG.md +93 -0
- data/MIGRATING.md +305 -0
- data/README.md +695 -169
- data/lib/cent/auto_batch.rb +305 -0
- data/lib/cent/batch.rb +209 -0
- data/lib/cent/broadcast.rb +117 -0
- data/lib/cent/client.rb +604 -230
- data/lib/cent/command.rb +64 -0
- data/lib/cent/endpoints.rb +191 -0
- data/lib/cent/errors.rb +392 -0
- data/lib/cent/generated/methods.rb +4226 -0
- data/lib/cent/generated/proxy_types.rb +1834 -0
- data/lib/cent/generated/types.rb +2844 -0
- data/lib/cent/handler.rb +179 -0
- data/lib/cent/http.rb +236 -32
- data/lib/cent/message.rb +52 -0
- data/lib/cent/pages.rb +165 -0
- data/lib/cent/payload.rb +93 -0
- data/lib/cent/proxy.rb +254 -0
- data/lib/cent/reply.rb +109 -0
- data/lib/cent/retry.rb +53 -0
- data/lib/cent/shared_poll.rb +61 -0
- data/lib/cent/token.rb +560 -0
- data/lib/cent/version.rb +2 -1
- data/lib/cent/wire.rb +222 -0
- data/lib/cent.rb +33 -5
- metadata +38 -56
- data/.github/workflows/main.yml +0 -20
- data/.github/workflows/release.yml +0 -37
- data/.gitignore +0 -20
- data/.rspec +0 -3
- data/.rubocop.yml +0 -13
- data/Gemfile +0 -17
- data/Rakefile +0 -12
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/cent.gemspec +0 -35
- data/lib/cent/error.rb +0 -9
- data/lib/cent/notary.rb +0 -96
data/lib/cent/handler.rb
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Cent
|
|
6
|
+
# A request to a handler, as the application's block receives it.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] method
|
|
9
|
+
# @return [String] the HTTP method, upper case
|
|
10
|
+
# @!attribute [r] headers
|
|
11
|
+
# @return [Hash{String => String}] the headers, lowercase names: those
|
|
12
|
+
# Centrifugo passes on from the client's connection to a proxy, the client
|
|
13
|
+
# SDK's to an endpoint
|
|
14
|
+
# @!attribute [r] query
|
|
15
|
+
# @return [Hash{String => String}] the query parameters, the first value of
|
|
16
|
+
# each
|
|
17
|
+
# @!attribute [r] path
|
|
18
|
+
# @return [String] the path, as the server saw it
|
|
19
|
+
# @!attribute [r] raw
|
|
20
|
+
# @return [Object] the server's own form of the request: the Rack env, or
|
|
21
|
+
# what was given to {Handler#handle} - for what the core does not carry,
|
|
22
|
+
# such as the client's address or the session
|
|
23
|
+
class HTTPRequest < Data.define(:method, :headers, :query, :path, :raw); end
|
|
24
|
+
|
|
25
|
+
# A handler's answer: +[status, headers, body]+ with {#to_a}.
|
|
26
|
+
#
|
|
27
|
+
# @!attribute [r] status
|
|
28
|
+
# @return [Integer]
|
|
29
|
+
# @!attribute [r] headers
|
|
30
|
+
# @return [Hash{String => String}] lowercase names, as Rack 3 wants them
|
|
31
|
+
# @!attribute [r] body
|
|
32
|
+
# @return [String]
|
|
33
|
+
class HTTPResponse < Data.define(:status, :headers, :body)
|
|
34
|
+
# @return [Array] +[status, headers, body]+
|
|
35
|
+
def to_a = [status, headers, body]
|
|
36
|
+
|
|
37
|
+
# @api private
|
|
38
|
+
def self.text(status, message, extra = {})
|
|
39
|
+
new(status:, headers: { "content-type" => "text/plain; charset=utf-8", **extra }, body: message)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @api private
|
|
43
|
+
REASONS = { 400 => "Bad Request", 403 => "Forbidden", 405 => "Method Not Allowed",
|
|
44
|
+
500 => "Internal Server Error" }.freeze
|
|
45
|
+
|
|
46
|
+
# @api private
|
|
47
|
+
def self.status_text(status) = text(status, REASONS.fetch(status, status.to_s))
|
|
48
|
+
|
|
49
|
+
# @api private
|
|
50
|
+
def self.allow(methods) = text(405, "only #{methods} allowed", { "allow" => methods })
|
|
51
|
+
|
|
52
|
+
# @api private
|
|
53
|
+
def self.json(body, cache = "no-store")
|
|
54
|
+
new(status: 200, headers: { "content-type" => "application/json", "cache-control" => cache }, body:)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The base of every handler - an endpoint for client SDKs, the JWKS endpoint,
|
|
59
|
+
# a proxy handler. Its framework-free core, {#handle}, turns a request into a
|
|
60
|
+
# response; {#call} serves it as a Rack app, which Rails, Sinatra, Hanami and
|
|
61
|
+
# every Rack server mount:
|
|
62
|
+
#
|
|
63
|
+
# mount handler, at: "/centrifugo/connect" # config/routes.rb
|
|
64
|
+
#
|
|
65
|
+
# Any other server calls {#handle}.
|
|
66
|
+
class Handler
|
|
67
|
+
# @return [Integer] the largest request body read, in bytes
|
|
68
|
+
attr_reader :max_body
|
|
69
|
+
|
|
70
|
+
# @api private
|
|
71
|
+
def initialize(max_body, on_error, &func)
|
|
72
|
+
raise ArgumentError, "#{self.class} takes a block" if func.nil? && !answers_itself?
|
|
73
|
+
raise ArgumentError, "on_error must respond to call" unless on_error.nil? || on_error.respond_to?(:call)
|
|
74
|
+
unless max_body.is_a?(Integer) && max_body.positive?
|
|
75
|
+
raise ArgumentError,
|
|
76
|
+
"max_body must be a positive number of bytes"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@max_body = max_body
|
|
80
|
+
@on_error = on_error
|
|
81
|
+
@func = func
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Answers a request.
|
|
85
|
+
#
|
|
86
|
+
# @param method [String]
|
|
87
|
+
# @param headers [Hash{String => String}, Array<Array(String, String)>]
|
|
88
|
+
# @param body [String]
|
|
89
|
+
# @param query [Hash{String => String}]
|
|
90
|
+
# @param path [String]
|
|
91
|
+
# @param raw [Object] passed on as {HTTPRequest#raw}: the server's own
|
|
92
|
+
# request object
|
|
93
|
+
# @return [HTTPResponse]
|
|
94
|
+
def handle(method, headers = {}, body = "", query: {}, path: "", raw: nil)
|
|
95
|
+
h = {}
|
|
96
|
+
headers.each do |k, v|
|
|
97
|
+
k = k.to_s.downcase
|
|
98
|
+
h[k] = h.key?(k) ? "#{h[k]}, #{v}" : v.to_s
|
|
99
|
+
end
|
|
100
|
+
req = HTTPRequest.new(method: method.to_s.upcase, headers: h.freeze, query: query.to_h.freeze, path:, raw:)
|
|
101
|
+
return failure(req, 400, ArgumentError.new("request body over #{max_body} bytes")) if body.bytesize > max_body
|
|
102
|
+
|
|
103
|
+
begun = start(req, body)
|
|
104
|
+
return begun if begun.is_a?(HTTPResponse)
|
|
105
|
+
|
|
106
|
+
begin
|
|
107
|
+
out = @func.call(*begun, req)
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
return finish(req, begun, nil, e)
|
|
110
|
+
end
|
|
111
|
+
finish(req, begun, out, nil)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The handler as a Rack app.
|
|
115
|
+
#
|
|
116
|
+
# @param env [Hash] the Rack env
|
|
117
|
+
# @return [Array] +[status, headers, [body]]+
|
|
118
|
+
def call(env)
|
|
119
|
+
body = read_body(env)
|
|
120
|
+
headers = env.filter_map { |k, v| [k.delete_prefix("HTTP_").tr("_", "-"), v] if k.start_with?("HTTP_") }
|
|
121
|
+
headers << ["content-type", env["CONTENT_TYPE"]] if env["CONTENT_TYPE"]
|
|
122
|
+
headers << ["content-length", env["CONTENT_LENGTH"]] if env["CONTENT_LENGTH"]
|
|
123
|
+
query = {}
|
|
124
|
+
URI.decode_www_form(env["QUERY_STRING"].to_s).each { |k, v| query[k] ||= v }
|
|
125
|
+
resp = handle(env["REQUEST_METHOD"], headers, body, query:, path: env["PATH_INFO"].to_s, raw: env)
|
|
126
|
+
[resp.status, resp.headers.dup, [resp.body]]
|
|
127
|
+
rescue ArgumentError => e
|
|
128
|
+
# A malformed query string.
|
|
129
|
+
resp = failure(HTTPRequest.new(method: env["REQUEST_METHOD"].to_s, headers: {}, query: {}, path: "", raw: env),
|
|
130
|
+
400, e)
|
|
131
|
+
[resp.status, resp.headers.dup, [resp.body]]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def answers_itself? = false
|
|
137
|
+
|
|
138
|
+
# The block's arguments before the request, or the response when the
|
|
139
|
+
# request is answered without calling it.
|
|
140
|
+
def start(req, body) = raise NotImplementedError
|
|
141
|
+
|
|
142
|
+
# The response to what the block returned or raised.
|
|
143
|
+
def finish(req, args, out, error) = raise NotImplementedError
|
|
144
|
+
|
|
145
|
+
# +status+, after telling on_error of +err+.
|
|
146
|
+
def failure(req, status, err)
|
|
147
|
+
@on_error&.call(req, err)
|
|
148
|
+
HTTPResponse.status_text(status)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def read_body(env)
|
|
152
|
+
input = env["rack.input"]
|
|
153
|
+
return "" if input.nil?
|
|
154
|
+
|
|
155
|
+
# A framework may have read it already: Rails does.
|
|
156
|
+
input.rewind if input.respond_to?(:rewind)
|
|
157
|
+
(input.read(max_body + 1) || "").b
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# err, or its cause, when it is a klass.
|
|
161
|
+
def find(err, klass)
|
|
162
|
+
while err
|
|
163
|
+
return err if err.is_a?(klass)
|
|
164
|
+
|
|
165
|
+
err = err.cause
|
|
166
|
+
end
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_json(req, body)
|
|
171
|
+
d = JSON.parse(body, allow_nan: false)
|
|
172
|
+
return failure(req, 400, ArgumentError.new("malformed request: not a JSON object")) unless d.is_a?(Hash)
|
|
173
|
+
|
|
174
|
+
d
|
|
175
|
+
rescue JSON::ParserError, EncodingError => e
|
|
176
|
+
failure(req, 400, ArgumentError.new("malformed request: #{e.message}"))
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
data/lib/cent/http.rb
CHANGED
|
@@ -1,47 +1,251 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
4
6
|
|
|
5
7
|
module Cent
|
|
6
|
-
#
|
|
8
|
+
# The default HTTP transport: Net::HTTP connections kept alive in a pool, each
|
|
9
|
+
# request bounded by one deadline through Net::HTTP's public API: the time
|
|
10
|
+
# left as its open, read and write timeouts, and again as the read timeout
|
|
11
|
+
# before each chunk of the body. Those bound each read, so a reply's headers
|
|
12
|
+
# trickling in would outlast them: the Watchdog finishes a connection whose
|
|
13
|
+
# deadline passed.
|
|
7
14
|
#
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
# @api private
|
|
16
|
+
module HTTP
|
|
17
|
+
# Up to 256 idle connections to a host are kept, so that a burst does not
|
|
18
|
+
# open connections only to close them; idle ones are closed after 50
|
|
19
|
+
# seconds, before load balancers commonly close theirs.
|
|
20
|
+
MAX_IDLE = 256
|
|
21
|
+
IDLE_TIMEOUT = 50.0
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
# The body of an unexpected status is read up to MAX_API_ERROR_BYTES for a
|
|
24
|
+
# Centrifugo error in it, MAX_ERROR_BODY_BYTES of it kept for the HTTPError,
|
|
25
|
+
# and the rest drained up to MAX_DRAIN_BYTES, so that the connection is
|
|
26
|
+
# reused.
|
|
27
|
+
MAX_ERROR_BODY_BYTES = 512
|
|
28
|
+
MAX_API_ERROR_BYTES = 4 << 10
|
|
29
|
+
MAX_DRAIN_BYTES = 64 << 10
|
|
18
30
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
def self.now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
32
|
+
|
|
33
|
+
# Finishes the connections whose request's deadline passed, which wakes a
|
|
34
|
+
# read blocked on them with an IOError. One thread for every pool, started
|
|
35
|
+
# when first needed, and again in a forked process.
|
|
36
|
+
class Watchdog
|
|
37
|
+
Entry = Struct.new(:conn, :deadline, :fired)
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@fork_lock = Mutex.new
|
|
41
|
+
reset
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the block's value, with +conn+ finished if +deadline+ passes
|
|
45
|
+
# first. An error raised once it was finished - an IOError from the read
|
|
46
|
+
# it woke, or whatever Net::HTTP raises on a connection finished under
|
|
47
|
+
# it - is the deadline's.
|
|
48
|
+
def watch(conn, deadline)
|
|
49
|
+
reset_after_fork
|
|
50
|
+
entry = Entry.new(conn, deadline, false)
|
|
51
|
+
@lock.synchronize do
|
|
52
|
+
@thread = Thread.new { run } unless @thread&.alive?
|
|
53
|
+
@entries << entry
|
|
54
|
+
@wake.signal
|
|
55
|
+
end
|
|
56
|
+
begin
|
|
57
|
+
yield
|
|
58
|
+
rescue StandardError
|
|
59
|
+
raise Net::ReadTimeout, "the call's deadline passed" if fired?(entry)
|
|
60
|
+
|
|
61
|
+
raise
|
|
62
|
+
ensure
|
|
63
|
+
# Under the lock: once removed, the connection is never finished
|
|
64
|
+
# again, so that it goes back to the pool safely.
|
|
65
|
+
@lock.synchronize { @entries.delete(entry) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# A forked process starts with fresh state: the parent's thread did not
|
|
72
|
+
# survive the fork, its entries are the parent's connections, and its
|
|
73
|
+
# lock may have been held by a thread which no longer exists - taking it
|
|
74
|
+
# would block forever.
|
|
75
|
+
def reset_after_fork
|
|
76
|
+
return if @pid == ::Process.pid
|
|
77
|
+
|
|
78
|
+
@fork_lock.synchronize do
|
|
79
|
+
reset unless @pid == ::Process.pid
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reset
|
|
84
|
+
@lock = Mutex.new
|
|
85
|
+
@wake = ConditionVariable.new
|
|
86
|
+
@entries = []
|
|
87
|
+
@thread = nil
|
|
88
|
+
@pid = ::Process.pid
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def fired?(entry) = @lock.synchronize { entry.fired }
|
|
92
|
+
|
|
93
|
+
def run
|
|
94
|
+
Thread.current.name = "rubycent-watchdog"
|
|
95
|
+
@lock.synchronize do
|
|
96
|
+
loop do
|
|
97
|
+
now = HTTP.now
|
|
98
|
+
next_deadline = nil
|
|
99
|
+
@entries.each do |e|
|
|
100
|
+
next if e.fired
|
|
25
101
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
102
|
+
if e.deadline <= now
|
|
103
|
+
e.fired = true
|
|
104
|
+
finish(e.conn)
|
|
105
|
+
elsif next_deadline.nil? || e.deadline < next_deadline
|
|
106
|
+
next_deadline = e.deadline
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
@wake.wait(@lock, next_deadline && (next_deadline - now))
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def finish(conn)
|
|
115
|
+
conn.finish
|
|
116
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
30
119
|
end
|
|
31
120
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
121
|
+
WATCHDOG = Watchdog.new
|
|
122
|
+
|
|
123
|
+
# Connections kept alive per scheme, host and port. Safe for concurrent use.
|
|
124
|
+
class Pool
|
|
125
|
+
# @param options [Hash] Net::HTTP settings for every connection: ca_file,
|
|
126
|
+
# cert, key, verify_mode, local_host and the like
|
|
127
|
+
def initialize(options = {})
|
|
128
|
+
@options = options
|
|
129
|
+
@idle = Hash.new { |h, k| h[k] = [] }
|
|
130
|
+
@lock = Mutex.new
|
|
131
|
+
@closed = false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [Hash] the Net::HTTP settings of its connections
|
|
135
|
+
attr_reader :options
|
|
136
|
+
|
|
137
|
+
# Sends a POST, and returns [status, headers, body]: the body of a
|
|
138
|
+
# status other than 200 read up to MAX_API_ERROR_BYTES.
|
|
139
|
+
def post(url, headers, body, deadline)
|
|
140
|
+
uri = URI.parse(url)
|
|
141
|
+
key = [uri.scheme, uri.host, uri.port]
|
|
142
|
+
conn = checkout(key, uri)
|
|
143
|
+
reusable = false
|
|
144
|
+
begin
|
|
145
|
+
out, reusable = WATCHDOG.watch(conn, deadline) { exchange(conn, uri, headers, body, deadline) }
|
|
146
|
+
reusable &&= conn.started?
|
|
147
|
+
out
|
|
148
|
+
ensure
|
|
149
|
+
reusable ? checkin(key, conn) : discard(conn)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def close
|
|
154
|
+
conns = @lock.synchronize do
|
|
155
|
+
@closed = true
|
|
156
|
+
all = @idle.values.flatten(1).map(&:first)
|
|
157
|
+
@idle.clear
|
|
158
|
+
all
|
|
159
|
+
end
|
|
160
|
+
conns.each { discard(it) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# Sends the request on +conn+, and returns [[status, headers, body],
|
|
166
|
+
# whether the connection may be reused].
|
|
167
|
+
def exchange(conn, uri, headers, body, deadline)
|
|
168
|
+
left = time_left(deadline)
|
|
169
|
+
conn.open_timeout = left
|
|
170
|
+
conn.read_timeout = left
|
|
171
|
+
conn.write_timeout = left
|
|
172
|
+
conn.start unless conn.started?
|
|
173
|
+
req = Net::HTTP::Post.new(uri.request_uri, headers)
|
|
174
|
+
req.body = body
|
|
175
|
+
out = nil
|
|
176
|
+
reusable = false
|
|
177
|
+
catch(:drop) do
|
|
178
|
+
conn.request(req) do |res|
|
|
179
|
+
out = [res.code.to_i, res.to_hash.transform_values { it.join(", ") }, +""]
|
|
180
|
+
read(conn, res, out[2], deadline)
|
|
181
|
+
end
|
|
182
|
+
reusable = true
|
|
183
|
+
end
|
|
184
|
+
[out, reusable]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Reads the body into +body+: whole for a 200, up to MAX_API_ERROR_BYTES
|
|
188
|
+
# otherwise, the rest drained so that the connection is reused - or
|
|
189
|
+
# dropped, by a throw, when there is too much. Each next chunk is read by
|
|
190
|
+
# the time left: a body arriving in pieces stops at the deadline.
|
|
191
|
+
def read(conn, res, body, deadline)
|
|
192
|
+
total = 0
|
|
193
|
+
res.read_body do |chunk|
|
|
194
|
+
if res.code == "200"
|
|
195
|
+
body << chunk
|
|
196
|
+
else
|
|
197
|
+
body << chunk.byteslice(0, MAX_API_ERROR_BYTES - body.bytesize) if body.bytesize < MAX_API_ERROR_BYTES
|
|
198
|
+
total += chunk.bytesize
|
|
199
|
+
throw :drop if total > MAX_DRAIN_BYTES
|
|
200
|
+
end
|
|
201
|
+
conn.read_timeout = time_left(deadline)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def time_left(deadline)
|
|
206
|
+
t = deadline - HTTP.now
|
|
207
|
+
raise Net::ReadTimeout, "the call's deadline passed" if t <= 0
|
|
208
|
+
|
|
209
|
+
t
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def checkout(key, uri)
|
|
213
|
+
stale = []
|
|
214
|
+
conn = @lock.synchronize do
|
|
215
|
+
list = @idle[key]
|
|
216
|
+
while (entry = list.pop)
|
|
217
|
+
c, since = entry
|
|
218
|
+
break c if HTTP.now - since < IDLE_TIMEOUT
|
|
219
|
+
|
|
220
|
+
stale << c
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
stale.each { discard(it) }
|
|
224
|
+
return conn if conn
|
|
225
|
+
|
|
226
|
+
conn = Net::HTTP.new(uri.host, uri.port)
|
|
227
|
+
conn.use_ssl = uri.scheme == "https"
|
|
228
|
+
conn.keep_alive_timeout = IDLE_TIMEOUT
|
|
229
|
+
conn.max_retries = 0
|
|
230
|
+
@options.each { |k, v| conn.public_send(:"#{k}=", v) }
|
|
231
|
+
conn
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def checkin(key, conn)
|
|
235
|
+
keep = @lock.synchronize do
|
|
236
|
+
next false if @closed || @idle[key].size >= MAX_IDLE
|
|
41
237
|
|
|
42
|
-
|
|
238
|
+
@idle[key] << [conn, HTTP.now]
|
|
239
|
+
true
|
|
240
|
+
end
|
|
241
|
+
discard(conn) unless keep
|
|
242
|
+
end
|
|
43
243
|
|
|
44
|
-
|
|
244
|
+
def discard(conn)
|
|
245
|
+
conn.finish if conn.started?
|
|
246
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
45
249
|
end
|
|
46
250
|
end
|
|
47
251
|
end
|
data/lib/cent/message.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cent
|
|
4
|
+
# What every result and message type has besides its fields: Hash and JSON
|
|
5
|
+
# forms, nested ones included, for rendering or logging a result -
|
|
6
|
+
# +render json: client.history(channel:)+ in Rails, say.
|
|
7
|
+
#
|
|
8
|
+
# res = client.history(channel: "news", limit: 1)
|
|
9
|
+
# res.to_h # => {publications: [{data: {"text" => "hi"}, info: nil, offset: 1, tags: {}}], offset: 1, ...}
|
|
10
|
+
# res.to_json # => '{"publications":[{"data":{"text":"hi"},...}],...}'
|
|
11
|
+
#
|
|
12
|
+
# Payloads stay as they are. An error in a result - a channel's, in a
|
|
13
|
+
# broadcast - is its message in JSON.
|
|
14
|
+
module Message
|
|
15
|
+
# The fields as a Hash with Symbol keys, nested messages as Hashes too.
|
|
16
|
+
#
|
|
17
|
+
# @return [Hash{Symbol => Object}]
|
|
18
|
+
def to_h(&)
|
|
19
|
+
return super if block_given?
|
|
20
|
+
|
|
21
|
+
super.transform_values { Message.deep(it) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The Hash a JSON encoder writes: {#to_h}, errors as their messages.
|
|
25
|
+
#
|
|
26
|
+
# @return [Hash{String => Object}]
|
|
27
|
+
def as_json(*) = Message.json(to_h)
|
|
28
|
+
|
|
29
|
+
# @return [String] the JSON of {#as_json}
|
|
30
|
+
def to_json(*) = as_json.to_json(*)
|
|
31
|
+
|
|
32
|
+
# @api private
|
|
33
|
+
def self.deep(v)
|
|
34
|
+
case v
|
|
35
|
+
when Message then v.to_h
|
|
36
|
+
when Array then v.map { deep(it) }
|
|
37
|
+
when Hash then v.transform_values { deep(it) }
|
|
38
|
+
else v
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @api private
|
|
43
|
+
def self.json(v)
|
|
44
|
+
case v
|
|
45
|
+
when Hash then v.to_h { |k, x| [k.to_s, json(x)] }
|
|
46
|
+
when Array then v.map { json(it) }
|
|
47
|
+
when Exception then v.message
|
|
48
|
+
else v
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/cent/pages.rb
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cent
|
|
4
|
+
# Where the publication went in the channel's stream.
|
|
5
|
+
class PublishResult
|
|
6
|
+
# @return [StreamPosition] the publication's offset and the stream's epoch:
|
|
7
|
+
# the since of a history call reading what came after it
|
|
8
|
+
def position = StreamPosition.new(offset:, epoch:)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# The channel's stream position when the history was read.
|
|
12
|
+
class HistoryResult
|
|
13
|
+
# @return [StreamPosition] the stream's top offset and epoch
|
|
14
|
+
def position = StreamPosition.new(offset:, epoch:)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The map channel's stream position when its state was read.
|
|
18
|
+
class MapReadStateResult
|
|
19
|
+
# @return [StreamPosition] the stream's offset and epoch: as map_read_stream's
|
|
20
|
+
# since_offset and since_epoch, it reads the changes since
|
|
21
|
+
def position = StreamPosition.new(offset:, epoch:)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The map channel's stream position when its stream was read.
|
|
25
|
+
class MapReadStreamResult
|
|
26
|
+
# @return [StreamPosition] the stream's offset and epoch
|
|
27
|
+
def position = StreamPosition.new(offset:, epoch:)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The map channel's stream position after the publication.
|
|
31
|
+
class MapPublishResult
|
|
32
|
+
# @return [StreamPosition] the publication's offset in the map's stream and
|
|
33
|
+
# the stream's epoch
|
|
34
|
+
def position = StreamPosition.new(offset:, epoch:)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The map channel's stream position after the removal.
|
|
38
|
+
class MapRemoveResult
|
|
39
|
+
# @return [StreamPosition] the removal's offset in the map's stream and the
|
|
40
|
+
# stream's epoch
|
|
41
|
+
def position = StreamPosition.new(offset:, epoch:)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A publication read by history_all, with the epoch of the stream it was read
|
|
45
|
+
# from. The publication's fields read directly - +item.data+, +item.offset+ -
|
|
46
|
+
# and {#position} is where it is in the channel's stream: the since of a
|
|
47
|
+
# history call resuming after it, or a subscription's recover_since.
|
|
48
|
+
#
|
|
49
|
+
# @!attribute [r] publication
|
|
50
|
+
# @return [Publication]
|
|
51
|
+
# @!attribute [r] epoch
|
|
52
|
+
# @return [String] the epoch of the channel's stream
|
|
53
|
+
class HistoryItem < Data.define(:publication, :epoch)
|
|
54
|
+
include Message
|
|
55
|
+
|
|
56
|
+
Publication.members.each { |m| define_method(m) { publication.public_send(m) } }
|
|
57
|
+
|
|
58
|
+
# @return [StreamPosition] the publication's offset and the stream's epoch
|
|
59
|
+
def position = StreamPosition.new(offset: publication.offset, epoch:)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# An entry read by map_read_state_all, with the position of the state it was
|
|
63
|
+
# read at: the first page's, every later page being read at it, or the
|
|
64
|
+
# revision the request gave. The entry's fields read directly - +item.key+,
|
|
65
|
+
# +item.data+ - and map_read_stream from {#position} follows the state with
|
|
66
|
+
# every change made since.
|
|
67
|
+
#
|
|
68
|
+
# @!attribute [r] entry
|
|
69
|
+
# @return [MapEntry]
|
|
70
|
+
# @!attribute [r] state
|
|
71
|
+
# @return [StreamPosition] the position of the map's state the entry was
|
|
72
|
+
# read at
|
|
73
|
+
class MapStateItem < Data.define(:entry, :state)
|
|
74
|
+
include Message
|
|
75
|
+
|
|
76
|
+
MapEntry.members.each { |m| define_method(m) { entry.public_send(m) } }
|
|
77
|
+
|
|
78
|
+
# @return [StreamPosition] the state's position, {#state}
|
|
79
|
+
def position = state
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The <method>_all iterators of Client (spec 3.8): paging written once over
|
|
83
|
+
# the cursor, and once for history.
|
|
84
|
+
#
|
|
85
|
+
# @api private
|
|
86
|
+
module Pages
|
|
87
|
+
# Each paged method's result type, the field of its items, and the field
|
|
88
|
+
# holding the next page's cursor - empty when there is none.
|
|
89
|
+
PAGED = {
|
|
90
|
+
"history" => [HistoryResult, :publications, nil],
|
|
91
|
+
"device_list" => [DeviceListResult, :items, :next_cursor],
|
|
92
|
+
"device_topic_list" => [DeviceTopicListResult, :items, :next_cursor],
|
|
93
|
+
"user_topic_list" => [UserTopicListResult, :items, :next_cursor],
|
|
94
|
+
"map_read_state" => [MapReadStateResult, :entries, :cursor]
|
|
95
|
+
}.freeze
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# The items of every page of +method+, the first page's request the JSON
|
|
100
|
+
# the block builds: an Enumerator, or each yielded to +block+. The request
|
|
101
|
+
# is checked here, before anything is sent.
|
|
102
|
+
def pages(method, page_timeout, block, &)
|
|
103
|
+
deadline(page_timeout, "page_timeout") # checked at the call, as the request is
|
|
104
|
+
first = encode_request(method, &)
|
|
105
|
+
check_history(first) if method == "history"
|
|
106
|
+
return Enumerator.new { |y| each_page(method, first, page_timeout) { y << it } } unless block
|
|
107
|
+
|
|
108
|
+
each_page(method, first, page_timeout, &block)
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def check_history(w)
|
|
113
|
+
limit = w["limit"] || 0
|
|
114
|
+
if limit < 1
|
|
115
|
+
raise InvalidRequestError, "invalid history request: history_all needs a limit of 1 or more, the page size " \
|
|
116
|
+
"(0 asks for the position alone, -1 for everything in one call)"
|
|
117
|
+
end
|
|
118
|
+
return unless w["reverse"]
|
|
119
|
+
|
|
120
|
+
raise InvalidRequestError, "invalid history request: history_all reads forward only: reverse is not supported"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def each_page(method, w, page_timeout)
|
|
124
|
+
result_class, items, cursor = PAGED.fetch(method)
|
|
125
|
+
# map_read_state's later pages are read at the first page's revision,
|
|
126
|
+
# unless the request pins one itself.
|
|
127
|
+
pin = method == "map_read_state" && !w.key?("revision_offset") && !w.key?("revision_epoch")
|
|
128
|
+
state = StreamPosition.new(offset: w["revision_offset"] || 0, epoch: w["revision_epoch"] || "") unless pin
|
|
129
|
+
loop do
|
|
130
|
+
res = invoke(method, result_class, page_timeout) { w }
|
|
131
|
+
page = res.public_send(items)
|
|
132
|
+
state ||= res.position if method == "map_read_state"
|
|
133
|
+
page.each do |item|
|
|
134
|
+
yield case method
|
|
135
|
+
when "history" then HistoryItem.new(publication: item, epoch: res.epoch)
|
|
136
|
+
when "map_read_state" then MapStateItem.new(entry: item, state:)
|
|
137
|
+
else item
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
if cursor.nil?
|
|
141
|
+
break if page.size < w["limit"]
|
|
142
|
+
|
|
143
|
+
w = w.merge("since" => position(page.last.offset, res.epoch))
|
|
144
|
+
else
|
|
145
|
+
following = res.public_send(cursor)
|
|
146
|
+
break if following.empty?
|
|
147
|
+
|
|
148
|
+
w = w.merge("cursor" => following)
|
|
149
|
+
if pin
|
|
150
|
+
w = w.merge(position(res.offset, res.epoch).transform_keys { "revision_#{it}" })
|
|
151
|
+
pin = false
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A stream position as the JSON of a request, zero values left out.
|
|
158
|
+
def position(offset, epoch)
|
|
159
|
+
w = {}
|
|
160
|
+
w["offset"] = offset unless offset.zero?
|
|
161
|
+
w["epoch"] = epoch unless epoch.empty?
|
|
162
|
+
w
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|