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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/bin/profile-worker +145 -0
- data/bin/zizq-worker +174 -0
- data/lib/active_job/queue_adapters/zizq_adapter.rb +109 -0
- data/lib/zizq/ack_processor.rb +132 -0
- data/lib/zizq/active_job_config.rb +122 -0
- data/lib/zizq/backoff.rb +50 -0
- data/lib/zizq/bulk_enqueue.rb +87 -0
- data/lib/zizq/client.rb +982 -0
- data/lib/zizq/configuration.rb +164 -0
- data/lib/zizq/enqueue_request.rb +178 -0
- data/lib/zizq/enqueue_with.rb +109 -0
- data/lib/zizq/error.rb +43 -0
- data/lib/zizq/job.rb +188 -0
- data/lib/zizq/job_config.rb +244 -0
- data/lib/zizq/lifecycle.rb +58 -0
- data/lib/zizq/middleware.rb +79 -0
- data/lib/zizq/query.rb +566 -0
- data/lib/zizq/resources/error_enumerator.rb +241 -0
- data/lib/zizq/resources/error_page.rb +19 -0
- data/lib/zizq/resources/error_record.rb +19 -0
- data/lib/zizq/resources/job.rb +124 -0
- data/lib/zizq/resources/job_page.rb +57 -0
- data/lib/zizq/resources/page.rb +77 -0
- data/lib/zizq/resources/resource.rb +45 -0
- data/lib/zizq/resources.rb +16 -0
- data/lib/zizq/version.rb +9 -0
- data/lib/zizq/worker.rb +467 -0
- data/lib/zizq.rb +269 -0
- data/sig/generated/zizq/ack_processor.rbs +73 -0
- data/sig/generated/zizq/active_job_config.rbs +74 -0
- data/sig/generated/zizq/backoff.rbs +34 -0
- data/sig/generated/zizq/bulk_enqueue.rbs +72 -0
- data/sig/generated/zizq/client.rbs +419 -0
- data/sig/generated/zizq/configuration.rbs +95 -0
- data/sig/generated/zizq/enqueue_request.rbs +94 -0
- data/sig/generated/zizq/enqueue_with.rbs +88 -0
- data/sig/generated/zizq/error.rbs +41 -0
- data/sig/generated/zizq/job.rbs +136 -0
- data/sig/generated/zizq/job_config.rbs +150 -0
- data/sig/generated/zizq/lifecycle.rbs +34 -0
- data/sig/generated/zizq/middleware.rbs +50 -0
- data/sig/generated/zizq/query.rbs +327 -0
- data/sig/generated/zizq/resources/error_enumerator.rbs +148 -0
- data/sig/generated/zizq/resources/error_page.rbs +13 -0
- data/sig/generated/zizq/resources/error_record.rbs +20 -0
- data/sig/generated/zizq/resources/job.rbs +89 -0
- data/sig/generated/zizq/resources/job_page.rbs +33 -0
- data/sig/generated/zizq/resources/page.rbs +47 -0
- data/sig/generated/zizq/resources/resource.rbs +26 -0
- data/sig/generated/zizq/version.rbs +5 -0
- data/sig/generated/zizq/worker.rbs +152 -0
- data/sig/generated/zizq.rbs +180 -0
- data/sig/zizq.rbs +111 -0
- metadata +134 -0
data/lib/zizq/client.rb
ADDED
|
@@ -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
|