zizq 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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +94 -0
  4. data/bin/profile-worker +145 -0
  5. data/bin/zizq-worker +174 -0
  6. data/lib/active_job/queue_adapters/zizq_adapter.rb +109 -0
  7. data/lib/zizq/ack_processor.rb +132 -0
  8. data/lib/zizq/active_job_config.rb +122 -0
  9. data/lib/zizq/backoff.rb +50 -0
  10. data/lib/zizq/bulk_enqueue.rb +87 -0
  11. data/lib/zizq/client.rb +982 -0
  12. data/lib/zizq/configuration.rb +164 -0
  13. data/lib/zizq/enqueue_request.rb +178 -0
  14. data/lib/zizq/enqueue_with.rb +109 -0
  15. data/lib/zizq/error.rb +43 -0
  16. data/lib/zizq/job.rb +188 -0
  17. data/lib/zizq/job_config.rb +244 -0
  18. data/lib/zizq/lifecycle.rb +58 -0
  19. data/lib/zizq/middleware.rb +79 -0
  20. data/lib/zizq/query.rb +566 -0
  21. data/lib/zizq/resources/error_enumerator.rb +241 -0
  22. data/lib/zizq/resources/error_page.rb +19 -0
  23. data/lib/zizq/resources/error_record.rb +19 -0
  24. data/lib/zizq/resources/job.rb +124 -0
  25. data/lib/zizq/resources/job_page.rb +57 -0
  26. data/lib/zizq/resources/page.rb +77 -0
  27. data/lib/zizq/resources/resource.rb +45 -0
  28. data/lib/zizq/resources.rb +16 -0
  29. data/lib/zizq/version.rb +9 -0
  30. data/lib/zizq/worker.rb +467 -0
  31. data/lib/zizq.rb +269 -0
  32. data/sig/generated/zizq/ack_processor.rbs +73 -0
  33. data/sig/generated/zizq/active_job_config.rbs +74 -0
  34. data/sig/generated/zizq/backoff.rbs +34 -0
  35. data/sig/generated/zizq/bulk_enqueue.rbs +72 -0
  36. data/sig/generated/zizq/client.rbs +419 -0
  37. data/sig/generated/zizq/configuration.rbs +95 -0
  38. data/sig/generated/zizq/enqueue_request.rbs +94 -0
  39. data/sig/generated/zizq/enqueue_with.rbs +88 -0
  40. data/sig/generated/zizq/error.rbs +41 -0
  41. data/sig/generated/zizq/job.rbs +136 -0
  42. data/sig/generated/zizq/job_config.rbs +150 -0
  43. data/sig/generated/zizq/lifecycle.rbs +34 -0
  44. data/sig/generated/zizq/middleware.rbs +50 -0
  45. data/sig/generated/zizq/query.rbs +327 -0
  46. data/sig/generated/zizq/resources/error_enumerator.rbs +148 -0
  47. data/sig/generated/zizq/resources/error_page.rbs +13 -0
  48. data/sig/generated/zizq/resources/error_record.rbs +20 -0
  49. data/sig/generated/zizq/resources/job.rbs +89 -0
  50. data/sig/generated/zizq/resources/job_page.rbs +33 -0
  51. data/sig/generated/zizq/resources/page.rbs +47 -0
  52. data/sig/generated/zizq/resources/resource.rbs +26 -0
  53. data/sig/generated/zizq/version.rbs +5 -0
  54. data/sig/generated/zizq/worker.rbs +152 -0
  55. data/sig/generated/zizq.rbs +180 -0
  56. data/sig/zizq.rbs +111 -0
  57. metadata +134 -0
@@ -0,0 +1,982 @@
1
+ # Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ # rbs_inline: enabled
5
+ # frozen_string_literal: true
6
+
7
+ require "async"
8
+ require "async/barrier"
9
+ require "async/http/client"
10
+ require "async/http/endpoint"
11
+ require "protocol/http/body/buffered"
12
+ require "msgpack"
13
+ require "json"
14
+ require "stringio"
15
+ require "uri"
16
+ require "weakref"
17
+
18
+ module Zizq
19
+ # Low-level HTTP wrapper for the Zizq job queue server API.
20
+ #
21
+ # Supports both JSON and MessagePack serialization formats, determined at
22
+ # construction time.
23
+ #
24
+ # HTTP requests are dispatched through a persistent background IO thread
25
+ # when called from non-Async contexts, keeping the HTTP/2 connection alive
26
+ # across calls and avoiding ephemeral port exhaustion. When called from
27
+ # within an existing Async reactor, the shared HTTP client is used directly.
28
+ class Client
29
+ # A fully-read HTTP response (status + decoded body), safe to use outside
30
+ # the async reactor that produced it.
31
+ RawResponse = Data.define(:status, :body, :content_type)
32
+
33
+ CONTENT_TYPES = { #: Hash[Zizq::format, String]
34
+ msgpack: "application/msgpack",
35
+ json: "application/json"
36
+ }.freeze
37
+
38
+ STREAM_ACCEPT = { #: Hash[Zizq::format, String]
39
+ msgpack: "application/vnd.zizq.msgpack-stream",
40
+ json: "application/x-ndjson"
41
+ }.freeze
42
+
43
+ # The base URL of the Zizq server (e.g. "https://localhost:7890")
44
+ attr_reader :url #: String
45
+
46
+ # The message format to use for all communication between the client and
47
+ # the server (default = `:msgpack`).
48
+ attr_reader :format #: Zizq::format
49
+
50
+ # Initialize a new instance of the client with the given base URL and
51
+ # optional format options.
52
+ #
53
+ # @rbs url: String
54
+ # @rbs format: Zizq::format
55
+ # @rbs ssl_context: OpenSSL::SSL::SSLContext?
56
+ # @rbs return: void
57
+ def initialize(url:, format: :msgpack, ssl_context: nil)
58
+ @url = url.chomp("/")
59
+ @format = format
60
+
61
+ endpoint_options = { protocol: Async::HTTP::Protocol::HTTP2 } #: Hash[Symbol, untyped]
62
+ endpoint_options[:ssl_context] = ssl_context if ssl_context
63
+
64
+ @endpoint = Async::HTTP::Endpoint.parse(
65
+ @url,
66
+ **endpoint_options,
67
+ )
68
+
69
+ # Streaming take uses a dedicated HTTP/1.1 endpoint. The take
70
+ # connection is long-lived and carries only one request, so HTTP/2's
71
+ # multiplexing, stream IDs, and frame headers add overhead with no
72
+ # benefit — there's nothing to multiplex against. Acks/enqueues run
73
+ # on separate threads with their own HTTP/2 clients, so they're
74
+ # unaffected either way. HTTP/1.1 gives the stream a plain TCP
75
+ # socket with no framing tax and measurably better throughput.
76
+ stream_endpoint_options = endpoint_options.merge(
77
+ protocol: Async::HTTP::Protocol::HTTP11,
78
+ )
79
+ @stream_endpoint = Async::HTTP::Endpoint.parse(
80
+ @url,
81
+ **stream_endpoint_options,
82
+ )
83
+
84
+ @mutex = Mutex.new
85
+
86
+ @io_thread = nil #: Thread?
87
+ @io_queue = nil #: Thread::Queue?
88
+
89
+ # Each thread gets its own Async::HTTP::Client bound to its own
90
+ # reactor — one for regular request/response traffic (HTTP/2) and
91
+ # a separate one lazily created on the first take_jobs call
92
+ # (HTTP/1.1). Both kinds of clients are tracked in a single array
93
+ # so `close` can shut them all down together.
94
+ @http_clients = [] #: Array[Async::HTTP::Client]
95
+ @http_key = :"zizq_http_#{object_id}"
96
+ @stream_http_key = :"zizq_stream_http_#{object_id}"
97
+
98
+ @content_type = CONTENT_TYPES.fetch(format)
99
+ @stream_accept = STREAM_ACCEPT.fetch(format)
100
+ end
101
+
102
+ # Close all thread-local HTTP clients and release connections.
103
+ def close #: () -> void
104
+ if @io_thread&.alive?
105
+ @mutex.synchronize do
106
+ @io_queue&.close
107
+ @io_thread&.join
108
+ end
109
+ end
110
+
111
+ self.class.make_finalizer(@io_queue, @http_clients).call
112
+ end
113
+
114
+ def cleanup_internal_clients #: () -> void
115
+ @mutex.synchronize do
116
+ @http_clients.each do |ref|
117
+ ref.close
118
+ rescue WeakRef::RefError
119
+ # Client already GC'd (owning thread exited).
120
+ rescue NoMethodError
121
+ # The async connection pool may hold references to tasks whose
122
+ # fibers were already reclaimed when their owning Sync reactor
123
+ # exited. Stopping those dead tasks raises NoMethodError; safe
124
+ # to ignore.
125
+ end
126
+ @http_clients.clear
127
+ end
128
+ end
129
+
130
+ # Enqueue a new job.
131
+ #
132
+ # This is a low-level primitive that makes a direct API call to the server
133
+ # using the Zizq API's expected inputs. Callers should generally use
134
+ # [`Zizq::enqueue`] instead.
135
+ #
136
+ # Returns a resource instance of the new job wrapping the API response.
137
+ #
138
+ # @rbs queue: String
139
+ # @rbs type: String
140
+ # @rbs payload: Hash[String | Symbol, untyped]
141
+ # @rbs priority: Integer?
142
+ # @rbs ready_at: Zizq::to_f?
143
+ # @rbs retry_limit: Integer?
144
+ # @rbs backoff: Zizq::backoff?
145
+ # @rbs retention: Zizq::retention?
146
+ # @rbs unique_key: String?
147
+ # @rbs unique_while: Zizq::unique_scope?
148
+ # @rbs return: Resources::Job
149
+ def enqueue(queue:,
150
+ type:,
151
+ payload:,
152
+ priority: nil,
153
+ ready_at: nil,
154
+ retry_limit: nil,
155
+ backoff: nil,
156
+ retention: nil,
157
+ unique_key: nil,
158
+ unique_while: nil)
159
+ body = { queue:, type:, payload: } #: Hash[Symbol, untyped]
160
+ body[:priority] = priority if priority
161
+ # ready_at is fractional seconds in Ruby; the server expects ms.
162
+ body[:ready_at] = (ready_at.to_f * 1000).to_i if ready_at
163
+ body[:retry_limit] = retry_limit if retry_limit
164
+ body[:backoff] = backoff if backoff
165
+ body[:retention] = retention if retention
166
+ body[:unique_key] = unique_key if unique_key
167
+ body[:unique_while] = unique_while.to_s if unique_while
168
+
169
+ response = post("/jobs", body)
170
+ data = handle_response!(response, expected: [200, 201])
171
+ Resources::Job.new(self, data)
172
+ end
173
+
174
+ # Enqueue multiple jobs atomically in a single bulk request.
175
+ #
176
+ # This is a low-level primitive that makes a direct API call to the server
177
+ # using the Zizq API's expected inputs. Callers should generally use
178
+ # [`Zizq::enqueue_bulk`] instead.
179
+ #
180
+ # Returns an array of resource instances wrapping the API response.
181
+ #
182
+ # @rbs jobs: Array[Hash[Symbol, untyped]]
183
+ # @rbs return: Array[Resources::Job]
184
+ def enqueue_bulk(jobs:)
185
+ body = {
186
+ jobs: jobs.map do |job|
187
+ wire = { type: job[:type], queue: job[:queue], payload: job[:payload] } #: Hash[Symbol, untyped]
188
+ wire[:priority] = job[:priority] if job[:priority]
189
+ # ready_at is fractional seconds in Ruby; the server expects ms.
190
+ wire[:ready_at] = (job[:ready_at].to_f * 1000).to_i if job[:ready_at]
191
+ wire[:retry_limit] = job[:retry_limit] if job[:retry_limit]
192
+ wire[:backoff] = job[:backoff] if job[:backoff]
193
+ wire[:retention] = job[:retention] if job[:retention]
194
+ wire[:unique_key] = job[:unique_key] if job[:unique_key]
195
+ wire[:unique_while] = job[:unique_while].to_s if job[:unique_while]
196
+ wire
197
+ end
198
+ }
199
+
200
+ response = post("/jobs/bulk", body)
201
+ data = handle_response!(response, expected: [200, 201])
202
+ data["jobs"].map { |j| Resources::Job.new(self, j) }
203
+ end
204
+
205
+ # Get a single job by ID.
206
+ def get_job(id) #: (String) -> Resources::Job
207
+ response = get("/jobs/#{id}")
208
+ data = handle_response!(response, expected: 200)
209
+ Resources::Job.new(self, data)
210
+ end
211
+
212
+ # List jobs with optional filters.
213
+ #
214
+ # Multi-value filters (`status`, `queue`, `type`, `id`) accept arrays —
215
+ # they are joined with commas as the server expects.
216
+ #
217
+ # The `filter` parameter accepts a jq expression for filtering jobs by
218
+ # payload content (e.g. `.user_id == 42`).
219
+ #
220
+ # @rbs id: (String | Array[String])?
221
+ # @rbs status: (String | Array[String])?
222
+ # @rbs queue: (String | Array[String])?
223
+ # @rbs type: (String | Array[String])?
224
+ # @rbs filter: String?
225
+ # @rbs from: String?
226
+ # @rbs order: Zizq::sort_direction?
227
+ # @rbs limit: Integer?
228
+ # @rbs return: Resources::JobPage
229
+ def list_jobs(id: nil,
230
+ status: nil,
231
+ queue: nil,
232
+ type: nil,
233
+ filter: nil,
234
+ from: nil,
235
+ order: nil,
236
+ limit: nil)
237
+ options = { id:, status:, queue:, type:, filter:, from:, order:, limit: }.compact #: Hash[Symbol, untyped]
238
+
239
+ multi_keys = %i[id status queue type]
240
+ params = build_where_params(options, multi_keys:)
241
+
242
+ # An empty filter ([] or "") matches nothing — short-circuit.
243
+ multi_keys.each do |key|
244
+ return Resources::JobPage.new(self, { "jobs" => [], "pages" => {} }) if params[key] == ""
245
+ end
246
+
247
+ response = get("/jobs", params:)
248
+ data = handle_response!(response, expected: 200)
249
+ Resources::JobPage.new(self, data)
250
+ end
251
+
252
+ # Delete a single job by ID.
253
+ #
254
+ # @rbs id: String
255
+ # @rbs return: void
256
+ def delete_job(id)
257
+ response = delete("/jobs/#{id}")
258
+ handle_response!(response, expected: [200, 204])
259
+ nil
260
+ end
261
+
262
+ # Delete jobs matching the given filters.
263
+ #
264
+ # Filters in the `where:` argument use the same keys as `list_jobs`. An
265
+ # empty `where:` hash deletes all jobs.
266
+ #
267
+ # Returns the number of deleted jobs.
268
+ #
269
+ # @rbs where: Zizq::where_params
270
+ # @rbs return: Integer
271
+ def delete_all_jobs(where: {})
272
+ filter_params = validate_where(**where)
273
+
274
+ multi_keys = %i[id status queue type]
275
+ params = build_where_params(filter_params, multi_keys:)
276
+
277
+ # An empty multi-value filter matches nothing — short-circuit.
278
+ multi_keys.each do |key|
279
+ return 0 if params[key] == ""
280
+ end
281
+
282
+ response = delete("/jobs", params:)
283
+ data = handle_response!(response, expected: 200)
284
+ data.fetch("deleted")
285
+ end
286
+
287
+ # Update a single job's mutable fields.
288
+ #
289
+ # Fields not provided are left unchanged. Use `Zizq::RESET` to clear
290
+ # a nullable field back to the server default.
291
+ #
292
+ # Raises `Zizq::NotFoundError` if the job does not exist.
293
+ # Raises `Zizq::ClientError` (422) if the job is in a terminal state.
294
+ #
295
+ # @rbs id: String
296
+ # @rbs queue: (String | singleton(Zizq::UNCHANGED))?
297
+ # @rbs priority: (Integer | singleton(Zizq::UNCHANGED))?
298
+ # @rbs ready_at: (Zizq::to_f | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
299
+ # @rbs retry_limit: (Integer | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
300
+ # @rbs backoff: (Zizq::backoff | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
301
+ # @rbs retention: (Zizq::retention | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
302
+ # @rbs return: Resources::Job
303
+ def update_job(id,
304
+ queue: UNCHANGED,
305
+ priority: UNCHANGED,
306
+ ready_at: UNCHANGED,
307
+ retry_limit: UNCHANGED,
308
+ backoff: UNCHANGED,
309
+ retention: UNCHANGED)
310
+ body = build_set_body(
311
+ queue:, priority:, ready_at:,
312
+ retry_limit:, backoff:, retention:
313
+ )
314
+ response = patch("/jobs/#{id}", body)
315
+ data = handle_response!(response, expected: 200)
316
+ Resources::Job.new(self, data)
317
+ end
318
+
319
+ # Update all jobs matching the given filters.
320
+ #
321
+ # Filters in the `where:` argument use the same keys as `list_jobs`.
322
+ # Fields in the `apply:` argument use the same keys as `update_job`.
323
+ #
324
+ # Terminal jobs (completed/dead) are silently skipped unless explicitly
325
+ # requested via `status:` in `where:`, which returns 422.
326
+ #
327
+ # Returns the number of updated jobs.
328
+ #
329
+ # @rbs where: Zizq::where_params
330
+ # @rbs apply: Zizq::apply_params
331
+ # @rbs return: Integer
332
+ def update_all_jobs(where: {}, apply: {})
333
+ filter_params = validate_where(**where)
334
+
335
+ multi_keys = %i[id status queue type]
336
+ params = build_where_params(filter_params, multi_keys:)
337
+
338
+ # An empty multi-value filter matches nothing — short-circuit.
339
+ multi_keys.each do |key|
340
+ return 0 if params[key] == ""
341
+ end
342
+
343
+ body = validate_and_build_set(**apply)
344
+ response = patch("/jobs", body, params:)
345
+ data = handle_response!(response, expected: 200)
346
+ data.fetch("patched")
347
+ end
348
+
349
+ # Get a single error record by job ID and attempt number.
350
+ #
351
+ # @rbs id: String
352
+ # @rbs attempt: Integer
353
+ # @rbs return: Resources::ErrorRecord
354
+ def get_error(id, attempt:)
355
+ response = get("/jobs/#{id}/errors/#{attempt}")
356
+ data = handle_response!(response, expected: 200)
357
+ Resources::ErrorRecord.new(self, data)
358
+ end
359
+
360
+ # List error records for a job.
361
+ #
362
+ # @rbs id: String
363
+ # @rbs from: String?
364
+ # @rbs order: Zizq::sort_direction?
365
+ # @rbs limit: Integer?
366
+ # @rbs return: Resources::ErrorPage
367
+ def list_errors(id, from: nil, order: nil, limit: nil)
368
+ params = { from:, order:, limit: }.compact #: Hash[Symbol, untyped]
369
+ response = get("/jobs/#{id}/errors", params:)
370
+ data = handle_response!(response, expected: 200)
371
+ Resources::ErrorPage.new(self, data)
372
+ end
373
+
374
+ # Health check.
375
+ def health #: () -> Hash[String, untyped]
376
+ response = get("/health")
377
+ handle_response!(response, expected: 200)
378
+ end
379
+
380
+ # Server version string.
381
+ def server_version #: () -> String
382
+ response = get("/version")
383
+ data = handle_response!(response, expected: 200)
384
+ data["version"]
385
+ end
386
+
387
+ # List all distinct queue names on the server.
388
+ def get_queues #: () -> Array[String]
389
+ response = get("/queues")
390
+ data = handle_response!(response, expected: 200)
391
+ data["queues"]
392
+ end
393
+
394
+ # Mark a job as successfully completed (ack).
395
+ #
396
+ # If this method (or [`#report_failure`]) is not called upon job
397
+ # completion, the Zizq server will consider it in-flight and will not
398
+ # send any more jobs if the prefetch limit has been reached, or the
399
+ # server's global in-flight limit has been reached. Jobs must be either
400
+ # acknowledged or failed before new jobs are sent.
401
+ #
402
+ # Jobs are durable and "at least once" delivery is guaranteed. If the
403
+ # client disconnects before it is able to report success or failure the
404
+ # server automatically moves the job back to the queue where it will be
405
+ # provided to another worker. Clients should be prepared to see the same
406
+ # job more than once for this reason.
407
+ #
408
+ # The Zizq server sends heartbeat messages to connected workers so that
409
+ # it can quickly detect and handle disconnected clients.
410
+ def report_success(id) #: (String) -> nil
411
+ response = raw_post("/jobs/#{id}/success")
412
+ handle_response!(response, expected: 204)
413
+ nil
414
+ end
415
+
416
+ # Bulk-mark jobs as successfully completed (bulk ack).
417
+ #
418
+ # See [`#report_success`] for full details of how acknowledgemen works.
419
+ #
420
+ # There are two ways in which the server can respond successfully:
421
+ #
422
+ # 1. 204 - No Content (All jobs acknowledged)
423
+ # 2. 422 - Unprocessible Entity (Some jobs were not found)
424
+ #
425
+ # Both of these statuses are in reality treated as success because missing
426
+ # jobs have either been previously acknowledged and purged, or moved to
427
+ # some other status that cannot be acknowledged.
428
+ #
429
+ # Other error response types will still raise.
430
+ #
431
+ # @rbs ids: Array[String]
432
+ # @rbs return: nil
433
+ def report_success_bulk(ids)
434
+ response = post("/jobs/success", { ids: ids })
435
+ return nil if response.status == 422
436
+ handle_response!(response, expected: 204)
437
+ end
438
+
439
+ alias ack_bulk report_success_bulk
440
+
441
+ # Report a job failure (nack).
442
+ #
443
+ # Returns the updated job metadata.
444
+ #
445
+ # If this method is not called when errors occur processing jobs, the
446
+ # Zizq server will consider it in-flight and will not send any more jobs
447
+ # if the prefetch limit has been reached, or the server's global in-flight
448
+ # limit has been reached. Jobs must be either acknowledged or failed before
449
+ # new jobs are sent.
450
+ #
451
+ # Jobs are durable and "at least once" delivery is guaranteed. If the
452
+ # client disconnects before it is able to report success or failure the
453
+ # server automatically moves the job back to the queue where it will be
454
+ # provided to another worker. Clients should be prepared to see the same
455
+ # job more than once for this reason.
456
+ #
457
+ # The Zizq server sends heartbeat messages to connected workers so that
458
+ # it can quickly detect and handle disconnected clients.
459
+ #
460
+ # @rbs id: String
461
+ # @rbs message: String
462
+ # @rbs error_type: String?
463
+ # @rbs backtrace: String?
464
+ # @rbs retry_at: Float?
465
+ # @rbs kill: bool
466
+ # @rbs return: Resources::Job
467
+ def report_failure(id, message:, error_type: nil, backtrace: nil, retry_at: nil, kill: false)
468
+ body = { message: } #: Hash[Symbol, untyped]
469
+ body[:error_type] = error_type if error_type
470
+ body[:backtrace] = backtrace if backtrace
471
+ # retry_at is fractional seconds in Ruby; the server expects ms.
472
+ body[:retry_at] = (retry_at * 1000).to_i if retry_at
473
+ body[:kill] = kill if kill
474
+
475
+ response = post("/jobs/#{id}/failure", body)
476
+ data = handle_response!(response, expected: 200)
477
+ Resources::Job.new(self, data)
478
+ end
479
+
480
+ # Aliases for ack/nack vs report_success/report_failure.
481
+ alias ack report_success
482
+ alias nack report_failure
483
+
484
+ # Stream jobs from the server. Yields parsed job hashes.
485
+ #
486
+ # This method does not return unless the server closes the connection or
487
+ # the connection is otherwise interrupted. Jobs are continuously streamed
488
+ # to the client, and when no jobs are available the client waits for new
489
+ # jobs to become ready.
490
+ #
491
+ # If the client does not acknowledge or fail jobs with `[#report_success`]
492
+ # or [`#report_failure`] the server will stop sending new jobs to the
493
+ # client as it hits its prefetch limit.
494
+ #
495
+ # Jobs are durable and "at least once" delivery is guaranteed. If the
496
+ # client disconnects before it is able to report success or failure the
497
+ # server automatically moves the job back to the queue where it will be
498
+ # provided to another worker. Clients should be prepared to see the same
499
+ # job more than once for this reason.
500
+ #
501
+ # The Zizq server sends periodic heartbeat messages to the client which are
502
+ # silently consumed.
503
+ #
504
+ # Example:
505
+ #
506
+ # client.take_jobs(prefetch: 5) do |job|
507
+ # puts "Got job: #{job.inspect}"
508
+ # client.ack(job.id) # mark the job completed
509
+ # end
510
+ #
511
+ # @rbs prefetch: Integer
512
+ # @rbs queues: Array[String]
513
+ # @rbs worker_id: String?
514
+ # @rbs &block: (Resources::Job) -> void
515
+ # @rbs return: void
516
+ def take_jobs(prefetch: 1, queues: [], worker_id: nil, on_connect: nil, on_response: nil, &block)
517
+ raise ArgumentError, "take_jobs requires a block" unless block
518
+
519
+ params = { prefetch: } #: Hash[Symbol, untyped]
520
+ params[:queue] = queues.join(",") unless queues.empty?
521
+
522
+ path = build_path("/jobs/take", params:)
523
+ headers = { "accept" => @stream_accept }
524
+ headers["worker-id"] = worker_id if worker_id
525
+
526
+ Sync do
527
+ response = stream_http.get(path, headers)
528
+
529
+ begin
530
+ raise StreamError, "take jobs stream returned HTTP #{response.status}" unless response.status == 200
531
+ on_connect&.call
532
+ on_response&.call(response)
533
+
534
+ # Wrap each parsed hash in a Resources::Job before yielding.
535
+ wrapper = proc { |data| block.call(Resources::Job.new(self, data)) }
536
+
537
+ # async-http returns `nil` for empty response bodies over HTTP/1.1
538
+ # (e.g. a 200 with content-length: 0 from the server closing the
539
+ # stream immediately). Treat that as "no chunks" rather than
540
+ # crashing in the parser.
541
+ body = response.body || []
542
+
543
+ case @format
544
+ when :json then self.class.parse_ndjson(body, &wrapper)
545
+ when :msgpack then self.class.parse_msgpack_stream(body, &wrapper)
546
+ end
547
+ ensure
548
+ response.close rescue nil
549
+ end
550
+ end
551
+ rescue SocketError, IOError, EOFError, Errno::ECONNRESET, Errno::EPIPE,
552
+ OpenSSL::SSL::SSLError => e
553
+ raise ConnectionError, e.message
554
+ end
555
+
556
+ # Parse an NDJSON stream from an enumerable of byte chunks.
557
+ #
558
+ # Buffers chunks and splits on newline boundaries. The buffer only
559
+ # ever holds one partial line between extractions, so the `slice!`
560
+ # cost is trivial. Empty lines (heartbeats) are silently skipped.
561
+ def self.parse_ndjson(chunks) #: (Enumerable[String]) { (Hash[String, untyped]) -> void } -> void
562
+ buffer = +""
563
+ chunks.each do |chunk|
564
+ buffer << chunk
565
+ while (idx = buffer.index("\n"))
566
+ line = buffer.slice!(0, idx + 1) #: String
567
+ line.strip!
568
+ next if line.empty?
569
+ yield JSON.parse(line)
570
+ end
571
+ end
572
+ end
573
+
574
+ # Parse a length-prefixed MessagePack stream from an enumerable of byte
575
+ # chunks.
576
+ #
577
+ # Format: [4-byte big-endian length][MsgPack payload].
578
+ # A zero-length frame is a heartbeat and is silently skipped.
579
+ #
580
+ # Uses StringIO for efficient position-based reading rather than
581
+ # repeatedly slicing from the front of a String (which copies all
582
+ # remaining bytes on every extraction).
583
+ def self.parse_msgpack_stream(chunks) #: (Enumerable[String]) { (Hash[String, untyped]) -> void } -> void
584
+ io = StringIO.new("".b)
585
+
586
+ chunks.each do |chunk|
587
+ # Append new data at the end, then return to the read position.
588
+ read_pos = io.pos
589
+ io.seek(0, IO::SEEK_END)
590
+ io.write(chunk.b)
591
+ io.seek(read_pos)
592
+
593
+ # Extract complete frames.
594
+ while io.size - io.pos >= 4
595
+ len_bytes = io.read(4) #: String
596
+ len = len_bytes.unpack1("N") #: Integer
597
+
598
+ if len == 0 # heartbeat
599
+ next
600
+ end
601
+
602
+ if io.size - io.pos < len
603
+ # Incomplete frame — rewind past the length header and wait
604
+ # for more data.
605
+ io.seek(-4, IO::SEEK_CUR)
606
+ break
607
+ end
608
+
609
+ yield MessagePack.unpack(io.read(len))
610
+ end
611
+
612
+ # Compact: discard already-consumed bytes so the StringIO doesn't
613
+ # grow without bound over the life of the stream.
614
+ remaining = io.read
615
+ io = StringIO.new(remaining || "".b)
616
+ end
617
+ end
618
+
619
+ # GET a path on the server and return the decoded response body.
620
+ #
621
+ # The path should include any query parameters already (e.g. pagination
622
+ # links from the server's `pages` object). This is intentionally public
623
+ # so that resource objects like Page can follow links without resorting
624
+ # to `.send`.
625
+ def get_path(path) #: (String) -> Hash[String, untyped]
626
+ response = request { |http| consume_response(http.get(path, {"accept" => @content_type})) }
627
+ handle_response!(response, expected: 200)
628
+ end
629
+
630
+ private
631
+
632
+ # Build a relative path with optional query parameters.
633
+ def build_path(path, params: {}) #: (String, ?params: Hash[Symbol, untyped]) -> String
634
+ unless params.empty?
635
+ path = "#{path}?#{URI.encode_www_form(params)}"
636
+ end
637
+ path
638
+ end
639
+
640
+ # Validate and normalize filter parameters for bulk operations.
641
+ #
642
+ # Uses keyword arguments so that unknown keys raise ArgumentError.
643
+ #
644
+ # @rbs id: (String | Array[String])?
645
+ # @rbs status: (String | Array[String])?
646
+ # @rbs queue: (String | Array[String])?
647
+ # @rbs type: (String | Array[String])?
648
+ # @rbs filter: String?
649
+ # @rbs return: Hash[Symbol, untyped]
650
+ def validate_where(id: nil, status: nil, queue: nil, type: nil, filter: nil)
651
+ { id:, status:, queue:, type:, filter: }.compact
652
+ end
653
+
654
+ # Validate set parameters via keyword args (rejects unknown keys) and
655
+ # build the JSON body. Used by `update_all_jobs`.
656
+ #
657
+ # @rbs queue: (String | singleton(Zizq::UNCHANGED))?
658
+ # @rbs priority: (Integer | singleton(Zizq::UNCHANGED))?
659
+ # @rbs ready_at: (Zizq::to_f | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
660
+ # @rbs retry_limit: (Integer | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
661
+ # @rbs backoff: (Zizq::backoff | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
662
+ # @rbs retention: (Zizq::retention | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
663
+ # @rbs return: Hash[Symbol, untyped]
664
+ def validate_and_build_set(queue: UNCHANGED,
665
+ priority: UNCHANGED,
666
+ ready_at: UNCHANGED,
667
+ retry_limit: UNCHANGED,
668
+ backoff: UNCHANGED,
669
+ retention: UNCHANGED)
670
+ build_set_body(queue:, priority:, ready_at:, retry_limit:, backoff:, retention:)
671
+ end
672
+
673
+ # Build the JSON body hash for a PATCH request from set parameters.
674
+ #
675
+ # - `UNCHANGED` values are omitted (field not sent).
676
+ # - `RESET` values are sent as `nil` (JSON null).
677
+ # - `nil` is rejected — use `RESET` to clear a field.
678
+ # - Other values are converted to their wire format.
679
+ #
680
+ # @rbs return: Hash[Symbol, untyped]
681
+ def build_set_body(queue: UNCHANGED,
682
+ priority: UNCHANGED,
683
+ ready_at: UNCHANGED,
684
+ retry_limit: UNCHANGED,
685
+ backoff: UNCHANGED,
686
+ retention: UNCHANGED)
687
+ body = {} #: Hash[Symbol, untyped]
688
+
689
+ unless queue.equal?(UNCHANGED)
690
+ raise ArgumentError, "queue cannot be nil; use Zizq::RESET to clear or Zizq::UNCHANGED to leave as-is" if queue.nil?
691
+ body[:queue] = queue
692
+ end
693
+
694
+ unless priority.equal?(UNCHANGED)
695
+ raise ArgumentError, "priority cannot be nil; use Zizq::RESET to clear or Zizq::UNCHANGED to leave as-is" if priority.nil?
696
+ body[:priority] = priority
697
+ end
698
+
699
+ unless ready_at.equal?(UNCHANGED)
700
+ body[:ready_at] = ready_at.equal?(RESET) ? nil : (ready_at.to_f * 1000).to_i
701
+ end
702
+
703
+ unless retry_limit.equal?(UNCHANGED)
704
+ body[:retry_limit] = retry_limit.equal?(RESET) ? nil : retry_limit
705
+ end
706
+
707
+ unless backoff.equal?(UNCHANGED)
708
+ body[:backoff] = if backoff.equal?(RESET)
709
+ nil
710
+ else
711
+ {
712
+ exponent: backoff[:exponent].to_f,
713
+ base_ms: (backoff[:base].to_f * 1000).to_i,
714
+ jitter_ms: (backoff[:jitter].to_f * 1000).to_i
715
+ }
716
+ end
717
+ end
718
+
719
+ unless retention.equal?(UNCHANGED)
720
+ body[:retention] = if retention.equal?(RESET)
721
+ nil
722
+ else
723
+ ret = {} #: Hash[Symbol, Integer]
724
+ ret[:completed_ms] = (retention[:completed].to_f * 1000).to_i if retention[:completed]
725
+ ret[:dead_ms] = (retention[:dead].to_f * 1000).to_i if retention[:dead]
726
+ ret
727
+ end
728
+ end
729
+
730
+ body
731
+ end
732
+
733
+ # Build query params for list endpoints, joining multi-value keys with ",".
734
+ def build_where_params(options, multi_keys: []) #: (Hash[Symbol, untyped], ?multi_keys: Array[Symbol]) -> Hash[Symbol, untyped]
735
+ params = {} #: Hash[Symbol, untyped]
736
+ options.each do |key, value|
737
+ if multi_keys.include?(key) && value.is_a?(Array)
738
+ params[key] = value.join(",")
739
+ else
740
+ params[key] = value
741
+ end
742
+ end
743
+ params
744
+ end
745
+
746
+ def encode_body(body) #: (Hash[Symbol, untyped]) -> String
747
+ case @format
748
+ when :msgpack then MessagePack.pack(body)
749
+ when :json then JSON.generate(body)
750
+ else raise ArgumentError, "Unknown format: #{@format}"
751
+ end
752
+ end
753
+
754
+ def decode_body(data, content_type: nil) #: (String, ?content_type: String?) -> Hash[String, untyped]
755
+ format = case content_type
756
+ when /msgpack/ then :msgpack
757
+ when /json/ then :json
758
+ else @format
759
+ end
760
+ case format
761
+ when :msgpack then MessagePack.unpack(data)
762
+ when :json then JSON.parse(data)
763
+ else raise ArgumentError, "Unknown format: #{format}"
764
+ end
765
+ end
766
+
767
+ # Dispatch a block to the appropriate execution context.
768
+ #
769
+ # If already inside an Async reactor (e.g. AckProcessor, producer),
770
+ # yields the calling thread's HTTP client directly. Otherwise,
771
+ # dispatches via the persistent background IO thread.
772
+ def request(&block) #: () { (Async::HTTP::Client) -> RawResponse } -> RawResponse
773
+ if Async::Task.current?
774
+ yield http
775
+ else
776
+ sync_call(&block)
777
+ end
778
+ end
779
+
780
+ # Read the response body and close it, returning a RawResponse that is
781
+ # safe to use outside the reactor.
782
+ def consume_response(response) #: (untyped) -> RawResponse
783
+ RawResponse.new(status: response.status, body: response.read, content_type: response.headers["content-type"])
784
+ ensure
785
+ response.close
786
+ end
787
+
788
+ # Push a work block to the background IO thread and block until it
789
+ # completes, returning the result or re-raising any exception.
790
+ def sync_call(&block) #: () { (Async::HTTP::Client) -> RawResponse } -> RawResponse
791
+ ensure_io_thread
792
+
793
+ result_queue = Thread::Queue.new
794
+ @io_queue.push([block, result_queue])
795
+
796
+ tag, value = result_queue.pop
797
+ if tag == :ok
798
+ value
799
+ else
800
+ raise value
801
+ end
802
+ rescue ClosedQueueError
803
+ raise ConnectionError, "client is closed"
804
+ end
805
+
806
+ # Lazily start the background IO thread (double-checked locking).
807
+ def ensure_io_thread #: () -> void
808
+ return if @io_thread&.alive?
809
+
810
+ @mutex.synchronize do
811
+ return if @io_thread&.alive?
812
+
813
+ @io_queue = Thread::Queue.new
814
+ @io_thread = Thread.new { io_thread_run }
815
+ @io_thread.name = "zizq-io"
816
+ end
817
+ end
818
+
819
+ # Main loop for the background IO thread. Mirrors AckProcessor: runs an
820
+ # Async reactor, pops work from the queue (fiber-scheduler-aware), and
821
+ # dispatches each call as a concurrent fiber via a barrier.
822
+ def io_thread_run #: () -> void
823
+ ObjectSpace.define_finalizer(
824
+ self,
825
+ self.class.make_finalizer(@io_queue, @http_clients)
826
+ )
827
+
828
+ Sync do
829
+ barrier = Async::Barrier.new
830
+
831
+ while (item = @io_queue.pop)
832
+ block, result_queue = item
833
+ barrier.async do
834
+ result_queue.push([:ok, block.call(http)])
835
+ rescue Exception => e # rubocop:disable Lint/RescueException
836
+ # Must catch Exception (not just StandardError) to ensure the
837
+ # caller is always unblocked. Without this, errors like
838
+ # NoMemoryError or library-level Exceptions would kill the IO
839
+ # thread and leave callers blocking on result_queue.pop forever.
840
+ result_queue.push([:error, e])
841
+ end
842
+ end
843
+
844
+ barrier.wait
845
+ end
846
+ ensure
847
+ ObjectSpace.undefine_finalizer(self)
848
+ end
849
+
850
+ # Return the calling thread's HTTP client, creating one if needed.
851
+ # Uses thread_variable_get/set (not Thread.current[]) because the
852
+ # latter is fiber-local — each Async fiber would get its own client.
853
+ # The tracking array holds WeakRefs so clients from exited threads
854
+ # can be garbage-collected.
855
+ def http #: () -> Async::HTTP::Client
856
+ thread_local_http(@http_key, @endpoint)
857
+ end
858
+
859
+ # Return the calling thread's streaming HTTP client (HTTP/1.1),
860
+ # creating one if needed. See `#http` for the thread-local locking
861
+ # rationale. Kept separate from the main client so the long-lived
862
+ # `/jobs/take` connection doesn't share an HTTP/2 session with
863
+ # ack/enqueue traffic.
864
+ def stream_http #: () -> Async::HTTP::Client
865
+ thread_local_http(@stream_http_key, @stream_endpoint)
866
+ end
867
+
868
+ def thread_local_http(key, endpoint) #: (Symbol, Async::HTTP::Endpoint) -> Async::HTTP::Client
869
+ Thread.current.thread_variable_get(key) || begin
870
+ client = Async::HTTP::Client.new(endpoint)
871
+ @mutex.synchronize do
872
+ @http_clients.reject! { |ref| !ref.weakref_alive? }
873
+ @http_clients << WeakRef.new(client)
874
+ end
875
+ Thread.current.thread_variable_set(key, client)
876
+ client
877
+ end
878
+ end
879
+
880
+ def get(path, params: {}) #: (String, ?params: Hash[Symbol, untyped]) -> RawResponse
881
+ request do |http|
882
+ consume_response(
883
+ http.get(
884
+ build_path(path, params:),
885
+ {"accept" => @content_type}
886
+ )
887
+ )
888
+ end
889
+ end
890
+
891
+ def post(path, body) #: (String, Hash[Symbol, untyped]) -> RawResponse
892
+ request do |http|
893
+ consume_response(
894
+ http.post(
895
+ build_path(path),
896
+ {"content-type" => @content_type, "accept" => @content_type},
897
+ Protocol::HTTP::Body::Buffered.wrap(encode_body(body))
898
+ )
899
+ )
900
+ end
901
+ end
902
+
903
+ def raw_post(path) #: (String) -> RawResponse
904
+ request do |http|
905
+ consume_response(
906
+ http.post(
907
+ build_path(path),
908
+ {"accept" => @content_type}
909
+ )
910
+ )
911
+ end
912
+ end
913
+
914
+ def delete(path, params: {}) #: (String, ?params: Hash[Symbol, untyped]) -> RawResponse
915
+ request do |http|
916
+ consume_response(
917
+ http.delete(
918
+ build_path(path, params:),
919
+ {"accept" => @content_type}
920
+ )
921
+ )
922
+ end
923
+ end
924
+
925
+ def patch(path, body, params: {}) #: (String, Hash[Symbol, untyped], ?params: Hash[Symbol, untyped]) -> RawResponse
926
+ request do |http|
927
+ consume_response(
928
+ http.patch(
929
+ build_path(path, params:),
930
+ {"content-type" => @content_type, "accept" => @content_type},
931
+ Protocol::HTTP::Body::Buffered.wrap(encode_body(body))
932
+ )
933
+ )
934
+ end
935
+ end
936
+
937
+ # Check response status and decode body, raising on errors.
938
+ def handle_response!(response, expected:) #: (RawResponse, expected: Integer | Array[Integer]) -> Hash[String, untyped]?
939
+ status = response.status
940
+ expected_statuses = Array(expected)
941
+
942
+ ct = response.content_type
943
+
944
+ if expected_statuses.include?(status)
945
+ return nil if status == 204
946
+ decode_body(response.body, content_type: ct)
947
+ else
948
+ body = begin
949
+ decode_body(response.body, content_type: ct)
950
+ rescue
951
+ nil
952
+ end
953
+ message = body&.fetch("error", nil) || "HTTP #{status}"
954
+ error_class = case status
955
+ when 404 then NotFoundError
956
+ when 400..499 then ClientError
957
+ when 500..599 then ServerError
958
+ else ResponseError
959
+ end
960
+ raise error_class.new(message, status: status, body: body)
961
+ end
962
+ end
963
+
964
+ # @private
965
+ def self.make_finalizer(io_queue, http_clients)
966
+ -> do
967
+ io_queue&.close
968
+ http_clients.each do |ref|
969
+ ref.close
970
+ rescue WeakRef::RefError
971
+ # Client already GC'd (owning thread exited).
972
+ rescue NoMethodError
973
+ # The async connection pool may hold references to tasks whose
974
+ # fibers were already reclaimed when their owning Sync reactor
975
+ # exited. Stopping those dead tasks raises NoMethodError; safe
976
+ # to ignore.
977
+ end
978
+ http_clients.clear
979
+ end
980
+ end
981
+ end
982
+ end