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.
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The request and response objects a Ruby worker's handler sees.
4
+ #
5
+ # These are the SDK's own types rather than Rack's, because the SDK must work with every
6
+ # Ruby web stack and none of them owns the wire. `Volucer::Rack.env` turns a Request into a
7
+ # Rack environment; a framework adapter is that environment, not these objects.
8
+
9
+ require 'uri'
10
+
11
+ module Volucer
12
+ # One request, as it arrived on the channel.
13
+ #
14
+ # `headers` is a list of pairs rather than a Hash, because the same name may arrive twice
15
+ # and the order is the client's. `#header` is the ordered lookup and `#header_map` is the
16
+ # collapsed form a Rack environment needs.
17
+ class Request
18
+ attr_reader :method, :uri, :protocol, :headers, :remote_addr, :script_filename, :body
19
+ attr_reader :https
20
+
21
+ def initialize(
22
+ method: 'GET', uri: '/', protocol: 'HTTP/1.1', headers: [], remote_addr: '',
23
+ script_filename: '', https: false, body: +''
24
+ )
25
+ @method = method
26
+ @uri = uri
27
+ @protocol = protocol
28
+ @headers = headers
29
+ @remote_addr = remote_addr
30
+ @script_filename = script_filename
31
+ @https = https
32
+ @body = body
33
+ end
34
+
35
+ def https?
36
+ @https
37
+ end
38
+
39
+ # The first value of a header, case-insensitively, or nil.
40
+ def header(name)
41
+ lowered = name.downcase
42
+ @headers.each { |key, value| return value if key.downcase == lowered }
43
+ nil
44
+ end
45
+
46
+ # Every header as a name => value map, repeated names joined with a comma.
47
+ #
48
+ # Repeated names are joined rather than overwritten, because that is what a repeated
49
+ # HTTP field means. `Set-Cookie` is the exception and it is a *response* header, so the
50
+ # response side keeps the pair list instead.
51
+ def header_map
52
+ @headers.each_with_object({}) do |(key, value), collapsed|
53
+ collapsed[key] = collapsed.key?(key) ? "#{collapsed[key]}, #{value}" : value
54
+ end
55
+ end
56
+
57
+ # The path, without the query string.
58
+ def path
59
+ @uri.split('?', 2).first
60
+ end
61
+
62
+ # Everything after the first `?`, or an empty string.
63
+ def query_string
64
+ _, _, query = @uri.partition('?')
65
+ query
66
+ end
67
+
68
+ # The query string as a Hash, later values winning.
69
+ def query
70
+ URI.decode_www_form(query_string).to_h
71
+ rescue ArgumentError
72
+ {}
73
+ end
74
+
75
+ def content_type
76
+ header('content-type')
77
+ end
78
+
79
+ # Whether this request is a gRPC call.
80
+ #
81
+ # The media type decides, on both sides of the channel — see `docs/worker-protocol.md`.
82
+ # A worker that serves both HTTP routes and gRPC services is the ordinary shape, so this
83
+ # is the question an adapter asks rather than something the loop decides for it.
84
+ def grpc?
85
+ media_type = (content_type || '').strip.downcase
86
+ return false unless media_type.start_with?('application/grpc')
87
+
88
+ rest = media_type['application/grpc'.length..] || ''
89
+ rest.empty? || [';', '+', '-'].include?(rest[0])
90
+ end
91
+ end
92
+
93
+ # A response the SDK will send.
94
+ #
95
+ # Header order is preserved as given, because the server relays it verbatim and two
96
+ # `Set-Cookie` headers are two cookies.
97
+ class Response
98
+ attr_accessor :status, :headers, :body
99
+
100
+ def initialize(status = 200, headers = [], body = +'')
101
+ @status = status
102
+ @headers = headers
103
+ @body = body.b
104
+ end
105
+
106
+ def self.text(body, status = 200)
107
+ new(status, [['Content-Type', 'text/plain; charset=utf-8']], body)
108
+ end
109
+
110
+ def self.html(body, status = 200)
111
+ new(status, [['Content-Type', 'text/html; charset=utf-8']], body)
112
+ end
113
+
114
+ def self.json(value, status = 200)
115
+ new(status, [['Content-Type', 'application/json']], JSON.generate(value))
116
+ end
117
+
118
+ def with_header(name, value)
119
+ @headers << [name, value]
120
+ self
121
+ end
122
+
123
+ def with_status(status)
124
+ @status = status
125
+ self
126
+ end
127
+
128
+ def with_body(body)
129
+ @body = body.b
130
+ self
131
+ end
132
+ end
133
+
134
+ # One unit of queued work, as a consumer sees it.
135
+ #
136
+ # A job is a separate exchange from a request rather than a flag on one, because the two
137
+ # differ in what an application may assume: a request handler is expected to reset what
138
+ # the last one left behind, and a consumer is expected to keep its connection pool and
139
+ # warm caches across jobs.
140
+ class Job
141
+ attr_reader :pipeline, :id, :task, :headers, :attempts, :body
142
+
143
+ def initialize(pipeline: '', id: '', task: '', headers: [], attempts: 0, body: +'')
144
+ @pipeline = pipeline
145
+ @id = id
146
+ @task = task
147
+ @headers = headers
148
+ @attempts = attempts
149
+ @body = body
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The volucer worker protocol, as a Ruby client implements it.
4
+ #
5
+ # The specification is `docs/worker-protocol.md` in `sailantis/volucer`; this module is
6
+ # the other end of it, beside the PHP SDK in `sailantis/volucer-php-sdk` and the Python one
7
+ # in `sailantis/volucer-python-sdk`. All three implement the same document rather than each
8
+ # other, and the wire-format tests assert the same frozen bytes.
9
+ #
10
+ # Everything here is stdlib-only, deliberately: a worker SDK is loaded before an
11
+ # application's own gems are known to work, and a protocol codec that needs a gem is one
12
+ # that can fail for a reason unrelated to the protocol.
13
+
14
+ require 'json'
15
+
16
+ module Volucer
17
+ # The only protocol version that exists so far.
18
+ PROTOCOL_VERSION = 1
19
+
20
+ # The size of a frame header on the wire.
21
+ HEADER_LEN = 16
22
+
23
+ # Refuse absurd payloads before allocating, matching MAX_PAYLOAD_LEN in Rust.
24
+ MAX_PAYLOAD_LEN = 64 * 1024 * 1024
25
+
26
+ # The largest body slice sent in one frame, matching MAX_BODY_CHUNK in Rust.
27
+ MAX_BODY_CHUNK = 64 * 1024
28
+
29
+ # The request id reserved for unsolicited frames.
30
+ UNSOLICITED = 0
31
+
32
+ # The message type table. The numbering *is* the protocol: never reorder, never reuse.
33
+ module MessageType
34
+ HELLO = 1
35
+ HELLO_ACK = 2
36
+ REQUEST = 3
37
+ RESPONSE = 4
38
+ BODY = 5
39
+ EVENT = 6
40
+ LOG = 7
41
+ METRIC = 8
42
+ CONTROL = 9
43
+ CONTROL_ACK = 10
44
+ END_FRAME = 11
45
+ PING = 12
46
+ PONG = 13
47
+ ERROR = 14
48
+ CALL = 15
49
+ RETURN = 16
50
+ JOB = 17
51
+ ACK = 18
52
+
53
+ NAMES = {
54
+ HELLO => 'HELLO', HELLO_ACK => 'HELLO_ACK', REQUEST => 'REQUEST',
55
+ RESPONSE => 'RESPONSE', BODY => 'BODY', EVENT => 'EVENT', LOG => 'LOG',
56
+ METRIC => 'METRIC', CONTROL => 'CONTROL', CONTROL_ACK => 'CONTROL_ACK',
57
+ END_FRAME => 'END', PING => 'PING', PONG => 'PONG', ERROR => 'ERROR',
58
+ CALL => 'CALL', RETURN => 'RETURN', JOB => 'JOB', ACK => 'ACK'
59
+ }.freeze
60
+
61
+ # The wire name of a type, for diagnostics.
62
+ def self.name(type)
63
+ NAMES.fetch(type, "unknown (#{type})")
64
+ end
65
+ end
66
+
67
+ # Bits in the `flags` field.
68
+ module Flags
69
+ COMPRESSED = 0x0001
70
+ CHUNKED = 0x0002
71
+ LAST = 0x0004
72
+ ERROR = 0x0008
73
+ NO_CHECKSUM = 0x0010
74
+ end
75
+
76
+ # A framing or message failure.
77
+ #
78
+ # The names are the protocol's own: a *framing* failure means the reader can no longer
79
+ # trust its position in the stream and the worker must be discarded, while a *message*
80
+ # failure (a wrong request id, a remote error) leaves the stream aligned.
81
+ class ProtocolError < StandardError
82
+ attr_reader :fatal
83
+
84
+ # Whether the failed operation may nevertheless have taken effect.
85
+ #
86
+ # The third answer `RETURN` has room for, and the one a retry has to know about: a call
87
+ # that was **written** and never answered -- a broken connection, an expired deadline --
88
+ # may have been served, so retrying it may duplicate a side effect. A refusal the server
89
+ # actually sent is `false`: nothing happened, and retrying is safe.
90
+ #
91
+ # Always `false` on a worker's channel, where the protocol guarantees an answer; it is
92
+ # {Rpc} that can reach the undecided case, because it reads a socket.
93
+ attr_reader :unknown
94
+
95
+ def initialize(message, fatal: true, unknown: false)
96
+ super(message)
97
+ @fatal = fatal
98
+ @unknown = unknown
99
+ end
100
+
101
+ # Whether the failure may have taken effect anyway, named for the question a caller asks.
102
+ #
103
+ # `may_have_taken_effect?` rather than a predicate over `#unknown`, because the sentence a caller
104
+ # actually needs to answer is "is it safe to retry this?", and `unknown?` reads as "the error is
105
+ # of an unknown kind" to anyone who has not read this file.
106
+ def may_have_taken_effect?
107
+ @unknown
108
+ end
109
+
110
+ # The field's own name, kept because `RETURN` spells it `unknown` on the wire.
111
+ def unknown?
112
+ @unknown
113
+ end
114
+ end
115
+
116
+ # CRC-32C (Castagnoli).
117
+ #
118
+ # A table rather than `Zlib.crc32`, which is CRC-32B: a different polynomial, so using it
119
+ # would make every frame fail its checksum against a correct peer. The check value is the
120
+ # standard one — `crc32c("123456789") == 0xE3069283` — and the test suite pins it.
121
+ module Crc32c
122
+ POLYNOMIAL = 0x82F63B78
123
+
124
+ TABLE = Array.new(256) do |index|
125
+ value = index
126
+ 8.times { value = (value & 1).zero? ? value >> 1 : (value >> 1) ^ POLYNOMIAL }
127
+ value
128
+ end.freeze
129
+
130
+ def self.digest(data)
131
+ crc = 0xFFFFFFFF
132
+ data.each_byte do |byte|
133
+ crc = TABLE[(crc ^ byte) & 0xFF] ^ (crc >> 8)
134
+ end
135
+ crc ^ 0xFFFFFFFF
136
+ end
137
+ end
138
+
139
+ # A decoded frame header: the 16 bytes that precede a payload.
140
+ #
141
+ # A direct transliteration of `Header::decode` in
142
+ # `crates/volucer-worker/src/header.rs`. Layout, little-endian:
143
+ #
144
+ # offset size field
145
+ # 0 4 payload_len
146
+ # 4 1 protocol_version
147
+ # 5 1 message_type
148
+ # 6 2 flags
149
+ # 8 4 request_id
150
+ # 12 4 crc32c(payload)
151
+ Header = Struct.new(:payload_len, :message_type, :flags, :request_id, :checksum) do
152
+ # Parse a header, which must be exactly 16 bytes.
153
+ #
154
+ # Exactly, not "at least". A stream that ended mid-header is an error rather than a
155
+ # clean close: treating it as one would hide a truncated frame.
156
+ def self.decode(raw)
157
+ length = raw.bytesize
158
+ unless length == HEADER_LEN
159
+ raise ProtocolError, "a frame header is exactly #{HEADER_LEN} bytes, got #{length}"
160
+ end
161
+
162
+ payload_len, version, type, flags, request_id, checksum =
163
+ raw.unpack('VCCvVV')
164
+
165
+ unless version == PROTOCOL_VERSION
166
+ raise ProtocolError,
167
+ "unsupported protocol version #{version} (this SDK speaks #{PROTOCOL_VERSION})"
168
+ end
169
+
170
+ unless type.between?(MessageType::HELLO, MessageType::ACK)
171
+ raise ProtocolError, "unknown message type #{type} (this SDK defines 1..18)"
172
+ end
173
+
174
+ # Refused rather than misread: with no codec negotiated, a "compressed" payload would
175
+ # be handed to JSON as if it were plain text.
176
+ unless (flags & Flags::COMPRESSED).zero?
177
+ raise ProtocolError, 'refusing a compressed frame: no codec is negotiated'
178
+ end
179
+
180
+ new(payload_len, type, flags, request_id, checksum)
181
+ end
182
+
183
+ def last?
184
+ (flags & Flags::LAST) != 0
185
+ end
186
+
187
+ # The writer's *claim*, not a permission. Whether it may be believed is the reader's
188
+ # decision, and `Worker#read_payload` refuses one the handshake never agreed to.
189
+ def declares_no_checksum?
190
+ (flags & Flags::NO_CHECKSUM) != 0
191
+ end
192
+
193
+ def type_name
194
+ MessageType.name(message_type)
195
+ end
196
+ end
197
+
198
+ # A frame ready to send: a message type, a request id and a payload.
199
+ Frame = Struct.new(:message_type, :request_id, :payload, :flags) do
200
+ def initialize(message_type, request_id, payload = '', flags = 0)
201
+ super(message_type, request_id, payload.b, flags)
202
+ end
203
+
204
+ # Serialize to header + payload.
205
+ #
206
+ # With the checksum off the field is written as zero and NO_CHECKSUM is raised, which
207
+ # is what makes the zero mean "not a checksum" rather than "the checksum of an empty
208
+ # payload".
209
+ def encode(checksum: true)
210
+ effective_flags = checksum ? flags : (flags | Flags::NO_CHECKSUM)
211
+ value = checksum ? Crc32c.digest(payload) : 0
212
+ [payload.bytesize, PROTOCOL_VERSION, message_type, effective_flags,
213
+ request_id, value].pack('VCCvVV') + payload
214
+ end
215
+ end
216
+
217
+ # JSON-encode a payload the way the protocol expects it.
218
+ #
219
+ # `JSON.generate` carries no spaces, which is what the golden fixtures in all three SDKs
220
+ # are written against.
221
+ def self.json_bytes(value)
222
+ JSON.generate(value, ascii_only: false).b
223
+ end
224
+
225
+ # Decode a JSON payload, turning a bad payload into a protocol error.
226
+ def self.decode_json(payload, what)
227
+ return {} if payload.nil? || payload.empty?
228
+
229
+ decoded = begin
230
+ JSON.parse(payload)
231
+ rescue JSON::ParserError => e
232
+ raise ProtocolError, "#{what} was not valid JSON: #{e.message}"
233
+ end
234
+
235
+ unless decoded.is_a?(Hash)
236
+ raise ProtocolError, "#{what} must be a JSON object"
237
+ end
238
+
239
+ decoded
240
+ end
241
+
242
+ # The extensions, message types and flags are loaded by `volucer/worker`; requiring this
243
+ # file alone gives a caller the codec without a process.
244
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A Rack adapter: turn a `Volucer::Request` into a Rack environment.
4
+ #
5
+ # This is the half of a Ruby worker that is genuinely new work rather than a transliteration
6
+ # of the PHP SDK. PHP has a SAPI that builds `$_SERVER` from a CGI environment, so the PHP
7
+ # SDK already receives one; Rack is *also* CGI-derived but has its own opinions, and two of
8
+ # them are where a naive adapter goes wrong:
9
+ #
10
+ # * `rack.input` has to respond to `gets`, `each`, `read` and `rewind`. A Rack app, and
11
+ # `Rack::Lint`, will use all four.
12
+ # * `rack.errors` has to be a stream. Pointing it at the protocol stream would corrupt the
13
+ # framing the first time an application logged a warning.
14
+ #
15
+ # `SCRIPT_NAME` is empty and `PATH_INFO` is the whole path, which is the shape a middleware
16
+ # stack mounted at the root expects. A framework that needs to route on a prefix does it from
17
+ # `PATH_INFO`, exactly as it does under any other Rack server.
18
+
19
+ require 'stringio'
20
+
21
+ module Volucer
22
+ module Rack
23
+ # The default server name, when a `Host` header carries none.
24
+ DEFAULT_SERVER_NAME = 'localhost'
25
+
26
+ # Split a `Host` header into a name and a port, with the scheme's default filled in.
27
+ #
28
+ # A bracketed IPv6 literal keeps its brackets and its colons; everything else splits on
29
+ # the last colon, which is the only unambiguous rule a host header has.
30
+ def self.split_host(host, https)
31
+ default_port = https ? '443' : '80'
32
+ return [DEFAULT_SERVER_NAME, default_port] if host.nil? || host.strip.empty?
33
+
34
+ host = host.strip
35
+ if host.start_with?('[')
36
+ closing = host.index(']')
37
+ unless closing.nil?
38
+ name = host[0..closing]
39
+ rest = host[(closing + 1)..] || ''
40
+ return [name, rest.start_with?(':') && rest.length > 1 ? rest[1..] : default_port]
41
+ end
42
+ return [host, default_port]
43
+ end
44
+
45
+ if host.include?(':')
46
+ name, _, port = host.rpartition(':')
47
+ return [name, port] unless name.empty? || port.empty?
48
+ end
49
+
50
+ [host, default_port]
51
+ end
52
+
53
+ # Build the Rack environment for one request.
54
+ #
55
+ # `server_port` overrides the port derived from the `Host` header, for a deployment whose
56
+ # listener is not the port a client sees. The header is the better source when it is
57
+ # there, so this is an override rather than the default.
58
+ def self.env(request, server_port = nil)
59
+ name, port = split_host(request.header('host'), request.https?)
60
+ port = server_port unless server_port.nil? || server_port.to_s.empty?
61
+
62
+ path = decode_path(request.path)
63
+
64
+ env = {
65
+ 'REQUEST_METHOD' => request.method,
66
+ 'SCRIPT_NAME' => '',
67
+ 'PATH_INFO' => path,
68
+ 'QUERY_STRING' => request.query_string,
69
+ 'SERVER_NAME' => name,
70
+ 'SERVER_PORT' => port.to_s,
71
+ 'SERVER_PROTOCOL' => request.protocol,
72
+ 'REMOTE_ADDR' => request.remote_addr,
73
+ 'rack.version' => [1, 3],
74
+ 'rack.url_scheme' => request.https? ? 'https' : 'http',
75
+ 'rack.input' => StringIO.new(request.body),
76
+ 'rack.errors' => ErrorsStream.new,
77
+ 'rack.multithread' => false,
78
+ 'rack.multiprocess' => true,
79
+ 'rack.run_once' => false,
80
+ 'rack.hijack?' => false
81
+ }
82
+
83
+ # Routing metadata, not a file to execute — the protocol's own documentation says so,
84
+ # and a Rack app that opened this path would be a path-traversal bug waiting to happen.
85
+ # Prefixing it keeps nothing from mistaking it for a CGI variable.
86
+ env['volucer.script_filename'] = request.script_filename unless request.script_filename.empty?
87
+
88
+ request.headers.each do |key, value|
89
+ upper = key.upcase.tr('-', '_')
90
+ case upper
91
+ when 'CONTENT_TYPE', 'CONTENT_LENGTH'
92
+ env[upper] = value
93
+ else
94
+ env["HTTP_#{upper}"] = value
95
+ end
96
+ end
97
+
98
+ # A Rack server is expected to supply this for a body; the request may have arrived
99
+ # without one, and a middleware that read `rack.input` to the end would otherwise not
100
+ # know how much to expect.
101
+ if !env.key?('CONTENT_LENGTH') && !request.body.empty?
102
+ env['CONTENT_LENGTH'] = request.body.bytesize.to_s
103
+ end
104
+
105
+ env
106
+ end
107
+
108
+ # Percent-decode a URL path, leaving it as the bytes a Rack app expects.
109
+ def self.decode_path(raw)
110
+ decoded = raw.gsub(/%([0-9A-Fa-f]{2})/) { [Regexp.last_match(1)].pack('H2') }
111
+ decoded.force_encoding(Encoding::UTF_8)
112
+ # A path that is not valid UTF-8 is still a path a request can carry, so it stays as
113
+ # bytes rather than raising. Rack apps that care will inspect `PATH_INFO` themselves.
114
+ decoded.valid_encoding? ? decoded : decoded.dup.force_encoding(Encoding::BINARY)
115
+ end
116
+
117
+ # `rack.errors`, writing somewhere that is not the protocol channel.
118
+ #
119
+ # `$stderr` is free (the supervisor drains it), so this is a thin pass-through. An
120
+ # application's warning is worth seeing, and losing it to a full pipe would be the worst
121
+ # of both.
122
+ class ErrorsStream
123
+ def write(text)
124
+ $stderr.write(text)
125
+ text.to_s.length
126
+ end
127
+
128
+ def <<(text)
129
+ write(text)
130
+ self
131
+ end
132
+
133
+ def puts(*lines)
134
+ lines = [''] if lines.empty?
135
+ lines.each { |line| write("#{line}\n") }
136
+ nil
137
+ end
138
+
139
+ def flush
140
+ $stderr.flush
141
+ self
142
+ end
143
+
144
+ def sync
145
+ true
146
+ end
147
+
148
+ def sync=(value); end
149
+
150
+ def close; end
151
+ end
152
+
153
+ # Wrap a Rack application so it can be handed to `Worker#run`.
154
+ #
155
+ # The usual shape of a Ruby worker, which is why this exists as a function rather than as
156
+ # documentation:
157
+ #
158
+ # require 'volucer'
159
+ # require 'my_app'
160
+ #
161
+ # Volucer::Worker.connect.run(&Volucer::Rack.handler(MyApp.new))
162
+ #
163
+ # The result is a callable taking `(request, worker)` and returning a `Response`, which
164
+ # is what `run` wants.
165
+ def self.handler(app)
166
+ lambda do |request, _worker|
167
+ status, headers, body = app.call(env(request))
168
+
169
+ collected = +''
170
+ begin
171
+ body.each { |chunk| collected << chunk.to_s }
172
+ ensure
173
+ body.close if body.respond_to?(:close)
174
+ end
175
+
176
+ Response.new(status.to_i, normalise_headers(headers), collected)
177
+ end
178
+ end
179
+
180
+ def self.normalise_headers(headers)
181
+ return [] if headers.nil?
182
+
183
+ collected = []
184
+ headers.each do |name, value|
185
+ if value.is_a?(Array)
186
+ # Rack 3 allows an Array value for a header that may repeat; two cookies are two
187
+ # headers, and joining them would silently break a session.
188
+ value.each { |one| collected << [name.to_s, one.to_s] }
189
+ else
190
+ collected << [name.to_s, value.to_s]
191
+ end
192
+ end
193
+ collected
194
+ end
195
+ end
196
+ end