volucer-worker 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 +7 -0
- data/LICENSE +21 -0
- data/README.md +442 -0
- data/lib/volucer/grpc.rb +83 -0
- data/lib/volucer/message.rb +152 -0
- data/lib/volucer/protocol.rb +244 -0
- data/lib/volucer/rack.rb +196 -0
- data/lib/volucer/rpc.rb +622 -0
- data/lib/volucer/services.rb +529 -0
- data/lib/volucer/supervisor.rb +299 -0
- data/lib/volucer/version.rb +15 -0
- data/lib/volucer/worker.rb +836 -0
- data/lib/volucer.rb +111 -0
- metadata +58 -0
data/lib/volucer/rpc.rb
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The call plane for an application that has no worker channel.
|
|
4
|
+
#
|
|
5
|
+
# A `Volucer::Worker` reaches `kv`, `lock` and `jobs` on the stream that carries its requests. A
|
|
6
|
+
# FastCGI application has no such stream -- it exists for the duration of one request and can only
|
|
7
|
+
# answer the frame it was handed -- so volucer serves the same namespaces on a socket of its own,
|
|
8
|
+
# `[rpc]`, which this class dials:
|
|
9
|
+
#
|
|
10
|
+
# require 'volucer'
|
|
11
|
+
#
|
|
12
|
+
# rpc = Volucer::Rpc.connect(env: env) # `env` is the Rack environment
|
|
13
|
+
#
|
|
14
|
+
# rpc.kv('cache').set('greeting', 'hello')
|
|
15
|
+
# holder = rpc.lock.acquire('pdf:create')
|
|
16
|
+
# rpc.jobs('mail').push('SendEmail', payload: '{"to":"a@b"}')
|
|
17
|
+
#
|
|
18
|
+
# Nothing here is configured, and that is the point. The address and the backend's identity are
|
|
19
|
+
# **in the request's environment**, put there by volucer -- `VOLUCER_RPC` and `VOLUCER_RPC_TOKEN`
|
|
20
|
+
# as CGI parameters, or `X-Volucer-Rpc` and `X-Volucer-Rpc-Token` as headers for an `upstream`
|
|
21
|
+
# origin, which Rack presents as `HTTP_X_VOLUCER_RPC`. One deployment states the address once, in
|
|
22
|
+
# one file, and both sides read it; an application never has a second copy to keep in step.
|
|
23
|
+
#
|
|
24
|
+
# A backend whose `services` list is empty is handed neither, which is why {.connect} does not raise
|
|
25
|
+
# when they are missing: the application gets a plane that refuses every call with a message naming
|
|
26
|
+
# the missing variable. A request that never touches a store therefore still works, and the one that
|
|
27
|
+
# does gets an error pointing at the deployment rather than at this class.
|
|
28
|
+
#
|
|
29
|
+
# ## The wire is the worker protocol's, minus the handshake
|
|
30
|
+
#
|
|
31
|
+
# The same {Header}, the same `CALL`/`RETURN`, the same CRC-32C. What a worker establishes once in
|
|
32
|
+
# `HELLO`/`HELLO_ACK` -- version, checksum policy, process identity -- would be paid for on every
|
|
33
|
+
# request by a CGI process, so instead **each call carries the token**. That also means a grant
|
|
34
|
+
# withdrawn by a reload takes effect on the next call rather than at the next reconnect.
|
|
35
|
+
#
|
|
36
|
+
# One consequence worth knowing: there is no `HELLO`, so there is no offer to skip the checksum, so
|
|
37
|
+
# every frame this class writes is checksummed.
|
|
38
|
+
#
|
|
39
|
+
# ## The deadline is here, unlike a worker's channel
|
|
40
|
+
#
|
|
41
|
+
# `Worker#call` blocks with no timeout of its own, because the protocol guarantees an answer. This
|
|
42
|
+
# class reads a **socket**, so a deadline is both possible and necessary: a call whose answer was
|
|
43
|
+
# lost, or a server that stopped reading, would otherwise hang until the request timed out -- and
|
|
44
|
+
# that failure names the wrong thing.
|
|
45
|
+
#
|
|
46
|
+
# The default matches `[rpc].call_timeout_seconds` in the default deployment, and **a call that
|
|
47
|
+
# legitimately takes longer needs a longer deadline**: {Lock#acquire} may wait, so a caller asking
|
|
48
|
+
# for a long wait must say so -- `Rpc.connect(timeout: 30)`.
|
|
49
|
+
#
|
|
50
|
+
# ## A failure raises, and says whether it may have happened
|
|
51
|
+
#
|
|
52
|
+
# These methods return values and raise {ProtocolError} for a failure, which is what a Ruby
|
|
53
|
+
# application expects and what `Volucer::Worker#call` already does. The distinction that has to
|
|
54
|
+
# survive is {ProtocolError#unknown?}: true when the call was **written** and the answer never
|
|
55
|
+
# arrived, so retrying may duplicate a side effect; false when nothing was sent, or when the server
|
|
56
|
+
# answered a refusal.
|
|
57
|
+
|
|
58
|
+
require 'socket'
|
|
59
|
+
# `IO#wait_readable` is what gives a read a deadline, and it is not in core on every supported Ruby
|
|
60
|
+
# -- `io/wait` has to be required explicitly on 3.1, which CI tests. Without it the deadline check
|
|
61
|
+
# below is false for a real socket, and the client would silently fall back to a blocking read with
|
|
62
|
+
# no timeout at all: the precise failure the deadline exists to prevent.
|
|
63
|
+
require 'io/wait'
|
|
64
|
+
|
|
65
|
+
require_relative 'protocol'
|
|
66
|
+
# For `Volucer.unix_path`, so the `unix:` prefix is spelled once in this gem rather than twice. The
|
|
67
|
+
# dependency is one direction -- `worker` knows nothing about this file -- so the require is safe in
|
|
68
|
+
# either order.
|
|
69
|
+
require_relative 'worker'
|
|
70
|
+
|
|
71
|
+
module Volucer
|
|
72
|
+
# The `[rpc]` call plane, as a client.
|
|
73
|
+
#
|
|
74
|
+
# Built by {.connect} from the request's environment, or constructed directly with a connected
|
|
75
|
+
# stream -- which is what the tests do, because every interesting case here is about what the
|
|
76
|
+
# client does with a particular answer.
|
|
77
|
+
class Rpc
|
|
78
|
+
# The CGI parameter (or upstream header) carrying the call plane's address.
|
|
79
|
+
ENDPOINT_PARAM = 'VOLUCER_RPC'
|
|
80
|
+
|
|
81
|
+
# The CGI parameter (or upstream header) carrying this backend's token.
|
|
82
|
+
TOKEN_PARAM = 'VOLUCER_RPC_TOKEN'
|
|
83
|
+
|
|
84
|
+
# The header form an `upstream` origin is given, as Rack spells it in its environment.
|
|
85
|
+
ENDPOINT_HEADER = 'HTTP_X_VOLUCER_RPC'
|
|
86
|
+
|
|
87
|
+
# The header form of the token.
|
|
88
|
+
TOKEN_HEADER = 'HTTP_X_VOLUCER_RPC_TOKEN'
|
|
89
|
+
|
|
90
|
+
# The default deadline for one call, in seconds.
|
|
91
|
+
#
|
|
92
|
+
# The same number `[rpc].call_timeout_seconds` defaults to, so a deployment that configured
|
|
93
|
+
# nothing behaves the same on both sides.
|
|
94
|
+
DEFAULT_TIMEOUT = 10.0
|
|
95
|
+
|
|
96
|
+
# How long to wait for a connection, in seconds.
|
|
97
|
+
#
|
|
98
|
+
# A **cap**, not a second budget: {#call} gives the whole round trip one deadline, and this bounds
|
|
99
|
+
# the dial's share of it. Without a cap, a dial to an address that is up but unresponsive would
|
|
100
|
+
# consume the entire call deadline and leave the read none -- so the failure would name the read
|
|
101
|
+
# path rather than the connection. The default deployment's `[rpc].call_timeout_seconds` is 10, so
|
|
102
|
+
# half of it is a dial nobody can honestly call slow, and a legitimate remote listener answers in
|
|
103
|
+
# milliseconds.
|
|
104
|
+
DIAL_SHARE = 0.5
|
|
105
|
+
|
|
106
|
+
# The first call id. Ids descend from the top of the range, matching the SDKs and the server.
|
|
107
|
+
FIRST_CALL_ID = 0xFFFFFFFF
|
|
108
|
+
|
|
109
|
+
# The last failure's message, if some call did not return a result.
|
|
110
|
+
attr_reader :last_call_error
|
|
111
|
+
|
|
112
|
+
# The address this plane dials, or nil when the deployment granted this backend nothing.
|
|
113
|
+
#
|
|
114
|
+
# For a diagnostic -- "which call plane is this application talking to?" is the first question
|
|
115
|
+
# when a store works in one backend and not another. The address is in the request's environment
|
|
116
|
+
# either way, so this publishes nothing new.
|
|
117
|
+
attr_reader :address
|
|
118
|
+
|
|
119
|
+
# Build a plane from the request's environment, or explicitly.
|
|
120
|
+
#
|
|
121
|
+
# Reads `VOLUCER_RPC`/`VOLUCER_RPC_TOKEN`, falling back to the `X-Volucer-Rpc*` header form an
|
|
122
|
+
# `upstream` backend is handed. An `address:` or `token:` passed here wins, which is what a test
|
|
123
|
+
# or a script needs.
|
|
124
|
+
#
|
|
125
|
+
# **`env:` is the Rack request's environment, and a Rack application should pass it.** volucer
|
|
126
|
+
# puts the address in the *request*, which is a different thing from the process's own
|
|
127
|
+
# environment: under FastCGI the values arrive as CGI parameters, and an `upstream` origin gets
|
|
128
|
+
# them as headers. Reading `ENV` would therefore work for a script a deployment started by hand
|
|
129
|
+
# and fail for every real request, with a message blaming the deployment. A CLI script or a queue
|
|
130
|
+
# consumer omits it and gets `ENV`, which is correct for both -- and `ENV` answers the same `[]`
|
|
131
|
+
# a Rack environment does, which is why this needs no type test.
|
|
132
|
+
#
|
|
133
|
+
# Returns a plane even when the deployment granted nothing: the *call* is what fails, with the
|
|
134
|
+
# missing variable named. A request that never touches a store must still work, and the one that
|
|
135
|
+
# does deserves a message about the deployment rather than an exception from a constructor it
|
|
136
|
+
# did not know it was calling.
|
|
137
|
+
#
|
|
138
|
+
# **Nothing is dialled here.** The connection is made by the first call, so a request that never
|
|
139
|
+
# touches a store pays for neither a socket nor a failed connect -- and a deployment whose call
|
|
140
|
+
# plane is briefly unavailable does not fail a request that would not have used it.
|
|
141
|
+
def self.connect(address: nil, token: nil, timeout: DEFAULT_TIMEOUT, env: nil)
|
|
142
|
+
source = env.nil? ? ENV : env
|
|
143
|
+
resolved = address.nil? ? first_value(source, ENDPOINT_PARAM, ENDPOINT_HEADER) : address
|
|
144
|
+
resolved_token = token.nil? ? (first_value(source, TOKEN_PARAM, TOKEN_HEADER) || '') : token
|
|
145
|
+
|
|
146
|
+
new(
|
|
147
|
+
nil, resolved_token, resolved, timeout,
|
|
148
|
+
unavailable: resolved.nil? ? unavailable_message(env) : nil
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Why a plane with no address exists, in the words that name the variable.
|
|
153
|
+
#
|
|
154
|
+
# The message distinguishes "the process's environment" from "the request's", because those are
|
|
155
|
+
# different mistakes: a CLI script reading `ENV` versus a backend whose `services` list is empty.
|
|
156
|
+
def self.unavailable_message(env)
|
|
157
|
+
where = env.nil? ? 'its environment' : 'the Rack request environment'
|
|
158
|
+
"this application was handed no call plane: #{ENDPOINT_PARAM} is not in #{where}, which " \
|
|
159
|
+
"means the backend's `services` list is empty or the deployment has no [rpc] listener"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The first of `names` that holds a non-blank value, or nil.
|
|
163
|
+
#
|
|
164
|
+
# Blank rather than merely absent, because `VOLUCER_RPC=` is what a shell produces while somebody
|
|
165
|
+
# is debugging, and an empty value is not an address to dial. The same rule
|
|
166
|
+
# {Volucer.socket_address} applies to `VOLUCER_WORKER_SOCKET`.
|
|
167
|
+
def self.first_value(source, *names)
|
|
168
|
+
names.each do |name|
|
|
169
|
+
value = source[name]
|
|
170
|
+
next unless value.is_a?(String)
|
|
171
|
+
|
|
172
|
+
value = value.strip
|
|
173
|
+
return value unless value.empty?
|
|
174
|
+
end
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Split an address into a unix path or a `[host, port]` pair.
|
|
179
|
+
#
|
|
180
|
+
# One method so the two forms are decided in one place, and so a malformed address is an error
|
|
181
|
+
# at connect rather than an `Integer()` failure a frame later.
|
|
182
|
+
def self.address_of(address)
|
|
183
|
+
path = Volucer.unix_path(address)
|
|
184
|
+
return [path, nil] unless path.nil?
|
|
185
|
+
|
|
186
|
+
host, _, port = address.rpartition(':')
|
|
187
|
+
unless port.match?(/\A\d+\z/)
|
|
188
|
+
raise ArgumentError, "#{address.inspect} is neither a unix: path nor a host:port address"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
[nil, [host.empty? ? '127.0.0.1' : host, Integer(port, 10)]]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @param stream [IO, nil] the connection to speak over, or nil before the first call
|
|
195
|
+
# @param token [String] the backend's identity, sent with every call
|
|
196
|
+
# @param address [String, nil] where to dial, when a stream is not given
|
|
197
|
+
# @param timeout [Float] how long one call may take, in seconds
|
|
198
|
+
# @param unavailable [String, nil] why there is no address, for a plane the deployment granted
|
|
199
|
+
# nothing. Passed to the constructor rather than set afterwards so there is no moment in which a
|
|
200
|
+
# plane exists with neither an address nor an explanation.
|
|
201
|
+
def initialize(stream, token = '', address = nil, timeout = DEFAULT_TIMEOUT, unavailable: nil)
|
|
202
|
+
if timeout <= 0
|
|
203
|
+
raise ArgumentError,
|
|
204
|
+
'a call deadline has to be positive: zero would abandon every call the moment it was sent'
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
@stream = stream
|
|
208
|
+
@token = token || ''
|
|
209
|
+
@address = address
|
|
210
|
+
@timeout = timeout
|
|
211
|
+
# Why there is no stream **at construction time**, kept apart from `last_call_error` because
|
|
212
|
+
# that one describes the last *call*. A plane built without an address would otherwise report
|
|
213
|
+
# "the call plane could not be reached" -- or nothing at all -- instead of naming the variable
|
|
214
|
+
# the deployment did not set.
|
|
215
|
+
@unavailable = unavailable
|
|
216
|
+
@last_call_error = unavailable
|
|
217
|
+
@last_call_was_unknown = false
|
|
218
|
+
@next_call_id = FIRST_CALL_ID
|
|
219
|
+
@deadline = nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Whether the last failed call may have taken effect.
|
|
223
|
+
#
|
|
224
|
+
# True for a connection that broke or timed out **after the call was written** -- the server may
|
|
225
|
+
# have served it and lost the answer. False for a call that was never sent, which is the whole
|
|
226
|
+
# reason {.connect} records a missing address instead of raising: "the deployment granted this
|
|
227
|
+
# backend nothing" is a definite non-event.
|
|
228
|
+
def last_call_was_unknown?
|
|
229
|
+
@last_call_was_unknown
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Whether there is a live connection to reuse.
|
|
233
|
+
def connected?
|
|
234
|
+
!@stream.nil?
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Drop the socket. The next call reconnects.
|
|
238
|
+
#
|
|
239
|
+
# Not needed before the process exits -- a CGI child's descriptors go with it -- but a caller that
|
|
240
|
+
# keeps an `Rpc` in a constant, or a long-running CLI script, may want the descriptor back.
|
|
241
|
+
def close
|
|
242
|
+
stream = @stream
|
|
243
|
+
@stream = nil
|
|
244
|
+
return nil if stream.nil?
|
|
245
|
+
|
|
246
|
+
stream.close if stream.respond_to?(:close)
|
|
247
|
+
nil
|
|
248
|
+
rescue IOError, SystemCallError
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------ calling
|
|
253
|
+
|
|
254
|
+
# One round trip. Returns the result, or raises {ProtocolError}.
|
|
255
|
+
#
|
|
256
|
+
# A `name` is `"<namespace>.<verb>"`, with the one exception the protocol documents: a **`kv`
|
|
257
|
+
# call is three-part**, `"kv.<storage>.<verb>"`, because the deployment chooses the storage
|
|
258
|
+
# names. {#kv}, {#lock} and {#jobs} build those names for you and are what an application should
|
|
259
|
+
# normally use; this is the escape hatch for a namespace added to the server later.
|
|
260
|
+
#
|
|
261
|
+
# @raise [ProtocolError] if the call was refused, the connection failed, or the deadline expired.
|
|
262
|
+
# {ProtocolError#unknown?} says whether it may nevertheless have taken effect.
|
|
263
|
+
def call(name, params = {})
|
|
264
|
+
@last_call_error = nil
|
|
265
|
+
@last_call_was_unknown = false
|
|
266
|
+
# **One deadline for the whole call, dial included.** Two additive budgets -- a connect timeout
|
|
267
|
+
# plus a read timeout -- make the worst case their sum, which is not what "one call may take
|
|
268
|
+
# `call_timeout_seconds`" means to anyone reading the deployment's configuration.
|
|
269
|
+
@deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
270
|
+
|
|
271
|
+
# Recorded first, then raised: `ensure_stream` writes the message into `last_call_error`, and a
|
|
272
|
+
# caller that rescues the exception may still ask this object why.
|
|
273
|
+
raise failed(@last_call_error, unknown: false) unless ensure_stream
|
|
274
|
+
|
|
275
|
+
call_id = take_call_id
|
|
276
|
+
body = { 'name' => name, 'params' => params }
|
|
277
|
+
# Omitted rather than sent empty when there is no token. The server tells the two apart on
|
|
278
|
+
# purpose: "carried no token" is a deployment that granted this backend nothing, and "carried a
|
|
279
|
+
# token no backend was issued" is an application reading the wrong variable.
|
|
280
|
+
body['token'] = @token unless @token.empty?
|
|
281
|
+
|
|
282
|
+
begin
|
|
283
|
+
write_frame(Frame.new(MessageType::CALL, call_id, Volucer.json_bytes(body)))
|
|
284
|
+
rescue IOError, SystemCallError => e
|
|
285
|
+
# The bytes may or may not have reached the server: a write that failed part-way is exactly
|
|
286
|
+
# the undecided case this flag exists for.
|
|
287
|
+
close
|
|
288
|
+
raise failed("call #{name.inspect} could not be written: #{e.message}", unknown: true)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
await_return(call_id, name)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------------ handles
|
|
295
|
+
|
|
296
|
+
# A handle to one of the server's key-value storages.
|
|
297
|
+
#
|
|
298
|
+
# The storage must be one this backend was granted in `services`; a call naming another is
|
|
299
|
+
# refused by the server with the storages that *are* reachable.
|
|
300
|
+
def kv(storage = 'default')
|
|
301
|
+
Kv.new(self, storage)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# A handle to the server's resource locks.
|
|
305
|
+
#
|
|
306
|
+
# Read {Lock}'s documentation before relying on one: a lease is not a fencing token, and that is
|
|
307
|
+
# a property of leases rather than of this transport.
|
|
308
|
+
def lock
|
|
309
|
+
Lock.new(self)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# A handle to one of the server's queues, from the producer's side.
|
|
313
|
+
#
|
|
314
|
+
# @param pipeline [String] the name from the `[jobs.pipelines.<name>]` table.
|
|
315
|
+
def jobs(pipeline)
|
|
316
|
+
Jobs.new(self, pipeline)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# ----------------------------------------------------------------- transport
|
|
320
|
+
|
|
321
|
+
private
|
|
322
|
+
|
|
323
|
+
# Record what went wrong and build the exception to raise.
|
|
324
|
+
#
|
|
325
|
+
# One place, so a new failure path cannot set one and forget the other -- and `unknown` is a
|
|
326
|
+
# required keyword, because choosing it is the decision that matters.
|
|
327
|
+
def failed(message, unknown:)
|
|
328
|
+
@last_call_was_unknown = unknown
|
|
329
|
+
@last_call_error = message
|
|
330
|
+
ProtocolError.new(message, fatal: false, unknown: unknown)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# The next call id, counting down and wrapping.
|
|
334
|
+
#
|
|
335
|
+
# Descending from the top of the range, like every other SDK: request ids and call ids are
|
|
336
|
+
# deliberately from opposite ends, so a call mistaken for a request is a visible correlation
|
|
337
|
+
# failure rather than a plausible answer.
|
|
338
|
+
def take_call_id
|
|
339
|
+
call_id = @next_call_id
|
|
340
|
+
@next_call_id = call_id <= 1 ? FIRST_CALL_ID : call_id - 1
|
|
341
|
+
call_id
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# The stream, dialling if there is not one yet.
|
|
345
|
+
#
|
|
346
|
+
# One path for both cases: a plane that has never connected, and a plane whose connection a
|
|
347
|
+
# previous call dropped. What must not be retried is a deployment that handed over no address at
|
|
348
|
+
# all -- that is `@unavailable`, decided once, and re-dialling nothing would only replace its
|
|
349
|
+
# message with a vaguer one.
|
|
350
|
+
#
|
|
351
|
+
# A failed *dial*, by contrast, is retried on the next call: it is the server being briefly
|
|
352
|
+
# absent, and a plane that gave up for the life of the process would turn a two-second gap into a
|
|
353
|
+
# permanently broken application.
|
|
354
|
+
def ensure_stream
|
|
355
|
+
return true unless @stream.nil?
|
|
356
|
+
|
|
357
|
+
unless @unavailable.nil?
|
|
358
|
+
@last_call_error = @unavailable
|
|
359
|
+
return false
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
if @address.nil?
|
|
363
|
+
@last_call_error = 'this call plane has no stream and no address to dial'
|
|
364
|
+
return false
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
stream = dial(@address)
|
|
368
|
+
if stream.nil?
|
|
369
|
+
@last_call_error = "the call plane at #{@address} could not be reached"
|
|
370
|
+
return false
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
@stream = stream
|
|
374
|
+
true
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Connect, or nil -- a connection failure is a call's failure, not a constructor's.
|
|
378
|
+
#
|
|
379
|
+
# Bounded by what is left of the call's deadline, so a dial cannot consume the time the read
|
|
380
|
+
# needs. {DIAL_SHARE} caps its share at half.
|
|
381
|
+
def dial(address)
|
|
382
|
+
path, tcp = Rpc.address_of(address)
|
|
383
|
+
budget = dial_budget
|
|
384
|
+
return nil if budget <= 0
|
|
385
|
+
|
|
386
|
+
if path.nil?
|
|
387
|
+
# `Socket.tcp` rather than `TCPSocket.new`: only the former takes a timeout.
|
|
388
|
+
Socket.tcp(tcp[0], tcp[1], connect_timeout: budget)
|
|
389
|
+
else
|
|
390
|
+
UNIXSocket.new(path)
|
|
391
|
+
end
|
|
392
|
+
rescue IOError, SystemCallError, SocketError, ArgumentError
|
|
393
|
+
nil
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# How long the dial may take: half the plane's timeout, or what is left of this call if less.
|
|
397
|
+
def dial_budget
|
|
398
|
+
share = @timeout * DIAL_SHARE
|
|
399
|
+
return share if @deadline.nil?
|
|
400
|
+
|
|
401
|
+
[share, @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)].min
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Write one frame, checksummed.
|
|
405
|
+
#
|
|
406
|
+
# No handshake happened, so nothing negotiated the checksum away and it is never optional here.
|
|
407
|
+
def write_frame(frame)
|
|
408
|
+
@stream.write(frame.encode(checksum: true))
|
|
409
|
+
# A no-op for a socket, and correct for a `StringIO` in a test.
|
|
410
|
+
@stream.flush if @stream.respond_to?(:flush)
|
|
411
|
+
nil
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Read until a RETURN for `call_id` arrives.
|
|
415
|
+
#
|
|
416
|
+
# Every exit that leaves the call undecided -- the deadline, a broken connection, an answer that
|
|
417
|
+
# belongs to another exchange -- closes the socket, because the stream can no longer be trusted
|
|
418
|
+
# to be where this client thinks it is. A refusal does not: the server answered, so the stream is
|
|
419
|
+
# aligned and the next call reuses it.
|
|
420
|
+
def await_return(call_id, name)
|
|
421
|
+
# Re-armed per call rather than per plane, so a plane reused across two calls gets two full
|
|
422
|
+
# deadlines rather than one that expired during the first. `call` has already set it before the
|
|
423
|
+
# dial, which is what makes the dial and the read share one budget; this only extends it when
|
|
424
|
+
# `await_return` is reached without going through `call`.
|
|
425
|
+
@deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
426
|
+
|
|
427
|
+
loop do
|
|
428
|
+
header, payload = read_frame
|
|
429
|
+
if header.nil?
|
|
430
|
+
# The server closed the connection after the call was written, so whether it was served is
|
|
431
|
+
# not knowable from here.
|
|
432
|
+
close
|
|
433
|
+
raise failed(
|
|
434
|
+
"call #{name.inspect} was written but the server closed the connection before answering",
|
|
435
|
+
unknown: true
|
|
436
|
+
)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
unless header.message_type == MessageType::RETURN
|
|
440
|
+
# Anything else is not part of this exchange. A worker absorbs telemetry here; this
|
|
441
|
+
# transport never sees unsolicited frames, so a correlated frame is a protocol error rather
|
|
442
|
+
# than something to skip.
|
|
443
|
+
close
|
|
444
|
+
raise failed(
|
|
445
|
+
"while waiting for a RETURN the server sent a #{header.type_name}",
|
|
446
|
+
unknown: true
|
|
447
|
+
)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
unless header.request_id == call_id
|
|
451
|
+
close
|
|
452
|
+
raise failed(
|
|
453
|
+
"a RETURN for call #{header.request_id} arrived while waiting for call #{call_id}",
|
|
454
|
+
unknown: true
|
|
455
|
+
)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
answer = begin
|
|
459
|
+
Volucer.decode_json(payload, 'a RETURN')
|
|
460
|
+
rescue ProtocolError => e
|
|
461
|
+
# An answer this client cannot read is an answer it cannot act on, and the call may well
|
|
462
|
+
# have been served: undecided, not refused.
|
|
463
|
+
close
|
|
464
|
+
raise failed("call #{name.inspect} was answered with a payload that is not a RETURN: #{e.message}",
|
|
465
|
+
unknown: true)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
unless answer['ok']
|
|
469
|
+
# A refusal carrying `unknown: true` says the effect may have happened; one without it says
|
|
470
|
+
# nothing happened. The server decides which, and this passes it through rather than
|
|
471
|
+
# collapsing the two.
|
|
472
|
+
raise failed(
|
|
473
|
+
answer.fetch('error', 'the call was refused'),
|
|
474
|
+
unknown: answer['unknown'] == true
|
|
475
|
+
)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
return answer['result']
|
|
479
|
+
end
|
|
480
|
+
rescue Framing => e
|
|
481
|
+
# A truncated frame, a checksum mismatch, an impossible payload length: the stream's position is
|
|
482
|
+
# no longer knowable, so the connection is discarded rather than reused.
|
|
483
|
+
close
|
|
484
|
+
raise failed("call #{name.inspect} failed: #{e.message}", unknown: true)
|
|
485
|
+
rescue Deadline
|
|
486
|
+
close
|
|
487
|
+
raise failed(
|
|
488
|
+
"call #{name.inspect} did not answer within #{@timeout}s; it may have been served, so " \
|
|
489
|
+
'retrying is not necessarily safe (a longer deadline: Rpc.connect(timeout: ...))',
|
|
490
|
+
unknown: true
|
|
491
|
+
)
|
|
492
|
+
rescue IOError, SystemCallError => e
|
|
493
|
+
close
|
|
494
|
+
raise failed("call #{name.inspect} failed: #{e.message}", unknown: true)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# One frame, or nil at a clean end of stream between calls.
|
|
498
|
+
#
|
|
499
|
+
# The framing failures are wrapped in {Framing} so that {#await_return} can tell them -- which
|
|
500
|
+
# must close the connection -- from the failures it has already converted into a {ProtocolError}
|
|
501
|
+
# with its `unknown` flag chosen.
|
|
502
|
+
def read_frame
|
|
503
|
+
header = read_header
|
|
504
|
+
return [nil, nil] if header.nil?
|
|
505
|
+
|
|
506
|
+
[header, read_payload(header)]
|
|
507
|
+
rescue ProtocolError => e
|
|
508
|
+
raise Framing, e.message
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# The next frame header, or nil at a clean end of stream.
|
|
512
|
+
#
|
|
513
|
+
# A stream that ends before the first byte is a clean close -- that is what a server does when its
|
|
514
|
+
# idle timeout expires between calls. A stream that ends *part-way* through a header is an error,
|
|
515
|
+
# because treating it as one would hide a truncated frame.
|
|
516
|
+
def read_header
|
|
517
|
+
raw = read_exactly(HEADER_LEN, allow_eof: true)
|
|
518
|
+
return nil if raw.nil?
|
|
519
|
+
|
|
520
|
+
Header.decode(raw)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Read a frame's payload and verify its checksum.
|
|
524
|
+
#
|
|
525
|
+
# A mismatch means framing has already desynchronised, so it is fatal by construction.
|
|
526
|
+
def read_payload(header)
|
|
527
|
+
return ''.b if header.payload_len.zero?
|
|
528
|
+
|
|
529
|
+
# Refused *before* reading, which is the point of the limit: a corrupt `payload_len` must not
|
|
530
|
+
# make the reader allocate 4 GiB.
|
|
531
|
+
if header.payload_len > MAX_PAYLOAD_LEN
|
|
532
|
+
raise ProtocolError,
|
|
533
|
+
"a frame declares #{header.payload_len} payload bytes, above the " \
|
|
534
|
+
"#{MAX_PAYLOAD_LEN} limit; refusing it before allocating"
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
payload = read_exactly(header.payload_len)
|
|
538
|
+
return payload if header.declares_no_checksum?
|
|
539
|
+
|
|
540
|
+
computed = Crc32c.digest(payload)
|
|
541
|
+
return payload if computed == header.checksum
|
|
542
|
+
|
|
543
|
+
raise ProtocolError,
|
|
544
|
+
'a RETURN arrived with a checksum that does not match ' \
|
|
545
|
+
"(expected #{format('0x%08x', header.checksum)}, computed #{format('0x%08x', computed)})"
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Exactly `length` bytes, or nil at a clean end of stream when `allow_eof` and nothing was read.
|
|
549
|
+
#
|
|
550
|
+
# The deadline is applied per read rather than once, so a peer that dribbles a frame out cannot
|
|
551
|
+
# hold a call open past the timeout by sending one byte at a time.
|
|
552
|
+
def read_exactly(length, allow_eof: false)
|
|
553
|
+
buffer = +''.b
|
|
554
|
+
while buffer.bytesize < length
|
|
555
|
+
chunk = read_some(length - buffer.bytesize, allow_eof && buffer.empty?)
|
|
556
|
+
if chunk.nil?
|
|
557
|
+
return nil if allow_eof && buffer.empty?
|
|
558
|
+
|
|
559
|
+
raise ProtocolError,
|
|
560
|
+
"the stream ended after #{buffer.bytesize} of #{length} bytes"
|
|
561
|
+
end
|
|
562
|
+
buffer << chunk
|
|
563
|
+
end
|
|
564
|
+
buffer
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Up to `length` bytes from the stream, or nil at end of stream.
|
|
568
|
+
def read_some(length, allow_eof)
|
|
569
|
+
return read_blocking(length, allow_eof) unless deadline_capable?
|
|
570
|
+
|
|
571
|
+
loop do
|
|
572
|
+
chunk = @stream.read_nonblock(length, exception: false)
|
|
573
|
+
case chunk
|
|
574
|
+
when :wait_readable
|
|
575
|
+
remaining = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
576
|
+
raise Deadline if remaining <= 0
|
|
577
|
+
|
|
578
|
+
# nil from `wait_readable` is the timeout expiring; the loop recomputes and raises.
|
|
579
|
+
@stream.wait_readable(remaining)
|
|
580
|
+
when :wait_writable
|
|
581
|
+
# Unreachable for a read, and handled rather than left to `NoMethodError` on a Symbol.
|
|
582
|
+
raise ProtocolError, 'the stream asked to wait for writability during a read'
|
|
583
|
+
when nil
|
|
584
|
+
return nil
|
|
585
|
+
else
|
|
586
|
+
return chunk
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# The fallback for a stream with no non-blocking read -- a `StringIO` in a test.
|
|
592
|
+
#
|
|
593
|
+
# No deadline, and that is honest: the deadline is a property of the transport. What must not
|
|
594
|
+
# happen is a *socket* taking this path, which is what {#deadline_capable?} is for.
|
|
595
|
+
def read_blocking(length, allow_eof)
|
|
596
|
+
chunk = @stream.read(length)
|
|
597
|
+
return nil if chunk.nil? || chunk.empty?
|
|
598
|
+
|
|
599
|
+
chunk
|
|
600
|
+
rescue EOFError
|
|
601
|
+
return nil if allow_eof
|
|
602
|
+
|
|
603
|
+
raise
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Whether the stream can be given a deadline.
|
|
607
|
+
#
|
|
608
|
+
# A socket can: `read_nonblock` plus `wait_readable` is a bounded read. A `StringIO` cannot, and
|
|
609
|
+
# an earlier version of this client assumed every stream could -- so a deadline applied to
|
|
610
|
+
# something that silently ignored it left the read blocking for ever. The test is on the
|
|
611
|
+
# *capability*, not on the class, so a test double that implements both gets the real path.
|
|
612
|
+
def deadline_capable?
|
|
613
|
+
@stream.respond_to?(:read_nonblock) && @stream.respond_to?(:wait_readable)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Internal: the deadline expired. Never escapes this file.
|
|
617
|
+
class Deadline < StandardError; end
|
|
618
|
+
|
|
619
|
+
# Internal: the stream's framing is no longer trustworthy. Never escapes this file.
|
|
620
|
+
class Framing < StandardError; end
|
|
621
|
+
end
|
|
622
|
+
end
|