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,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