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
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The namespaces, reached through a `Volucer::Rpc` call plane.
|
|
4
|
+
#
|
|
5
|
+
# These are the client halves of `kv`, `lock` and `jobs`, and they are written against `Rpc`'s
|
|
6
|
+
# `call`/`last_call_error` rather than against a socket, so the same classes work over any transport
|
|
7
|
+
# a future SDK version adds.
|
|
8
|
+
#
|
|
9
|
+
# ## Bytes, and where they are base64
|
|
10
|
+
#
|
|
11
|
+
# The server stores opaque bytes and never parses an application's objects, which is what makes a
|
|
12
|
+
# serializer possible at all. The JSON codec cannot carry a byte above `0x7f`, so **every field the
|
|
13
|
+
# server treats as bytes is base64 on the wire** -- a KV key, a KV value, a job payload. A lock's
|
|
14
|
+
# `resource` is the exception: it is a *name*, read as text.
|
|
15
|
+
#
|
|
16
|
+
# That asymmetry is not cosmetic. `"greeting"` is itself well-formed base64, so a client that forgot
|
|
17
|
+
# to encode a key looks like a client storing a strange key rather than a client with a bug; `"a"` is
|
|
18
|
+
# refused. See `docs/worker-protocol.md`, "What a call's params carry".
|
|
19
|
+
#
|
|
20
|
+
# ## Keys and values are Strings or bytes
|
|
21
|
+
#
|
|
22
|
+
# A KV key is a `String` or a byte `String` (Ruby has only the one type; what matters is the
|
|
23
|
+
# encoding). A `Symbol` or an `Integer` is refused here rather than by the server, because a key that
|
|
24
|
+
# is a number is a caller's bug and a message from this file names the line.
|
|
25
|
+
#
|
|
26
|
+
# A KV value is JSON-encoded by default, so `set('k', { 'a' => 1 })` returns the Hash it was given.
|
|
27
|
+
# {Volucer::Kv#serializer} replaces that with any pair of callables.
|
|
28
|
+
|
|
29
|
+
require 'json'
|
|
30
|
+
|
|
31
|
+
require_relative 'protocol'
|
|
32
|
+
|
|
33
|
+
module Volucer
|
|
34
|
+
# Base64 the way the protocol spells it: strict, with padding.
|
|
35
|
+
#
|
|
36
|
+
# `Array#pack('m0')` and `String#unpack1('m0')` are the standard library's strict forms, which is
|
|
37
|
+
# why this does not need the `base64` gem (a bundled gem since Ruby 3.4, and therefore one more
|
|
38
|
+
# thing a worker could fail to load). The strictness matters in one direction only: a malformed
|
|
39
|
+
# encoding of a *server's* answer is a bug this client should not paper over.
|
|
40
|
+
module Base64Codec
|
|
41
|
+
# Encode bytes, with padding.
|
|
42
|
+
def self.encode(bytes)
|
|
43
|
+
[bytes.b].pack('m0')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Decode base64, refusing anything that is not.
|
|
47
|
+
#
|
|
48
|
+
# `ArgumentError` rather than a protocol error: this is called on a caller's own input when a key
|
|
49
|
+
# or a value is being built as often as on the server's answer, and the two callers want
|
|
50
|
+
# different exception classes. {Volucer::Kv} turns it into a {ProtocolError} for the answer path.
|
|
51
|
+
def self.decode(text)
|
|
52
|
+
text.unpack1('m0')
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# One key-value storage, reached through a call plane.
|
|
57
|
+
#
|
|
58
|
+
# The shape is PSR-16's, which the server implements: a missing key is `nil` rather than an
|
|
59
|
+
# exception, and a *failure* is an exception, because those are different things and a cache that
|
|
60
|
+
# returned `nil` for a broken server would be a cache that silently stopped caching.
|
|
61
|
+
class Kv
|
|
62
|
+
# The storage name this handle reaches.
|
|
63
|
+
attr_reader :storage
|
|
64
|
+
|
|
65
|
+
def initialize(plane, storage = 'default')
|
|
66
|
+
@plane = plane
|
|
67
|
+
@storage = storage
|
|
68
|
+
@encode = ->(value) { Volucer.json_bytes(value) }
|
|
69
|
+
@decode = ->(raw) { Kv.decode_value(raw) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The value of a key, or `nil`.
|
|
73
|
+
#
|
|
74
|
+
# A *failure* raises, so a miss and a broken server look different.
|
|
75
|
+
def get(key)
|
|
76
|
+
answer = ask('get', 'key' => Kv.key_of(key))
|
|
77
|
+
encoded = answer['value']
|
|
78
|
+
return nil if encoded.nil?
|
|
79
|
+
|
|
80
|
+
@decode.call(Kv.unbase64(encoded))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Whether a key exists, without copying its value out of the store.
|
|
84
|
+
def has?(key)
|
|
85
|
+
answer = ask('has', 'key' => Kv.key_of(key))
|
|
86
|
+
answer['present'] == true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Write a value, optionally with a TTL in **seconds**.
|
|
90
|
+
def set(key, value, ttl: nil)
|
|
91
|
+
answer = ask('set', Kv.written(key, value, @encode).merge(Kv.ttl_param(ttl)))
|
|
92
|
+
answer['stored'] == true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Write **only if the key does not exist**, atomically on the storage side.
|
|
96
|
+
#
|
|
97
|
+
# `false` is a *successful* answer -- somebody else got there first -- and must not be read as a
|
|
98
|
+
# failure. This is the one method here that can protect an invariant: a lock has no fencing token
|
|
99
|
+
# and cannot stop a late writer, and a conditional write can, because the storage decides.
|
|
100
|
+
def set_if_absent(key, value, ttl: nil)
|
|
101
|
+
answer = ask('setIfAbsent', Kv.written(key, value, @encode).merge(Kv.ttl_param(ttl)))
|
|
102
|
+
answer['stored'] == true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Remove a key, answering whether it was there.
|
|
106
|
+
def delete(key)
|
|
107
|
+
answer = ask('delete', 'key' => Kv.key_of(key))
|
|
108
|
+
answer['deleted'] == true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The remaining TTL: a number of seconds, or `'missing'` / `'forever'`.
|
|
112
|
+
#
|
|
113
|
+
# A word rather than `-1`, because the three states are not a range and a caller checking
|
|
114
|
+
# `seconds > 0` would read "never expires" as "expired".
|
|
115
|
+
def ttl(key)
|
|
116
|
+
ask('ttl', 'key' => Kv.key_of(key))['ttl']
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Empty the storage.
|
|
120
|
+
def clear
|
|
121
|
+
ask('clear', {})['cleared'] == true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Several keys at once, as a Hash of the ones that were there.
|
|
125
|
+
#
|
|
126
|
+
# Missing keys are **absent from the Hash** rather than present with a `nil`, which is what makes
|
|
127
|
+
# `{ 'a' => nil }` -- a key holding JSON `null` -- distinguishable from a key that is not stored.
|
|
128
|
+
def get_multiple(keys)
|
|
129
|
+
ordered = keys.to_a
|
|
130
|
+
values = ask('getMultiple', 'keys' => ordered.map { |key| Kv.key_of(key) })['values'] || []
|
|
131
|
+
found = {}
|
|
132
|
+
ordered.each_with_index do |key, index|
|
|
133
|
+
encoded = index < values.length ? values[index] : nil
|
|
134
|
+
found[key] = @decode.call(Kv.unbase64(encoded)) unless encoded.nil?
|
|
135
|
+
end
|
|
136
|
+
found
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Several keys at once, keeping the *positions*: `nil` for each key that is missing.
|
|
140
|
+
def get_many(keys)
|
|
141
|
+
ordered = keys.to_a
|
|
142
|
+
values = ask('getMultiple', 'keys' => ordered.map { |key| Kv.key_of(key) })['values'] || []
|
|
143
|
+
ordered.each_index.map do |index|
|
|
144
|
+
value = index < values.length ? values[index] : nil
|
|
145
|
+
value.nil? ? nil : @decode.call(Kv.unbase64(value))
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Store several values at once, all with the same TTL in **seconds**.
|
|
150
|
+
def set_multiple(values, ttl: nil)
|
|
151
|
+
pairs = values.to_h
|
|
152
|
+
encoded = pairs.each_with_object({}) do |(key, value), out|
|
|
153
|
+
out[Kv.key_of(key)] = Base64Codec.encode(@encode.call(value))
|
|
154
|
+
end
|
|
155
|
+
answer = ask('setMultiple', { 'values' => encoded }.merge(Kv.ttl_param(ttl)))
|
|
156
|
+
answer['stored'].to_i == encoded.length
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Replace the JSON serializer with a pair of callables.
|
|
160
|
+
#
|
|
161
|
+
# The server never sees a difference: it stores whatever bytes `encode` returns.
|
|
162
|
+
def serializer(encode, decode)
|
|
163
|
+
@encode = encode
|
|
164
|
+
@decode = decode
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The default deserializer: JSON, falling back to bytes.
|
|
169
|
+
#
|
|
170
|
+
# A value written by `set` is always JSON. A value written by another application through the
|
|
171
|
+
# same storage -- a PHP worker, say -- need not be, and returning the bytes is the honest answer
|
|
172
|
+
# rather than an exception that would make one storage unusable by two languages.
|
|
173
|
+
#
|
|
174
|
+
# **The encoding is checked before parsing, rather than relying on `JSON.parse` to raise.** It
|
|
175
|
+
# does not, consistently: json 2.x as shipped with Ruby 3.1 and 3.3 returns a String for a
|
|
176
|
+
# binary argument it cannot decode, where the version local testing had raised `ParserError` --
|
|
177
|
+
# so a test that passed on one machine failed in CI with a value that was neither the bytes nor
|
|
178
|
+
# the JSON. Bytes that are not valid UTF-8 cannot be JSON text by definition, which is a fact
|
|
179
|
+
# about the data rather than about the library.
|
|
180
|
+
def self.decode_value(raw)
|
|
181
|
+
text = raw.dup.force_encoding(Encoding::UTF_8)
|
|
182
|
+
return raw unless text.valid_encoding?
|
|
183
|
+
|
|
184
|
+
JSON.parse(text)
|
|
185
|
+
rescue JSON::ParserError
|
|
186
|
+
raw
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# A KV key as the wire spells it: base64 of its bytes.
|
|
190
|
+
def self.key_of(key)
|
|
191
|
+
unless key.is_a?(String)
|
|
192
|
+
raise TypeError, "a kv key must be a String, not #{key.class}"
|
|
193
|
+
end
|
|
194
|
+
if key.empty?
|
|
195
|
+
raise ArgumentError,
|
|
196
|
+
'a kv key must not be empty; the server refuses one rather than storing it'
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
Base64Codec.encode(key)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Base64 of a value's JSON encoding, or of the bytes given.
|
|
203
|
+
#
|
|
204
|
+
# A value that is bytes is sent as given rather than re-encoded: a caller who compressed or
|
|
205
|
+
# encrypted it should not have it wrapped in a JSON string, which would double its size and change
|
|
206
|
+
# what a consumer reads.
|
|
207
|
+
def self.written(key, value, encode)
|
|
208
|
+
raw = bytes?(value) ? value : encode.call(value)
|
|
209
|
+
{ 'key' => key_of(key), 'value' => Base64Codec.encode(raw) }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Whether a value is bytes rather than something to serialise.
|
|
213
|
+
#
|
|
214
|
+
# **Two tests, and the second is the one that matters in practice.** A `File.binread` is tagged
|
|
215
|
+
# `BINARY`; a `File.read` of the same file is tagged UTF-8 and is not valid UTF-8, which Ruby
|
|
216
|
+
# permits and the JSON encoder refuses with `JSON::GeneratorError` from inside the library. A
|
|
217
|
+
# string that cannot be JSON text *is* bytes, so treating it as such is both the only workable
|
|
218
|
+
# answer and the honest one -- and it means the failure a caller sees is never a `GeneratorError`
|
|
219
|
+
# naming no argument of theirs.
|
|
220
|
+
def self.bytes?(value)
|
|
221
|
+
value.is_a?(String) && (value.encoding == Encoding::BINARY || !value.valid_encoding?)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The `ttl` parameter, or nothing.
|
|
225
|
+
#
|
|
226
|
+
# An empty Hash rather than a `nil` value: the server reads `ttl >= 0` as a TTL and absent as
|
|
227
|
+
# "no expiry", and sending `null` for the absent case would be a third thing to decode.
|
|
228
|
+
def self.ttl_param(ttl)
|
|
229
|
+
ttl.nil? ? {} : { 'ttl' => Integer(ttl) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Decode a server-supplied value, turning a bad encoding into a protocol error.
|
|
233
|
+
def self.unbase64(text)
|
|
234
|
+
Base64Codec.decode(text)
|
|
235
|
+
rescue ArgumentError => e
|
|
236
|
+
raise ProtocolError, "the server sent a value that is not valid base64: #{e.message}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
# One call, with a failure raised naming the storage and the reason.
|
|
242
|
+
def ask(verb, params)
|
|
243
|
+
name = "kv.#{@storage}.#{verb}"
|
|
244
|
+
answer = @plane.call(name, params)
|
|
245
|
+
answer.is_a?(Hash) ? answer : {}
|
|
246
|
+
rescue ProtocolError => e
|
|
247
|
+
# Re-raised with the storage in the message, because "kv.get failed" does not say which store
|
|
248
|
+
# -- and the server's own refusal already names the ones this backend *may* reach, which is the
|
|
249
|
+
# sentence an operator needs.
|
|
250
|
+
raise ProtocolError.new("#{name} failed: #{e.message}", fatal: e.fatal, unknown: e.unknown)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# The resource locks, reached through a call plane.
|
|
255
|
+
#
|
|
256
|
+
# A lock is for **collapsing work** -- two requests rendering the same PDF -- and not for
|
|
257
|
+
# protecting an invariant. A lease expires, and an expired lease is not a fencing token: a process
|
|
258
|
+
# that was paused past its TTL and then resumed will keep writing. That rule is the same one the
|
|
259
|
+
# worker channel's lock documents, because it is a property of leases rather than of the transport.
|
|
260
|
+
class Lock
|
|
261
|
+
def initialize(plane)
|
|
262
|
+
@plane = plane
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Take the lock, returning the id that holds it, or nil for contention.
|
|
266
|
+
#
|
|
267
|
+
# `ttl` and `wait` are **microseconds**. Zero `ttl` means "held until released" and zero `wait`
|
|
268
|
+
# means one attempt. Contention is a successful call with `acquired: false`, not an error --
|
|
269
|
+
# nothing failed, the caller just knows it is not in.
|
|
270
|
+
#
|
|
271
|
+
# `mode: :shared` takes a shared lock instead, which other readers may hold too while an
|
|
272
|
+
# exclusive holder excludes it. It defaults to exclusive, the reading a caller who did not think
|
|
273
|
+
# about it wants: a shared lock is an optimisation for a read path, and defaulting to it would
|
|
274
|
+
# silently stop two writers from excluding each other.
|
|
275
|
+
#
|
|
276
|
+
# A `wait` longer than the plane's timeout needs a longer timeout: the deadline is the client's,
|
|
277
|
+
# and a call abandoned mid-wait leaves the lock's state unknown -- which is why that case raises
|
|
278
|
+
# with {ProtocolError#unknown?} true and the remedy is `exists?`.
|
|
279
|
+
def acquire(resource, ttl: 0, wait: 0, mode: :exclusive, id: nil)
|
|
280
|
+
params = {
|
|
281
|
+
'resource' => resource, 'ttl' => Integer(ttl), 'wait' => Integer(wait),
|
|
282
|
+
'mode' => Lock.mode_of(mode)
|
|
283
|
+
}
|
|
284
|
+
params['id'] = id unless id.nil?
|
|
285
|
+
|
|
286
|
+
answer = ask('acquire', params)
|
|
287
|
+
return nil unless answer['acquired'] == true
|
|
288
|
+
|
|
289
|
+
answer['id']
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# One attempt with no waiting at all.
|
|
293
|
+
#
|
|
294
|
+
# Equivalent to `acquire(resource, wait: 0)`, and its own method because the server has one:
|
|
295
|
+
# `wait = 0` on a *contended* lock still costs a lookup, and saying "do not wait" is saying "one
|
|
296
|
+
# attempt, not a deadline that happens to be now".
|
|
297
|
+
def try_acquire(resource, ttl: 0, mode: :exclusive, id: nil)
|
|
298
|
+
params = { 'resource' => resource, 'ttl' => Integer(ttl), 'mode' => Lock.mode_of(mode) }
|
|
299
|
+
params['id'] = id unless id.nil?
|
|
300
|
+
|
|
301
|
+
answer = ask('try_acquire', params)
|
|
302
|
+
return nil unless answer['acquired'] == true
|
|
303
|
+
|
|
304
|
+
answer['id']
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Release a lock this caller holds, answering whether there was one.
|
|
308
|
+
#
|
|
309
|
+
# `false` means the id did not hold the resource -- the lease expired, or something released it --
|
|
310
|
+
# and that is an **answer rather than an error**, because "my lock is already gone" is not
|
|
311
|
+
# something an `ensure` block wants to raise for.
|
|
312
|
+
def release(resource, id)
|
|
313
|
+
ask('release', 'resource' => resource, 'id' => id)['released'] == true
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Release **every** lock on the resource, whoever holds them.
|
|
317
|
+
#
|
|
318
|
+
# The escape hatch for a resource wedged by a worker that died. It cannot be scoped by id and is
|
|
319
|
+
# not, which is why the server puts it behind the same grant that allows locking at all.
|
|
320
|
+
def force_release(resource)
|
|
321
|
+
ask('release_all', 'resource' => resource)['released'] == true
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Whether anything holds the resource.
|
|
325
|
+
#
|
|
326
|
+
# The method to call when an acquisition raised with an unknown outcome: it is the question that
|
|
327
|
+
# resolves it, and the server derives it from the same code that reports holders, so the two
|
|
328
|
+
# cannot disagree.
|
|
329
|
+
def exists?(resource)
|
|
330
|
+
ask('exists', 'resource' => resource)['exists'] == true
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Every holder of the resource, with what is left of each lease.
|
|
334
|
+
#
|
|
335
|
+
# A list of `{ 'id' => String, 'mode' => 'exclusive'|'shared', 'remaining_ms' => Integer|nil }`,
|
|
336
|
+
# where `nil` is a lease that does not expire. An expired holder is not reported, whether or not
|
|
337
|
+
# the backend has got round to removing it.
|
|
338
|
+
def holders(resource)
|
|
339
|
+
answer = ask('holders', 'resource' => resource)['holders']
|
|
340
|
+
answer.is_a?(Array) ? answer : []
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Set what is left of this id's lease, from now, in **microseconds**.
|
|
344
|
+
#
|
|
345
|
+
# **Replaces the deadline rather than extending it**: renewing for ten microseconds ten
|
|
346
|
+
# microseconds into a lease of twenty leaves ten, not twenty. A caller that reads it as "another
|
|
347
|
+
# N" can be released early.
|
|
348
|
+
#
|
|
349
|
+
# `false` means the id held nothing, which is the answer to act on -- the section being protected
|
|
350
|
+
# is no longer protected -- and not an error, because the call itself succeeded.
|
|
351
|
+
def update_ttl(resource, id, ttl)
|
|
352
|
+
ask('update_ttl', 'resource' => resource, 'id' => id, 'ttl' => Integer(ttl))['updated'] == true
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The wire's spelling of a mode.
|
|
356
|
+
#
|
|
357
|
+
# Both spellings the server accepts are accepted here, and a Symbol is what a Ruby caller writes,
|
|
358
|
+
# so `:read` works as well as `:shared`. Anything else is refused here rather than by the server,
|
|
359
|
+
# because a mode that is a typo is a caller's bug and this message names the line.
|
|
360
|
+
def self.mode_of(mode)
|
|
361
|
+
case mode.to_s
|
|
362
|
+
when 'exclusive', 'write' then 'exclusive'
|
|
363
|
+
when 'shared', 'read' then 'shared'
|
|
364
|
+
else
|
|
365
|
+
raise ArgumentError, "a lock mode is :exclusive or :shared, not #{mode.inspect}"
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
private
|
|
370
|
+
|
|
371
|
+
def ask(verb, params)
|
|
372
|
+
name = "lock.#{verb}"
|
|
373
|
+
answer = @plane.call(name, params)
|
|
374
|
+
answer.is_a?(Hash) ? answer : {}
|
|
375
|
+
rescue ProtocolError => e
|
|
376
|
+
raise ProtocolError.new("#{name} failed: #{e.message}", fatal: e.fatal, unknown: e.unknown)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# One queue pipeline, from the producer's side.
|
|
381
|
+
#
|
|
382
|
+
# The payload is the application's own bytes and the server never parses it -- the consuming side
|
|
383
|
+
# is a worker with a `JOB` handler, and what it finds in the body is whatever this side put there.
|
|
384
|
+
class Jobs
|
|
385
|
+
# The pipeline this handle pushes to.
|
|
386
|
+
attr_reader :pipeline
|
|
387
|
+
|
|
388
|
+
def initialize(plane, pipeline)
|
|
389
|
+
@plane = plane
|
|
390
|
+
@pipeline = pipeline
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Enqueue one job, answering the id the server assigned.
|
|
394
|
+
#
|
|
395
|
+
# `task` is the name the consuming side dispatches on and is **required**: the server refuses a
|
|
396
|
+
# push without one rather than guessing from the pipeline, because a queue whose task name was
|
|
397
|
+
# implicit would put every job on a pipeline into one handler.
|
|
398
|
+
#
|
|
399
|
+
# `payload` is a String of the application's own bytes, sent as given. A Hash or an Array is
|
|
400
|
+
# JSON-encoded, and `nil` is the empty payload -- which is what a job whose whole instruction is
|
|
401
|
+
# its task name sends.
|
|
402
|
+
#
|
|
403
|
+
# `headers` are string metadata such as `traceparent`. A non-string value is refused here rather
|
|
404
|
+
# than by the server: a header that was meant to be in the payload is a caller's bug.
|
|
405
|
+
def push(task, payload: '', headers: {})
|
|
406
|
+
if task.nil? || task.to_s.empty?
|
|
407
|
+
raise ArgumentError, 'a job needs a task name, or its consumer cannot tell what it is'
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
params = {
|
|
411
|
+
'pipeline' => @pipeline,
|
|
412
|
+
'task' => task.to_s,
|
|
413
|
+
'payload' => Base64Codec.encode(Jobs.payload_of(payload)),
|
|
414
|
+
# An empty Hash is sent as `{}` rather than omitted: the server reads an absent map and an
|
|
415
|
+
# empty one the same way, and a client that varies its params by emptiness is a client whose
|
|
416
|
+
# wire format has two shapes for one call.
|
|
417
|
+
'headers' => Jobs.string_headers(headers)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
id = ask('push', params)['id']
|
|
421
|
+
if !id.is_a?(String) || id.empty?
|
|
422
|
+
# Unreachable against this server, and checked because the alternative is returning an empty
|
|
423
|
+
# string for a job that was pushed -- which an application would log as "the queue is broken"
|
|
424
|
+
# on a call that succeeded.
|
|
425
|
+
raise ProtocolError, 'the server accepted a push without naming the job'
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
id
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Enqueue several jobs at once, answering the ids that were accepted.
|
|
432
|
+
#
|
|
433
|
+
# **A batch is not atomic, and this throws honestly about it**: a batch whose third push fails has
|
|
434
|
+
# already pushed two, and reporting that as a total failure would make a caller's retry duplicate
|
|
435
|
+
# them. So a partial batch raises with the ids that made it in the message, and the caller decides
|
|
436
|
+
# whether to retry the tail.
|
|
437
|
+
#
|
|
438
|
+
# @param jobs [Array<Hash>] each with `:task` and optionally `:payload` and `:headers`.
|
|
439
|
+
# @return [Array<String>] the ids, in the order the jobs were given.
|
|
440
|
+
def push_batch(jobs)
|
|
441
|
+
entries = jobs.each_with_index.map do |job, index|
|
|
442
|
+
unless job.is_a?(Hash) && !job[:task].to_s.empty?
|
|
443
|
+
raise ArgumentError, "the job at index #{index} needs a task name"
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
{
|
|
447
|
+
'pipeline' => @pipeline,
|
|
448
|
+
'task' => job[:task].to_s,
|
|
449
|
+
'payload' => Base64Codec.encode(Jobs.payload_of(job[:payload])),
|
|
450
|
+
'headers' => Jobs.string_headers(job[:headers] || {})
|
|
451
|
+
}
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
return [] if entries.empty?
|
|
455
|
+
|
|
456
|
+
answer = ask('push_batch', 'jobs' => entries)
|
|
457
|
+
ids = answer['ids'].is_a?(Array) ? answer['ids'].select { |id| id.is_a?(String) } : []
|
|
458
|
+
|
|
459
|
+
unless answer['failed_index'].nil?
|
|
460
|
+
raise ProtocolError,
|
|
461
|
+
"the batch stopped at index #{answer['failed_index']}: #{answer['error'] || 'no reason given'}; " \
|
|
462
|
+
"the #{ids.length} jobs before it were pushed (#{ids.join(', ')}) -- retry from there"
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
ids
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# How many jobs are ready, and how many are held by a consumer.
|
|
469
|
+
#
|
|
470
|
+
# Both numbers, because the pair is what an operator reads: a pipeline whose ready count grows
|
|
471
|
+
# while its in-flight count stays flat is a consumer that is stuck, and neither number says that
|
|
472
|
+
# alone.
|
|
473
|
+
#
|
|
474
|
+
# @return [Hash] `{ 'size' => Integer, 'in_flight' => Integer }`
|
|
475
|
+
def size
|
|
476
|
+
answer = ask('size', 'pipeline' => @pipeline)
|
|
477
|
+
{ 'size' => answer['size'].to_i, 'in_flight' => answer['in_flight'].to_i }
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Every pipeline this deployment has, with its driver.
|
|
481
|
+
#
|
|
482
|
+
# For a diagnostic, and for the refusal a typo in a pipeline name produces -- which is where the
|
|
483
|
+
# list is most useful. It is *every* pipeline, not the ones this backend may push to, because a
|
|
484
|
+
# queue is not scoped per backend the way a KV storage is -- which is also why the answer does not
|
|
485
|
+
# depend on the handle it was asked through, and why this matches the PHP SDK's `Jobs::pipelines`
|
|
486
|
+
# rather than taking no receiver at all.
|
|
487
|
+
def pipelines
|
|
488
|
+
answer = ask('pipelines', {})
|
|
489
|
+
list = answer['pipelines']
|
|
490
|
+
list.is_a?(Array) ? list : []
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# A payload's bytes: as given when it is a String, JSON otherwise.
|
|
494
|
+
#
|
|
495
|
+
# The same rule {Kv.bytes?} follows, and the reason it is a little broader here: a caller writes
|
|
496
|
+
# `payload: '{"to":"a@b"}'` far more often than it hands over binary, so a *valid* UTF-8 String is
|
|
497
|
+
# the bytes verbatim while a String that cannot be JSON text is treated as bytes too. Only a
|
|
498
|
+
# non-String -- a Hash, an Array -- is JSON-encoded.
|
|
499
|
+
def self.payload_of(payload)
|
|
500
|
+
return ''.b if payload.nil?
|
|
501
|
+
return payload.b if payload.is_a?(String)
|
|
502
|
+
|
|
503
|
+
Volucer.json_bytes(payload)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# The header map, refusing a non-string value.
|
|
507
|
+
def self.string_headers(headers)
|
|
508
|
+
headers.each_with_object({}) do |(name, value), out|
|
|
509
|
+
unless name.is_a?(String) && value.is_a?(String)
|
|
510
|
+
raise ArgumentError,
|
|
511
|
+
"the header #{name.inspect} must be a pair of Strings: headers are metadata, and a " \
|
|
512
|
+
'structured value belongs in the payload'
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
out[name] = value
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
private
|
|
520
|
+
|
|
521
|
+
def ask(verb, params)
|
|
522
|
+
name = "jobs.#{verb}"
|
|
523
|
+
answer = @plane.call(name, params)
|
|
524
|
+
answer.is_a?(Hash) ? answer : {}
|
|
525
|
+
rescue ProtocolError => e
|
|
526
|
+
raise ProtocolError.new("#{name} failed: #{e.message}", fatal: e.fatal, unknown: e.unknown)
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|