zizq 0.5.0 → 0.7.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -12
  3. data/lib/active_job/queue_adapters/zizq_adapter.rb +27 -24
  4. data/lib/zizq/ack_processor.rb +17 -11
  5. data/lib/zizq/active_job_config.rb +30 -10
  6. data/lib/zizq/backoff.rb +5 -1
  7. data/lib/zizq/bulk_enqueue.rb +67 -14
  8. data/lib/zizq/client.rb +845 -239
  9. data/lib/zizq/configuration.rb +15 -14
  10. data/lib/zizq/crontab.rb +70 -35
  11. data/lib/zizq/crontab_builder.rb +11 -8
  12. data/lib/zizq/crontab_entry.rb +36 -35
  13. data/lib/zizq/crontab_entry_builder.rb +80 -31
  14. data/lib/zizq/enqueue_request.rb +67 -53
  15. data/lib/zizq/enqueue_with.rb +66 -11
  16. data/lib/zizq/error.rb +22 -6
  17. data/lib/zizq/job.rb +31 -13
  18. data/lib/zizq/job_config.rb +303 -16
  19. data/lib/zizq/lifecycle.rb +10 -2
  20. data/lib/zizq/middleware.rb +7 -6
  21. data/lib/zizq/query.rb +175 -85
  22. data/lib/zizq/resources/budget.rb +109 -0
  23. data/lib/zizq/resources/cron_entry.rb +7 -7
  24. data/lib/zizq/resources/cron_group.rb +9 -5
  25. data/lib/zizq/resources/error_enumerator.rb +11 -9
  26. data/lib/zizq/resources/error_page.rb +2 -1
  27. data/lib/zizq/resources/error_record.rb +5 -5
  28. data/lib/zizq/resources/job.rb +149 -26
  29. data/lib/zizq/resources/job_page.rb +19 -8
  30. data/lib/zizq/resources/job_template.rb +31 -8
  31. data/lib/zizq/resources/page.rb +2 -1
  32. data/lib/zizq/resources/resource.rb +5 -4
  33. data/lib/zizq/resources.rb +10 -9
  34. data/lib/zizq/router.rb +3 -2
  35. data/lib/zizq/test/client.rb +82 -58
  36. data/lib/zizq/test.rb +8 -8
  37. data/lib/zizq/tls_configuration.rb +7 -6
  38. data/lib/zizq/version.rb +1 -1
  39. data/lib/zizq/worker.rb +68 -50
  40. data/lib/zizq/worker_configuration.rb +11 -10
  41. data/lib/zizq.rb +239 -55
  42. data/sig/generated/zizq/active_job_config.rbs +16 -5
  43. data/sig/generated/zizq/bulk_enqueue.rbs +4 -15
  44. data/sig/generated/zizq/client.rbs +378 -8
  45. data/sig/generated/zizq/crontab.rbs +17 -1
  46. data/sig/generated/zizq/crontab_builder.rbs +9 -0
  47. data/sig/generated/zizq/crontab_entry_builder.rbs +4 -16
  48. data/sig/generated/zizq/enqueue_request.rbs +16 -4
  49. data/sig/generated/zizq/enqueue_with.rbs +4 -9
  50. data/sig/generated/zizq/error.rbs +10 -0
  51. data/sig/generated/zizq/job.rbs +6 -0
  52. data/sig/generated/zizq/job_config.rbs +163 -0
  53. data/sig/generated/zizq/query.rbs +79 -2
  54. data/sig/generated/zizq/resources/budget.rbs +97 -0
  55. data/sig/generated/zizq/resources/cron_group.rbs +3 -0
  56. data/sig/generated/zizq/resources/job.rbs +96 -0
  57. data/sig/generated/zizq/resources/job_template.rbs +16 -2
  58. data/sig/generated/zizq.rbs +111 -32
  59. data/sig/zizq.rbs +111 -7
  60. metadata +4 -2
data/lib/zizq/client.rb CHANGED
@@ -30,10 +30,35 @@ module Zizq
30
30
  # the async reactor that produced it.
31
31
  RawResponse = Data.define(:status, :body, :content_type)
32
32
 
33
- CONTENT_TYPES = { #: Hash[Zizq::format, String]
33
+ CONTENT_TYPES = {
34
34
  msgpack: "application/msgpack",
35
35
  json: "application/json"
36
- }.freeze
36
+ }.freeze #: Hash[Zizq::format, String]
37
+
38
+ # Filter keys the server accepts as comma-delimited lists, matching
39
+ # any of the named values. An empty value matches nothing, which every
40
+ # filtered call short-circuits on rather than sending a request that
41
+ # would have matched everything. Callers should omit the key entirely
42
+ # if what they really mean is "match everything".
43
+ #
44
+ # These are the Ruby keyword argument names; see `API_FILTER_NAMES` for
45
+ # alternative names used in the API.
46
+ MULTI_FILTER_KEYS = %i[id status queue type budgets_key].freeze #: Array[Symbol]
47
+
48
+ # Filter arguments whose query parameter is spelled differently in
49
+ # the API.
50
+ #
51
+ # `budgets.key` is dotted in the API to mirror the path into the
52
+ # `budgets` array on a job, which is not a valid Ruby keyword, so
53
+ # `validate_where` takes it as `budgets_key:`.
54
+ #
55
+ # The rename happens here, in value position, rather than as a key
56
+ # in `validate_where`'s hash: syntax_tree rewrites a quoted symbol
57
+ # *key* into a string key when the same hash uses Ruby 3.1 omitted
58
+ # values, and a string key silently stops matching
59
+ # `MULTI_FILTER_KEYS` — so an empty filter would be sent as no
60
+ # filter, matching everything.
61
+ API_FILTER_NAMES = { budgets_key: :"budgets.key" }.freeze #: Hash[Symbol, Symbol]
37
62
 
38
63
  STREAM_ACCEPT = { #: Hash[Zizq::format, String]
39
64
  msgpack: "application/vnd.zizq.msgpack-stream",
@@ -62,24 +87,23 @@ module Zizq
62
87
  # @rbs read_timeout: Numeric
63
88
  # @rbs stream_idle_timeout: Numeric
64
89
  # @rbs return: void
65
- def initialize(url:,
66
- format: :msgpack,
67
- ssl_context: nil,
68
- read_timeout: 30,
69
- stream_idle_timeout: 30)
90
+ def initialize(
91
+ url:,
92
+ format: :msgpack,
93
+ ssl_context: nil,
94
+ read_timeout: 30,
95
+ stream_idle_timeout: 30
96
+ )
70
97
  @url = url.chomp("/")
71
98
  @format = format
72
99
 
73
100
  endpoint_options = {
74
101
  protocol: Async::HTTP::Protocol::HTTP2,
75
- timeout: read_timeout,
102
+ timeout: read_timeout
76
103
  } #: Hash[Symbol, untyped]
77
104
  endpoint_options[:ssl_context] = ssl_context if ssl_context
78
105
 
79
- @endpoint = Async::HTTP::Endpoint.parse(
80
- @url,
81
- **endpoint_options,
82
- )
106
+ @endpoint = Async::HTTP::Endpoint.parse(@url, **endpoint_options)
83
107
 
84
108
  # Streaming take uses a dedicated HTTP/1.1 endpoint. The take
85
109
  # connection is long-lived and carries only one request, so HTTP/2's
@@ -93,14 +117,13 @@ module Zizq
93
117
  # timeout so server heartbeats (~3s) keep it alive while only
94
118
  # genuinely dead connections (no data for the full window)
95
119
  # trigger a reconnect.
96
- stream_endpoint_options = endpoint_options.merge(
97
- protocol: Async::HTTP::Protocol::HTTP11,
98
- timeout: stream_idle_timeout,
99
- )
100
- @stream_endpoint = Async::HTTP::Endpoint.parse(
101
- @url,
102
- **stream_endpoint_options,
103
- )
120
+ stream_endpoint_options =
121
+ endpoint_options.merge(
122
+ protocol: Async::HTTP::Protocol::HTTP11,
123
+ timeout: stream_idle_timeout
124
+ )
125
+ @stream_endpoint =
126
+ Async::HTTP::Endpoint.parse(@url, **stream_endpoint_options)
104
127
 
105
128
  @mutex = Mutex.new
106
129
 
@@ -154,6 +177,9 @@ module Zizq
154
177
  # using the Zizq API's expected inputs. Callers should generally use
155
178
  # [`Zizq::enqueue`] instead.
156
179
  #
180
+ # Durations and timestamps are fractional seconds throughout to align with
181
+ # Ruby, as opposed to the server's integer milliseconds representation.
182
+ #
157
183
  # Returns a resource instance of the new job wrapping the API response.
158
184
  #
159
185
  # @rbs queue: String
@@ -166,26 +192,34 @@ module Zizq
166
192
  # @rbs retention: Zizq::retention?
167
193
  # @rbs unique_key: String?
168
194
  # @rbs unique_while: Zizq::unique_scope?
195
+ # @rbs batch: Zizq::batch?
196
+ # @rbs budgets: Array[Zizq::budget_binding_params]?
169
197
  # @rbs return: Resources::Job
170
- def enqueue(queue:,
171
- type:,
172
- payload:,
173
- priority: nil,
174
- ready_at: nil,
175
- retry_limit: nil,
176
- backoff: nil,
177
- retention: nil,
178
- unique_key: nil,
179
- unique_while: nil)
198
+ def enqueue(
199
+ queue:,
200
+ type:,
201
+ payload:,
202
+ priority: nil,
203
+ ready_at: nil,
204
+ retry_limit: nil,
205
+ backoff: nil,
206
+ retention: nil,
207
+ unique_key: nil,
208
+ unique_while: nil,
209
+ batch: nil,
210
+ budgets: nil
211
+ )
180
212
  body = { queue:, type:, payload: } #: Hash[Symbol, untyped]
181
213
  body[:priority] = priority if priority
182
214
  # ready_at is fractional seconds in Ruby; the server expects ms.
183
215
  body[:ready_at] = (ready_at.to_f * 1000).to_i if ready_at
184
216
  body[:retry_limit] = retry_limit if retry_limit
185
- body[:backoff] = backoff if backoff
186
- body[:retention] = retention if retention
217
+ body[:backoff] = wire_backoff(backoff) if backoff
218
+ body[:retention] = wire_retention(retention) if retention
187
219
  body[:unique_key] = unique_key if unique_key
188
220
  body[:unique_while] = unique_while.to_s if unique_while
221
+ body[:batch] = batch if batch
222
+ body[:budgets] = budgets.map { |b| wire_binding(b) } if budgets&.any?
189
223
 
190
224
  response = post("/jobs", body)
191
225
  data = handle_response!(response, expected: [200, 201])
@@ -204,18 +238,31 @@ module Zizq
204
238
  # @rbs return: Array[Resources::Job]
205
239
  def enqueue_bulk(jobs:)
206
240
  body = {
207
- jobs: jobs.map do |job|
208
- wire = { type: job[:type], queue: job[:queue], payload: job[:payload] } #: Hash[Symbol, untyped]
209
- wire[:priority] = job[:priority] if job[:priority]
210
- # ready_at is fractional seconds in Ruby; the server expects ms.
211
- wire[:ready_at] = (job[:ready_at].to_f * 1000).to_i if job[:ready_at]
212
- wire[:retry_limit] = job[:retry_limit] if job[:retry_limit]
213
- wire[:backoff] = job[:backoff] if job[:backoff]
214
- wire[:retention] = job[:retention] if job[:retention]
215
- wire[:unique_key] = job[:unique_key] if job[:unique_key]
216
- wire[:unique_while] = job[:unique_while].to_s if job[:unique_while]
217
- wire
218
- end
241
+ jobs:
242
+ jobs.map do |job|
243
+ wire = {
244
+ type: job[:type],
245
+ queue: job[:queue],
246
+ payload: job[:payload]
247
+ } #: Hash[Symbol, untyped]
248
+ wire[:priority] = job[:priority] if job[:priority]
249
+ # ready_at is fractional seconds in Ruby; the server expects ms.
250
+ wire[:ready_at] = (job[:ready_at].to_f * 1000).to_i if job[
251
+ :ready_at
252
+ ]
253
+ wire[:retry_limit] = job[:retry_limit] if job[:retry_limit]
254
+ wire[:backoff] = wire_backoff(job[:backoff]) if job[:backoff]
255
+ wire[:retention] = wire_retention(job[:retention]) if job[
256
+ :retention
257
+ ]
258
+ wire[:unique_key] = job[:unique_key] if job[:unique_key]
259
+ wire[:unique_while] = job[:unique_while].to_s if job[:unique_while]
260
+ wire[:batch] = job[:batch] if job[:batch]
261
+ if (budgets = job[:budgets])&.any?
262
+ wire[:budgets] = budgets.map { |b| wire_binding(b) }
263
+ end
264
+ wire
265
+ end
219
266
  }
220
267
 
221
268
  response = post("/jobs/bulk", body)
@@ -253,39 +300,39 @@ module Zizq
253
300
  # @rbs order: Zizq::sort_direction?
254
301
  # @rbs limit: Integer?
255
302
  # @rbs return: Resources::JobPage
256
- def list_jobs(id: nil,
257
- status: nil,
258
- queue: nil,
259
- type: nil,
260
- filter: nil,
261
- priority: nil,
262
- ready_at: nil,
263
- attempts: nil,
264
- from: nil,
265
- order: nil,
266
- limit: nil)
267
- options = {
303
+ def list_jobs(
304
+ id: nil,
305
+ status: nil,
306
+ queue: nil,
307
+ type: nil,
308
+ filter: nil,
309
+ priority: nil,
310
+ ready_at: nil,
311
+ attempts: nil,
312
+ budgets_key: nil,
313
+ from: nil,
314
+ order: nil,
315
+ limit: nil
316
+ )
317
+ where = {
268
318
  id:,
269
319
  status:,
270
320
  queue:,
271
321
  type:,
272
322
  filter:,
273
- priority: encode_range(priority),
274
- ready_at: encode_range(ready_at) { |v| (v.to_f * 1000).to_i },
275
- attempts: encode_range(attempts),
276
- from:,
277
- order:,
278
- limit:,
279
- }.compact #: Hash[Symbol, untyped]
280
-
281
- multi_keys = %i[id status queue type]
282
- params = build_where_params(options, multi_keys:)
323
+ priority:,
324
+ ready_at:,
325
+ attempts:,
326
+ budgets_key:
327
+ }.compact #: Zizq::where_params
328
+ params = filter_params(where)
283
329
 
284
- # An empty filter ([] or "") matches nothing — short-circuit.
285
- multi_keys.each do |key|
286
- return Resources::JobPage.new(self, { "jobs" => [], "pages" => {} }) if params[key] == ""
330
+ if params.nil?
331
+ return Resources::JobPage.new(self, { "jobs" => [], "pages" => {} })
287
332
  end
288
333
 
334
+ params = params.merge({ from:, order:, limit: }.compact)
335
+
289
336
  response = get("/jobs", params:)
290
337
  data = handle_response!(response, expected: 200)
291
338
  Resources::JobPage.new(self, data)
@@ -305,32 +352,30 @@ module Zizq
305
352
  # @rbs ready_at: (Zizq::to_f | Range[Zizq::to_f?])?
306
353
  # @rbs attempts: (Integer | Range[Integer?])?
307
354
  # @rbs return: Integer
308
- def count_jobs(id: nil,
309
- status: nil,
310
- queue: nil,
311
- type: nil,
312
- filter: nil,
313
- priority: nil,
314
- ready_at: nil,
315
- attempts: nil)
316
- options = {
355
+ def count_jobs(
356
+ id: nil,
357
+ status: nil,
358
+ queue: nil,
359
+ type: nil,
360
+ filter: nil,
361
+ priority: nil,
362
+ ready_at: nil,
363
+ attempts: nil,
364
+ budgets_key: nil
365
+ )
366
+ where = {
317
367
  id:,
318
368
  status:,
319
369
  queue:,
320
370
  type:,
321
371
  filter:,
322
- priority: encode_range(priority),
323
- ready_at: encode_range(ready_at) { |v| (v.to_f * 1000).to_i },
324
- attempts: encode_range(attempts),
325
- }.compact #: Hash[Symbol, untyped]
326
-
327
- multi_keys = %i[id status queue type]
328
- params = build_where_params(options, multi_keys:)
329
-
330
- # An empty filter ([] or "") matches nothing — short-circuit.
331
- multi_keys.each do |key|
332
- return 0 if params[key] == ""
333
- end
372
+ priority:,
373
+ ready_at:,
374
+ attempts:,
375
+ budgets_key:
376
+ }.compact #: Zizq::where_params
377
+ params = filter_params(where)
378
+ return 0 if params.nil?
334
379
 
335
380
  response = get("/jobs/count", params:)
336
381
  data = handle_response!(response, expected: 200)
@@ -357,15 +402,8 @@ module Zizq
357
402
  # @rbs where: Zizq::where_params
358
403
  # @rbs return: Integer
359
404
  def delete_all_jobs(where: {})
360
- filter_params = validate_where(**where)
361
-
362
- multi_keys = %i[id status queue type]
363
- params = build_where_params(filter_params, multi_keys:)
364
-
365
- # An empty multi-value filter matches nothing — short-circuit.
366
- multi_keys.each do |key|
367
- return 0 if params[key] == ""
368
- end
405
+ params = filter_params(where)
406
+ return 0 if params.nil?
369
407
 
370
408
  response = delete("/jobs", params:)
371
409
  data = handle_response!(response, expected: 200)
@@ -408,21 +446,24 @@ module Zizq
408
446
  # @rbs backoff: (Zizq::backoff | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
409
447
  # @rbs retention: (Zizq::retention | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
410
448
  # @rbs return: Resources::Job
411
- def update_job(id,
412
- queue: UNCHANGED,
413
- priority: UNCHANGED,
414
- ready_at: UNCHANGED,
415
- retry_limit: UNCHANGED,
416
- backoff: UNCHANGED,
417
- retention: UNCHANGED)
418
- body = build_set_body(
419
- queue:,
420
- priority:,
421
- ready_at:,
422
- retry_limit:,
423
- backoff:,
424
- retention:
425
- )
449
+ def update_job(
450
+ id,
451
+ queue: UNCHANGED,
452
+ priority: UNCHANGED,
453
+ ready_at: UNCHANGED,
454
+ retry_limit: UNCHANGED,
455
+ backoff: UNCHANGED,
456
+ retention: UNCHANGED
457
+ )
458
+ body =
459
+ build_set_body(
460
+ queue:,
461
+ priority:,
462
+ ready_at:,
463
+ retry_limit:,
464
+ backoff:,
465
+ retention:
466
+ )
426
467
  response = patch("/jobs/#{enc(id)}", body)
427
468
  data = handle_response!(response, expected: 200)
428
469
  Resources::Job.new(self, data)
@@ -442,15 +483,8 @@ module Zizq
442
483
  # @rbs apply: Zizq::apply_params
443
484
  # @rbs return: Integer
444
485
  def update_all_jobs(where: {}, apply: {})
445
- filter_params = validate_where(**where)
446
-
447
- multi_keys = %i[id status queue type]
448
- params = build_where_params(filter_params, multi_keys:)
449
-
450
- # An empty multi-value filter matches nothing — short-circuit.
451
- multi_keys.each do |key|
452
- return 0 if params[key] == ""
453
- end
486
+ params = filter_params(where)
487
+ return 0 if params.nil?
454
488
 
455
489
  body = validate_and_build_set(**apply)
456
490
  response = patch("/jobs", body, params:)
@@ -503,6 +537,336 @@ module Zizq
503
537
  data["queues"]
504
538
  end
505
539
 
540
+ # List every defined budget, with its policy.
541
+ #
542
+ # Requires a Pro license on the server, else a `Zizq::ClientError`
543
+ # is raised.
544
+ #
545
+ # @rbs return: Array[Zizq::Resources::Budget]
546
+ def list_budgets
547
+ response = get("/budgets")
548
+ data = handle_response!(response, expected: 200)
549
+ (data["budgets"] || []).map { |b| Resources::Budget.new(self, b) }
550
+ end
551
+
552
+ # Fetch one budget's policy.
553
+ #
554
+ # Raises `Zizq::NotFoundError` if no budget exists under this key.
555
+ #
556
+ # @rbs key: String
557
+ # @rbs return: Zizq::Resources::Budget
558
+ def get_budget(key)
559
+ response = get("/budgets/#{enc(key)}")
560
+ data = handle_response!(response, expected: 200)
561
+ Resources::Budget.new(self, data)
562
+ end
563
+
564
+ # Create a budget, raising if the key would be overwritten.
565
+ #
566
+ # Raises a `Zizq::ConflictError` (409) if a budget already
567
+ # exists under this key, leaving the stored policy untouched.
568
+ #
569
+ # See `#put_budget` for the version that would overwrite the existing
570
+ # key.
571
+ #
572
+ # `strategy` is one of:
573
+ #
574
+ # { type: :time_based, duration: 60 }
575
+ # { type: :time_based, duration: 60, burst: 5 }
576
+ # { type: :while_in_flight }
577
+ #
578
+ # `duration` is fractional seconds and is also read back as fractional
579
+ # seconds.
580
+ #
581
+ # A newly created bucket starts full, so a budget with no burst set
582
+ # hands out an entire allocation the moment work arrives and only
583
+ # then settles to its continuous drip rate — `10` per minute really
584
+ # does permit twenty in the first minute. Setting a burst caps that
585
+ # spike without changing the long-run rate; a burst of `1` paces
586
+ # dispatches evenly at all times. The burst rule also applies to
587
+ # budgets that have not been consumed for an entire duration's worth
588
+ # of time.
589
+ #
590
+ # It is valid for `burst` to be set higher than the `allocation`. In
591
+ # this case the meaning is to continue filling the bucket after the
592
+ # duration has elapsed, so a `burst` of 200 on a 100 token allocation
593
+ # would continue accruing across two consecutive periods, but no more
594
+ # than that.
595
+ #
596
+ # This is designed to allow applications to absorb short-lived
597
+ # spikes.
598
+ #
599
+ # @rbs key: String
600
+ # @rbs allocation: Integer
601
+ # @rbs strategy: Zizq::budget_strategy
602
+ # @rbs return: Zizq::Resources::Budget
603
+ def create_budget(key, allocation:, strategy:)
604
+ body = { allocation:, strategy: wire_strategy(strategy) }
605
+ response = post("/budgets/#{enc(key)}", body)
606
+ data = handle_response!(response, expected: 201)
607
+ Resources::Budget.new(self, data)
608
+ end
609
+
610
+ # Create a budget, or replace an existing budgets policy outright.
611
+ #
612
+ # Unlike `create_budget` this never conflicts. `created_at` is
613
+ # preserved when the budget already exists — a replace changes the
614
+ # policy, not the budget's identity.
615
+ #
616
+ # Raises a `Zizq::ClientError` (422) if the new allocation is below
617
+ # a cost already committed to the budget by a queued job or a cron
618
+ # entry, since those could then never be afforded.
619
+ #
620
+ # `strategy` takes the same shape as `create_budget`.
621
+ #
622
+ # @rbs key: String
623
+ # @rbs allocation: Integer
624
+ # @rbs strategy: Zizq::budget_strategy
625
+ # @rbs return: Zizq::Resources::Budget
626
+ def put_budget(key, allocation:, strategy:)
627
+ body = { allocation:, strategy: wire_strategy(strategy) }
628
+ response = put("/budgets/#{enc(key)}", body)
629
+ data = handle_response!(response, expected: 200)
630
+ Resources::Budget.new(self, data)
631
+ end
632
+
633
+ # Update specified fields of a budget's policy, leaving the rest
634
+ # unchanged.
635
+ #
636
+ # Follows JSON merge patch semantics, and recurses into `strategy`, so
637
+ # e.g. the burst can be changed without restating the type and period:
638
+ #
639
+ # update_budget("emails", strategy: {burst: 5})
640
+ #
641
+ # Within `strategy`, an absent field is left alone and `burst: nil`
642
+ # clears it back to the default value of the allocation. `burst` is
643
+ # the only field with a meaningful "unset": a strategy with no type, or
644
+ # a `time_based` strategy with no period, is not a valid strategy.
645
+ #
646
+ # Raises `Zizq::NotFoundError` if no budget exists under this key.
647
+ #
648
+ # @rbs key: String
649
+ # @rbs allocation: Integer?
650
+ # @rbs strategy: Zizq::budget_strategy_patch?
651
+ # @rbs return: Zizq::Resources::Budget
652
+ def update_budget(key, allocation: nil, strategy: nil)
653
+ body = {
654
+ allocation:,
655
+ strategy: strategy && wire_strategy(strategy)
656
+ }.compact
657
+ response = patch("/budgets/#{enc(key)}", body)
658
+ data = handle_response!(response, expected: 200)
659
+ Resources::Budget.new(self, data)
660
+ end
661
+
662
+ # Delete a budget.
663
+ #
664
+ # Raises `Zizq::NotFoundError` if no budget exists under this key.
665
+ #
666
+ # Raises a `Zizq::ConflictError` (409) while anything still
667
+ # references it.
668
+ #
669
+ # @rbs key: String
670
+ # @rbs return: void
671
+ def delete_budget(key)
672
+ response = delete("/budgets/#{enc(key)}")
673
+ handle_response!(response, expected: 204)
674
+ nil
675
+ end
676
+
677
+ # Replace the whole set of budgets a job is bound to.
678
+ #
679
+ # Budgets omitted from `budgets` will be unbound. Passing `[]` is
680
+ # the same as `clear_job_budgets`.
681
+ #
682
+ # Only queued (scheduled, ready) jobs may have their budgets changed —
683
+ # an in-flight job already debited tokens against its budgets, and
684
+ # jobs in a terminal state are immutable to all further changes.
685
+ # Either raises a `Zizq::ClientError` (422).
686
+ #
687
+ # Requires a Pro license on the server, else a `Zizq::ClientError`
688
+ # (403) is raised.
689
+ #
690
+ # @rbs id: String
691
+ # @rbs budgets: Array[Zizq::budget_binding_params]
692
+ # @rbs return: Resources::Job
693
+ def replace_job_budgets(id, budgets:)
694
+ body = { budgets: budgets.map { |b| wire_binding(b) } }
695
+ response = put("/jobs/#{enc(id)}/budgets", body)
696
+ data = handle_response!(response, expected: 200)
697
+ Resources::Job.new(self, data)
698
+ end
699
+
700
+ # Unbind every budget from a job, leaving it unthrottled.
701
+ #
702
+ # @rbs id: String
703
+ # @rbs return: Resources::Job
704
+ def clear_job_budgets(id)
705
+ response = delete("/jobs/#{enc(id)}/budgets")
706
+ data = handle_response!(response, expected: 200)
707
+ Resources::Job.new(self, data)
708
+ end
709
+
710
+ # Bind a job to a budget it is not already bound to.
711
+ #
712
+ # Raises a `Zizq::ConflictError` (409) if the job is already bound
713
+ # to this budget, leaving the existing binding untouched — use
714
+ # `put_job_budget` to replace it, or `update_job_budget` to change
715
+ # only its cost.
716
+ #
717
+ # `create_with` declares the budget's policy should it not exist
718
+ # yet, binding and creating in one atomic call. It is ignored when
719
+ # the budget already exists.
720
+ #
721
+ # @rbs id: String
722
+ # @rbs key: String
723
+ # @rbs cost: Integer?
724
+ # @rbs create_with: Zizq::budget_policy?
725
+ # @rbs return: Resources::Job
726
+ def add_job_budget(id, key, cost: nil, create_with: nil)
727
+ body = binding_body(cost:, create_with:)
728
+ response = post("/jobs/#{enc(id)}/budgets/#{enc(key)}", body)
729
+ data = handle_response!(response, expected: 200)
730
+ Resources::Job.new(self, data)
731
+ end
732
+
733
+ # Bind a job to a budget, replacing any existing binding to it.
734
+ #
735
+ # Unlike `add_job_budget` this never conflicts. The binding is
736
+ # replaced whole, so a `cost` left unset returns to the default of 1
737
+ # rather than keeping what was there.
738
+ #
739
+ # @rbs id: String
740
+ # @rbs key: String
741
+ # @rbs cost: Integer?
742
+ # @rbs create_with: Zizq::budget_policy?
743
+ # @rbs return: Resources::Job
744
+ def put_job_budget(id, key, cost: nil, create_with: nil)
745
+ body = binding_body(cost:, create_with:)
746
+ response = put("/jobs/#{enc(id)}/budgets/#{enc(key)}", body)
747
+ data = handle_response!(response, expected: 200)
748
+ Resources::Job.new(self, data)
749
+ end
750
+
751
+ # Change what an existing binding costs, leaving the rest alone.
752
+ #
753
+ # Raises `Zizq::NotFoundError` if the job is not bound to this
754
+ # budget. Unlike `put_job_budget`, a change of cost has nothing to
755
+ # apply to and does not create the binding if missing.
756
+ #
757
+ # Raises a `Zizq::ClientError` (422) if the new cost exceeds the
758
+ # budget's capacity.
759
+ #
760
+ # @rbs id: String
761
+ # @rbs key: String
762
+ # @rbs cost: Integer
763
+ # @rbs return: Resources::Job
764
+ def update_job_budget(id, key, cost:)
765
+ response = patch("/jobs/#{enc(id)}/budgets/#{enc(key)}", { cost: })
766
+ data = handle_response!(response, expected: 200)
767
+ Resources::Job.new(self, data)
768
+ end
769
+
770
+ # Unbind one budget from a job, leaving its other budgets alone.
771
+ #
772
+ # Raises `Zizq::NotFoundError` if the job is not bound to it.
773
+ #
774
+ # @rbs id: String
775
+ # @rbs key: String
776
+ # @rbs return: Resources::Job
777
+ def delete_job_budget(id, key)
778
+ response = delete("/jobs/#{enc(id)}/budgets/#{enc(key)}")
779
+ data = handle_response!(response, expected: 200)
780
+ Resources::Job.new(self, data)
781
+ end
782
+
783
+ # Bind every job matching the filter to a budget, skipping over the
784
+ # ones that are already bound to it.
785
+ #
786
+ # The bulk form of `add_job_budget`; each of these takes the same
787
+ # name with `job` widened to `all_jobs`.
788
+ #
789
+ # Filters in the `where:` argument use the same keys as `list_jobs`.
790
+ # An unfiltered call binds every queued job on the server.
791
+ #
792
+ # Unlike the single-job form this does not conflict but simply
793
+ # skips.
794
+ #
795
+ # @rbs key: String
796
+ # @rbs where: Zizq::where_params
797
+ # @rbs cost: Integer?
798
+ # @rbs create_with: Zizq::budget_policy?
799
+ # @rbs return: Zizq::bulk_budget_change
800
+ def add_all_jobs_budget(key, where: {}, cost: nil, create_with: nil)
801
+ bulk_budget_request(
802
+ :post,
803
+ "/jobs/budgets/#{enc(key)}",
804
+ where,
805
+ binding_body(cost:, create_with:)
806
+ )
807
+ end
808
+
809
+ # Bind every job matching the filter to a budget, replacing any
810
+ # existing binding to it.
811
+ #
812
+ # See `add_all_jobs_budget` for the filter semantics, and
813
+ # `put_job_budget` for how a binding is replaced.
814
+ #
815
+ # @rbs key: String
816
+ # @rbs where: Zizq::where_params
817
+ # @rbs cost: Integer?
818
+ # @rbs create_with: Zizq::budget_policy?
819
+ # @rbs return: Zizq::bulk_budget_change
820
+ def put_all_jobs_budget(key, where: {}, cost: nil, create_with: nil)
821
+ bulk_budget_request(
822
+ :put,
823
+ "/jobs/budgets/#{enc(key)}",
824
+ where,
825
+ binding_body(cost:, create_with:)
826
+ )
827
+ end
828
+
829
+ # Change what an existing binding costs, across every job matching
830
+ # the filter. Jobs not bound to this budget are skipped.
831
+ #
832
+ # The bulk form of `update_job_budget`.
833
+ #
834
+ # Raises a `Zizq::ClientError` (422) if the new cost exceeds the
835
+ # budget's capacity.
836
+ #
837
+ # @rbs key: String
838
+ # @rbs cost: Integer
839
+ # @rbs where: Zizq::where_params
840
+ # @rbs return: Zizq::bulk_budget_change
841
+ def update_all_jobs_budget(key, cost:, where: {})
842
+ bulk_budget_request(:patch, "/jobs/budgets/#{enc(key)}", where, { cost: })
843
+ end
844
+
845
+ # Unbind one budget from every job matching the filter, leaving
846
+ # their other budgets alone. The bulk form of `delete_job_budget`.
847
+ #
848
+ # This is the way to drain a budget before deleting it, since
849
+ # `delete_budget` refuses while anything is still bound to it.
850
+ #
851
+ # @rbs key: String
852
+ # @rbs where: Zizq::where_params
853
+ # @rbs return: Zizq::bulk_budget_change
854
+ def delete_all_jobs_budget(key, where: {})
855
+ bulk_budget_request(:delete, "/jobs/budgets/#{enc(key)}", where, nil)
856
+ end
857
+
858
+ # Unbind *every* budget from every job matching the filter. The
859
+ # bulk form of `clear_job_budgets`.
860
+ #
861
+ # Beware: An unfiltered call leaves every queued job on the server
862
+ # unthrottled — include a filter unless that is the intent.
863
+ #
864
+ # @rbs where: Zizq::where_params
865
+ # @rbs return: Zizq::bulk_budget_change
866
+ def clear_all_jobs_budgets(where: {})
867
+ bulk_budget_request(:delete, "/jobs/budgets", where, nil)
868
+ end
869
+
506
870
  # List all cron group names.
507
871
  #
508
872
  # @rbs return: Array[String]
@@ -527,13 +891,19 @@ module Zizq
527
891
  # Entries not present in the request are removed. Entries with unchanged
528
892
  # expressions preserve their scheduling state.
529
893
  #
894
+ # The timezone applies to every entry that does not specify one of its
895
+ # own. Since this replaces the group in full, omitting it clears whatever
896
+ # the group had.
897
+ #
530
898
  # @rbs name: String
531
899
  # @rbs paused: bool?
900
+ # @rbs timezone: String?
532
901
  # @rbs entries: Array[Zizq::cron_entry_params]
533
902
  # @rbs return: Resources::CronGroup
534
- def replace_cron_group(name, paused: nil, entries: [])
903
+ def replace_cron_group(name, paused: nil, timezone: nil, entries: [])
535
904
  body = {
536
905
  paused:,
906
+ timezone:,
537
907
  entries: entries.map { |entry| build_cron_entry(**entry) }
538
908
  }.compact
539
909
  response = put("/crons/#{enc(name)}", body)
@@ -592,7 +962,7 @@ module Zizq
592
962
 
593
963
  # Add a single entry to a cron group (creates the group if needed).
594
964
  #
595
- # Raises a ClientError (409 Conflict) if an entry with the same name
965
+ # Raises a `Zizq::ConflictError` (409) if an entry with the same name
596
966
  # already exists.
597
967
  #
598
968
  # @rbs group: String
@@ -602,7 +972,14 @@ module Zizq
602
972
  # @rbs timezone: String?
603
973
  # @rbs paused: bool?
604
974
  # @rbs return: Resources::CronEntry
605
- def add_cron_group_entry(group, name:, expression:, job:, timezone: nil, paused: nil)
975
+ def add_cron_group_entry(
976
+ group,
977
+ name:,
978
+ expression:,
979
+ job:,
980
+ timezone: nil,
981
+ paused: nil
982
+ )
606
983
  body = build_cron_entry(name:, expression:, job:, timezone:, paused:)
607
984
  response = post("/crons/#{enc(group)}/entries", body)
608
985
  data = handle_response!(response, expected: 201)
@@ -620,8 +997,16 @@ module Zizq
620
997
  # @rbs timezone: String?
621
998
  # @rbs paused: bool?
622
999
  # @rbs return: Resources::CronEntry
623
- def replace_cron_group_entry(group, entry, expression:, job:, timezone: nil, paused: nil)
624
- body = build_cron_entry(name: entry, expression:, job:, timezone:, paused:)
1000
+ def replace_cron_group_entry(
1001
+ group,
1002
+ entry,
1003
+ expression:,
1004
+ job:,
1005
+ timezone: nil,
1006
+ paused: nil
1007
+ )
1008
+ body =
1009
+ build_cron_entry(name: entry, expression:, job:, timezone:, paused:)
625
1010
  response = put("/crons/#{enc(group)}/entries/#{enc(entry)}", body)
626
1011
  data = handle_response!(response, expected: 200)
627
1012
  Resources::CronEntry.new(self, data)
@@ -634,7 +1019,8 @@ module Zizq
634
1019
  # @rbs paused: bool
635
1020
  # @rbs return: Resources::CronEntry
636
1021
  def update_cron_group_entry(group, entry, paused:)
637
- response = patch("/crons/#{enc(group)}/entries/#{enc(entry)}", { paused: })
1022
+ response =
1023
+ patch("/crons/#{enc(group)}/entries/#{enc(entry)}", { paused: })
638
1024
  data = handle_response!(response, expected: 200)
639
1025
  Resources::CronEntry.new(self, data)
640
1026
  end
@@ -723,7 +1109,14 @@ module Zizq
723
1109
  # @rbs retry_at: Float?
724
1110
  # @rbs kill: bool
725
1111
  # @rbs return: Resources::Job
726
- def report_failure(id, message:, error_type: nil, backtrace: nil, retry_at: nil, kill: false)
1112
+ def report_failure(
1113
+ id,
1114
+ message:,
1115
+ error_type: nil,
1116
+ backtrace: nil,
1117
+ retry_at: nil,
1118
+ kill: false
1119
+ )
727
1120
  body = { message: } #: Hash[Symbol, untyped]
728
1121
  body[:error_type] = error_type if error_type
729
1122
  body[:backtrace] = backtrace if backtrace
@@ -772,7 +1165,14 @@ module Zizq
772
1165
  # @rbs worker_id: String?
773
1166
  # @rbs &block: (Resources::Job) -> void
774
1167
  # @rbs return: void
775
- def take_jobs(prefetch: 1, queues: [], worker_id: nil, on_connect: nil, on_response: nil, &block)
1168
+ def take_jobs(
1169
+ prefetch: 1,
1170
+ queues: [],
1171
+ worker_id: nil,
1172
+ on_connect: nil,
1173
+ on_response: nil,
1174
+ &block
1175
+ )
776
1176
  raise ArgumentError, "take_jobs requires a block" unless block
777
1177
 
778
1178
  params = { prefetch: } #: Hash[Symbol, untyped]
@@ -786,7 +1186,10 @@ module Zizq
786
1186
  response = stream_http.get(path, headers)
787
1187
 
788
1188
  begin
789
- raise StreamError, "take jobs stream returned HTTP #{response.status}" unless response.status == 200
1189
+ unless response.status == 200
1190
+ raise StreamError,
1191
+ "take jobs stream returned HTTP #{response.status}"
1192
+ end
790
1193
  on_connect&.call
791
1194
  on_response&.call(response)
792
1195
 
@@ -800,11 +1203,17 @@ module Zizq
800
1203
  body = response.body || []
801
1204
 
802
1205
  case @format
803
- when :json then self.class.parse_ndjson(body, &wrapper)
804
- when :msgpack then self.class.parse_msgpack_stream(body, &wrapper)
1206
+ when :json
1207
+ self.class.parse_ndjson(body, &wrapper)
1208
+ when :msgpack
1209
+ self.class.parse_msgpack_stream(body, &wrapper)
805
1210
  end
806
1211
  ensure
807
- response.close rescue nil
1212
+ begin
1213
+ response.close
1214
+ rescue StandardError
1215
+ nil
1216
+ end
808
1217
  end
809
1218
  end
810
1219
  rescue SocketError,
@@ -887,7 +1296,10 @@ module Zizq
887
1296
  # so that resource objects like Page can follow links without resorting
888
1297
  # to `.send`.
889
1298
  def get_path(path) #: (String) -> Hash[String, untyped]
890
- response = request { |http| consume_response(http.get(path, {"accept" => @content_type})) }
1299
+ response =
1300
+ request do |http|
1301
+ consume_response(http.get(path, { "accept" => @content_type }))
1302
+ end
891
1303
  handle_response!(response, expected: 200)
892
1304
  end
893
1305
 
@@ -900,9 +1312,7 @@ module Zizq
900
1312
 
901
1313
  # Build a relative path with optional query parameters.
902
1314
  def build_path(path, params: {}) #: (String, ?params: Hash[Symbol, untyped]) -> String
903
- unless params.empty?
904
- path = "#{path}?#{URI.encode_www_form(params)}"
905
- end
1315
+ path = "#{path}?#{URI.encode_www_form(params)}" unless params.empty?
906
1316
  path
907
1317
  end
908
1318
 
@@ -914,13 +1324,19 @@ module Zizq
914
1324
  # @rbs timezone: String?
915
1325
  # @rbs paused: bool?
916
1326
  # @rbs return: Hash[Symbol, untyped]
917
- def build_cron_entry(name: nil, expression: nil, job: nil, timezone: nil, paused: nil)
1327
+ def build_cron_entry(
1328
+ name: nil,
1329
+ expression: nil,
1330
+ job: nil,
1331
+ timezone: nil,
1332
+ paused: nil
1333
+ )
918
1334
  {
919
1335
  name:,
920
1336
  expression:,
921
1337
  timezone:,
922
1338
  paused:,
923
- job: build_cron_job(**job),
1339
+ job: build_cron_job(**job)
924
1340
  }.compact
925
1341
  end
926
1342
 
@@ -937,23 +1353,31 @@ module Zizq
937
1353
  # @rbs retention: Zizq::retention?
938
1354
  # @rbs unique_key: String?
939
1355
  # @rbs unique_while: Zizq::unique_scope?
1356
+ # @rbs batch: Zizq::batch?
1357
+ # @rbs budgets: Array[Zizq::budget_binding_params]?
940
1358
  # @rbs return: Hash[Symbol, untyped]
941
- def build_cron_job(type: nil,
942
- queue: nil,
943
- payload: nil,
944
- priority: nil,
945
- retry_limit: nil,
946
- backoff: nil,
947
- retention: nil,
948
- unique_key: nil,
949
- unique_while: nil)
1359
+ def build_cron_job(
1360
+ type: nil,
1361
+ queue: nil,
1362
+ payload: nil,
1363
+ priority: nil,
1364
+ retry_limit: nil,
1365
+ backoff: nil,
1366
+ retention: nil,
1367
+ unique_key: nil,
1368
+ unique_while: nil,
1369
+ batch: nil,
1370
+ budgets: nil
1371
+ )
950
1372
  job = { type:, queue:, payload: } #: Hash[Symbol, untyped]
951
1373
  job[:priority] = priority if priority
952
1374
  job[:retry_limit] = retry_limit if retry_limit
953
- job[:backoff] = backoff if backoff
954
- job[:retention] = retention if retention
1375
+ job[:backoff] = wire_backoff(backoff) if backoff
1376
+ job[:retention] = wire_retention(retention) if retention
955
1377
  job[:unique_key] = unique_key if unique_key
956
1378
  job[:unique_while] = unique_while.to_s if unique_while
1379
+ job[:batch] = batch if batch
1380
+ job[:budgets] = budgets.map { |b| wire_binding(b) } if budgets&.any?
957
1381
  job
958
1382
  end
959
1383
 
@@ -969,15 +1393,19 @@ module Zizq
969
1393
  # @rbs priority: (Integer | Range[Integer?])?
970
1394
  # @rbs ready_at: (Zizq::to_f | Range[Zizq::to_f?])?
971
1395
  # @rbs attempts: (Integer | Range[Integer?])?
1396
+ # @rbs budgets_key: (String | Array[String])?
972
1397
  # @rbs return: Hash[Symbol, untyped]
973
- def validate_where(id: nil,
974
- status: nil,
975
- queue: nil,
976
- type: nil,
977
- filter: nil,
978
- priority: nil,
979
- ready_at: nil,
980
- attempts: nil)
1398
+ def validate_where(
1399
+ id: nil,
1400
+ status: nil,
1401
+ queue: nil,
1402
+ type: nil,
1403
+ filter: nil,
1404
+ priority: nil,
1405
+ ready_at: nil,
1406
+ attempts: nil,
1407
+ budgets_key: nil
1408
+ )
981
1409
  {
982
1410
  id:,
983
1411
  status:,
@@ -987,6 +1415,7 @@ module Zizq
987
1415
  priority: encode_range(priority),
988
1416
  ready_at: encode_range(ready_at) { |v| (v.to_f * 1000).to_i },
989
1417
  attempts: encode_range(attempts),
1418
+ budgets_key:
990
1419
  }.compact
991
1420
  end
992
1421
 
@@ -1016,7 +1445,7 @@ module Zizq
1016
1445
  when Range
1017
1446
  if value.exclude_end?
1018
1447
  raise ArgumentError,
1019
- "exclusive ranges are not supported by the server; use an inclusive range (a..b) instead"
1448
+ "exclusive ranges are not supported by the server; use an inclusive range (a..b) instead"
1020
1449
  end
1021
1450
  "#{value.begin&.then(&block)}..#{value.end&.then(&block)}"
1022
1451
  else
@@ -1034,13 +1463,22 @@ module Zizq
1034
1463
  # @rbs backoff: (Zizq::backoff | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
1035
1464
  # @rbs retention: (Zizq::retention | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
1036
1465
  # @rbs return: Hash[Symbol, untyped]
1037
- def validate_and_build_set(queue: UNCHANGED,
1038
- priority: UNCHANGED,
1039
- ready_at: UNCHANGED,
1040
- retry_limit: UNCHANGED,
1041
- backoff: UNCHANGED,
1042
- retention: UNCHANGED)
1043
- build_set_body(queue:, priority:, ready_at:, retry_limit:, backoff:, retention:)
1466
+ def validate_and_build_set(
1467
+ queue: UNCHANGED,
1468
+ priority: UNCHANGED,
1469
+ ready_at: UNCHANGED,
1470
+ retry_limit: UNCHANGED,
1471
+ backoff: UNCHANGED,
1472
+ retention: UNCHANGED
1473
+ )
1474
+ build_set_body(
1475
+ queue:,
1476
+ priority:,
1477
+ ready_at:,
1478
+ retry_limit:,
1479
+ backoff:,
1480
+ retention:
1481
+ )
1044
1482
  end
1045
1483
 
1046
1484
  # Build the JSON body hash for a PATCH request from set parameters.
@@ -1051,26 +1489,40 @@ module Zizq
1051
1489
  # - Other values are converted to their wire format.
1052
1490
  #
1053
1491
  # @rbs return: Hash[Symbol, untyped]
1054
- def build_set_body(queue: UNCHANGED,
1055
- priority: UNCHANGED,
1056
- ready_at: UNCHANGED,
1057
- retry_limit: UNCHANGED,
1058
- backoff: UNCHANGED,
1059
- retention: UNCHANGED)
1492
+ def build_set_body(
1493
+ queue: UNCHANGED,
1494
+ priority: UNCHANGED,
1495
+ ready_at: UNCHANGED,
1496
+ retry_limit: UNCHANGED,
1497
+ backoff: UNCHANGED,
1498
+ retention: UNCHANGED
1499
+ )
1060
1500
  body = {} #: Hash[Symbol, untyped]
1061
1501
 
1062
1502
  unless queue.equal?(UNCHANGED)
1063
- raise ArgumentError, "queue cannot be nil; use Zizq::RESET to clear or Zizq::UNCHANGED to leave as-is" if queue.nil?
1503
+ if queue.nil?
1504
+ raise ArgumentError,
1505
+ "queue cannot be nil; use Zizq::RESET to clear or Zizq::UNCHANGED to leave as-is"
1506
+ end
1064
1507
  body[:queue] = queue
1065
1508
  end
1066
1509
 
1067
1510
  unless priority.equal?(UNCHANGED)
1068
- raise ArgumentError, "priority cannot be nil; use Zizq::RESET to clear or Zizq::UNCHANGED to leave as-is" if priority.nil?
1511
+ if priority.nil?
1512
+ raise ArgumentError,
1513
+ "priority cannot be nil; use Zizq::RESET to clear or Zizq::UNCHANGED to leave as-is"
1514
+ end
1069
1515
  body[:priority] = priority
1070
1516
  end
1071
1517
 
1072
1518
  unless ready_at.equal?(UNCHANGED)
1073
- body[:ready_at] = ready_at.equal?(RESET) ? nil : (ready_at.to_f * 1000).to_i
1519
+ body[:ready_at] = (
1520
+ if ready_at.equal?(RESET)
1521
+ nil
1522
+ else
1523
+ (ready_at.to_f * 1000).to_i
1524
+ end
1525
+ )
1074
1526
  end
1075
1527
 
1076
1528
  unless retry_limit.equal?(UNCHANGED)
@@ -1078,39 +1530,179 @@ module Zizq
1078
1530
  end
1079
1531
 
1080
1532
  unless backoff.equal?(UNCHANGED)
1081
- body[:backoff] = if backoff.equal?(RESET)
1082
- nil
1083
- else
1084
- {
1085
- exponent: backoff[:exponent].to_f,
1086
- base_ms: (backoff[:base].to_f * 1000).to_i,
1087
- jitter_ms: (backoff[:jitter].to_f * 1000).to_i
1088
- }
1089
- end
1533
+ body[:backoff] = backoff.equal?(RESET) ? nil : wire_backoff(backoff)
1090
1534
  end
1091
1535
 
1092
1536
  unless retention.equal?(UNCHANGED)
1093
1537
  body[:retention] = if retention.equal?(RESET)
1094
1538
  nil
1095
1539
  else
1096
- ret = {} #: Hash[Symbol, Integer]
1097
- ret[:completed_ms] = (retention[:completed].to_f * 1000).to_i if retention[:completed]
1098
- ret[:dead_ms] = (retention[:dead].to_f * 1000).to_i if retention[:dead]
1099
- ret
1540
+ wire_retention(retention)
1100
1541
  end
1101
1542
  end
1102
1543
 
1103
1544
  body
1104
1545
  end
1105
1546
 
1547
+ # Convert a `Zizq::backoff` (fractional seconds) into the server's form
1548
+ # (integer milliseconds under keys suffixed `_ms`).
1549
+ #
1550
+ # @rbs backoff: Zizq::backoff
1551
+ # @rbs return: Hash[Symbol, untyped]
1552
+ def wire_backoff(backoff)
1553
+ {
1554
+ exponent: backoff[:exponent].to_f,
1555
+ base_ms: (backoff[:base].to_f * 1000).to_i,
1556
+ jitter_ms: (backoff[:jitter].to_f * 1000).to_i
1557
+ }
1558
+ end
1559
+
1560
+ # Convert a `Zizq::retention` (fractional seconds) into the server's form
1561
+ # (integer milliseconds under keys suffixed `_ms`). Both fields are
1562
+ # optional.
1563
+ #
1564
+ # @rbs retention: Zizq::retention
1565
+ # @rbs return: Hash[Symbol, Integer]
1566
+ def wire_retention(retention)
1567
+ wire = {} #: Hash[Symbol, Integer]
1568
+ wire[:completed_ms] = (
1569
+ retention[:completed].to_f * 1000
1570
+ ).to_i if retention[:completed]
1571
+ wire[:dead_ms] = (retention[:dead].to_f * 1000).to_i if retention[:dead]
1572
+ wire
1573
+ end
1574
+
1575
+ # Build the body for a single binding, where the budget key travels
1576
+ # in the path rather than the body.
1577
+ #
1578
+ # @rbs cost: Integer?
1579
+ # @rbs create_with: Zizq::budget_policy?
1580
+ # @rbs return: Hash[Symbol, untyped]
1581
+ def binding_body(cost:, create_with:)
1582
+ body = {} #: Hash[Symbol, untyped]
1583
+ body[:cost] = cost if cost
1584
+ body[:create_with] = wire_policy(create_with) if create_with
1585
+ body
1586
+ end
1587
+
1588
+ # Convert a `Zizq::budget_binding_params` into the wire form, key included.
1589
+ #
1590
+ # @rbs binding: Zizq::budget_binding_params
1591
+ # @rbs return: Hash[Symbol, untyped]
1592
+ def wire_binding(binding)
1593
+ { key: binding[:key] }.merge(
1594
+ binding_body(cost: binding[:cost], create_with: binding[:create_with])
1595
+ )
1596
+ end
1597
+
1598
+ # Convert a `Zizq::budget_policy` into the wire form.
1599
+ #
1600
+ # @rbs policy: Zizq::budget_policy
1601
+ # @rbs return: Hash[Symbol, untyped]
1602
+ def wire_policy(policy)
1603
+ {
1604
+ allocation: policy[:allocation],
1605
+ strategy: wire_strategy(policy[:strategy])
1606
+ }
1607
+ end
1608
+
1609
+ # Send one of the bulk job-budget requests and read its result.
1610
+ #
1611
+ # `body` is `nil` for the two that carry none, which are sent as
1612
+ # `DELETE` with the filter in the query string.
1613
+ #
1614
+ # @rbs method: Symbol
1615
+ # @rbs path: String
1616
+ # @rbs where: Zizq::where_params
1617
+ # @rbs body: Hash[Symbol, untyped]?
1618
+ # @rbs return: Zizq::bulk_budget_change
1619
+ def bulk_budget_request(method, path, where, body)
1620
+ params = filter_params(where)
1621
+
1622
+ # An empty multi-value filter matches nothing — short-circuit
1623
+ # rather than send a request that would match everything.
1624
+ return { changed: 0, blocked: [] } if params.nil?
1625
+
1626
+ response =
1627
+ case method
1628
+ when :post
1629
+ post(build_path(path, params:), body || {})
1630
+ when :put
1631
+ put(build_path(path, params:), body || {})
1632
+ when :patch
1633
+ patch(path, body || {}, params:)
1634
+ else
1635
+ delete(path, params:)
1636
+ end
1637
+ data = handle_response!(response, expected: 200)
1638
+ { changed: data.fetch("changed"), blocked: data.fetch("blocked") }
1639
+ end
1640
+
1641
+ # Validate a `where:` filter and build its query params.
1642
+ #
1643
+ # Returns `nil` when a multi-value filter is present but empty,
1644
+ # which matches nothing — callers short-circuit rather than send a
1645
+ # request that would match everything instead.
1646
+ #
1647
+ # @rbs where: Zizq::where_params
1648
+ # @rbs return: Hash[Symbol, untyped]?
1649
+ def filter_params(where)
1650
+ params =
1651
+ build_where_params(
1652
+ validate_where(**where),
1653
+ multi_keys: MULTI_FILTER_KEYS
1654
+ )
1655
+
1656
+ # Both `[]` and `""` mean "match nothing"; an empty array has
1657
+ # already been joined into an empty string by this point.
1658
+ if MULTI_FILTER_KEYS.any? { |key| params[wire_name(key)] == "" }
1659
+ return nil
1660
+ end
1661
+
1662
+ params
1663
+ end
1664
+
1665
+ # Convert a budget strategy into the wire form — the period as
1666
+ # integer milliseconds under `duration_ms`, and the kind as a
1667
+ # string. A `while_in_flight` strategy carries neither field.
1668
+ #
1669
+ # Takes the merge-patch shape, which a whole `Zizq::budget_strategy`
1670
+ # also satisfies, so `create_budget` and `update_budget` share this.
1671
+ # Key presence is what the server reads either way: a field absent
1672
+ # from a patch is absent from the body and left alone, while an
1673
+ # explicit `burst: nil` is sent as JSON null and clears the burst.
1674
+ # That is why `burst` is tested with `key?` rather than for truth.
1675
+ #
1676
+ # @rbs strategy: Zizq::budget_strategy_patch
1677
+ # @rbs return: Hash[Symbol, untyped]
1678
+ def wire_strategy(strategy)
1679
+ wire = {} #: Hash[Symbol, untyped]
1680
+ wire[:type] = strategy[:type].to_s if strategy[:type]
1681
+ if (duration = strategy[:duration])
1682
+ wire[:duration_ms] = (duration.to_f * 1000).to_i
1683
+ end
1684
+ wire[:burst] = strategy[:burst] if strategy.key?(:burst)
1685
+ wire
1686
+ end
1687
+
1106
1688
  # Build query params for list endpoints, joining multi-value keys with ",".
1689
+ # The query parameter name for a filter argument, which differs from
1690
+ # the Ruby name only where `API_FILTER_NAMES` says so.
1691
+ #
1692
+ # @rbs key: Symbol
1693
+ # @rbs return: Symbol
1694
+ def wire_name(key)
1695
+ API_FILTER_NAMES.fetch(key, key)
1696
+ end
1697
+
1107
1698
  def build_where_params(options, multi_keys: []) #: (Hash[Symbol, untyped], ?multi_keys: Array[Symbol]) -> Hash[Symbol, untyped]
1108
1699
  params = {} #: Hash[Symbol, untyped]
1109
1700
  options.each do |key, value|
1110
- if multi_keys.include?(key) && value.is_a?(Array)
1111
- params[key] = value.join(",")
1701
+ params[wire_name(key)] = if multi_keys.include?(key) &&
1702
+ value.is_a?(Array)
1703
+ value.join(",")
1112
1704
  else
1113
- params[key] = value
1705
+ value
1114
1706
  end
1115
1707
  end
1116
1708
  params
@@ -1118,22 +1710,32 @@ module Zizq
1118
1710
 
1119
1711
  def encode_body(body) #: (Hash[Symbol, untyped]) -> String
1120
1712
  case @format
1121
- when :msgpack then MessagePack.pack(body)
1122
- when :json then JSON.generate(body)
1123
- else raise ArgumentError, "Unknown format: #{@format}"
1713
+ when :msgpack
1714
+ MessagePack.pack(body)
1715
+ when :json
1716
+ JSON.generate(body)
1717
+ else
1718
+ raise ArgumentError, "Unknown format: #{@format}"
1124
1719
  end
1125
1720
  end
1126
1721
 
1127
1722
  def decode_body(data, content_type: nil) #: (String, ?content_type: String?) -> Hash[String, untyped]
1128
- format = case content_type
1129
- when /msgpack/ then :msgpack
1130
- when /json/ then :json
1131
- else @format
1132
- end
1723
+ format =
1724
+ case content_type
1725
+ when /msgpack/
1726
+ :msgpack
1727
+ when /json/
1728
+ :json
1729
+ else
1730
+ @format
1731
+ end
1133
1732
  case format
1134
- when :msgpack then MessagePack.unpack(data)
1135
- when :json then JSON.parse(data)
1136
- else raise ArgumentError, "Unknown format: #{format}"
1733
+ when :msgpack
1734
+ MessagePack.unpack(data)
1735
+ when :json
1736
+ JSON.parse(data)
1737
+ else
1738
+ raise ArgumentError, "Unknown format: #{format}"
1137
1739
  end
1138
1740
  end
1139
1741
 
@@ -1153,7 +1755,11 @@ module Zizq
1153
1755
  # Read the response body and close it, returning a RawResponse that is
1154
1756
  # safe to use outside the reactor.
1155
1757
  def consume_response(response) #: (untyped) -> RawResponse
1156
- RawResponse.new(status: response.status, body: response.read, content_type: response.headers["content-type"])
1758
+ RawResponse.new(
1759
+ status: response.status,
1760
+ body: response.read,
1761
+ content_type: response.headers["content-type"]
1762
+ )
1157
1763
  ensure
1158
1764
  response.close
1159
1765
  end
@@ -1239,24 +1845,22 @@ module Zizq
1239
1845
  end
1240
1846
 
1241
1847
  def thread_local_http(key, endpoint) #: (Symbol, Async::HTTP::Endpoint) -> Async::HTTP::Client
1242
- Thread.current.thread_variable_get(key) || begin
1243
- client = Async::HTTP::Client.new(endpoint)
1244
- @mutex.synchronize do
1245
- @http_clients.reject! { |ref| !ref.weakref_alive? }
1246
- @http_clients << WeakRef.new(client)
1848
+ Thread.current.thread_variable_get(key) ||
1849
+ begin
1850
+ client = Async::HTTP::Client.new(endpoint)
1851
+ @mutex.synchronize do
1852
+ @http_clients.reject! { |ref| !ref.weakref_alive? }
1853
+ @http_clients << WeakRef.new(client)
1854
+ end
1855
+ Thread.current.thread_variable_set(key, client)
1856
+ client
1247
1857
  end
1248
- Thread.current.thread_variable_set(key, client)
1249
- client
1250
- end
1251
1858
  end
1252
1859
 
1253
1860
  def get(path, params: {}) #: (String, ?params: Hash[Symbol, untyped]) -> RawResponse
1254
1861
  request do |http|
1255
1862
  consume_response(
1256
- http.get(
1257
- build_path(path, params:),
1258
- {"accept" => @content_type}
1259
- )
1863
+ http.get(build_path(path, params:), { "accept" => @content_type })
1260
1864
  )
1261
1865
  end
1262
1866
  end
@@ -1266,7 +1870,7 @@ module Zizq
1266
1870
  consume_response(
1267
1871
  http.post(
1268
1872
  build_path(path),
1269
- {"content-type" => @content_type, "accept" => @content_type},
1873
+ { "content-type" => @content_type, "accept" => @content_type },
1270
1874
  Protocol::HTTP::Body::Buffered.wrap(encode_body(body))
1271
1875
  )
1272
1876
  )
@@ -1276,10 +1880,7 @@ module Zizq
1276
1880
  def raw_post(path) #: (String) -> RawResponse
1277
1881
  request do |http|
1278
1882
  consume_response(
1279
- http.post(
1280
- build_path(path),
1281
- {"accept" => @content_type}
1282
- )
1883
+ http.post(build_path(path), { "accept" => @content_type })
1283
1884
  )
1284
1885
  end
1285
1886
  end
@@ -1289,7 +1890,7 @@ module Zizq
1289
1890
  consume_response(
1290
1891
  http.put(
1291
1892
  build_path(path),
1292
- {"content-type" => @content_type, "accept" => @content_type},
1893
+ { "content-type" => @content_type, "accept" => @content_type },
1293
1894
  Protocol::HTTP::Body::Buffered.wrap(encode_body(body))
1294
1895
  )
1295
1896
  )
@@ -1299,10 +1900,7 @@ module Zizq
1299
1900
  def delete(path, params: {}) #: (String, ?params: Hash[Symbol, untyped]) -> RawResponse
1300
1901
  request do |http|
1301
1902
  consume_response(
1302
- http.delete(
1303
- build_path(path, params:),
1304
- {"accept" => @content_type}
1305
- )
1903
+ http.delete(build_path(path, params:), { "accept" => @content_type })
1306
1904
  )
1307
1905
  end
1308
1906
  end
@@ -1312,7 +1910,7 @@ module Zizq
1312
1910
  consume_response(
1313
1911
  http.patch(
1314
1912
  build_path(path, params:),
1315
- {"content-type" => @content_type, "accept" => @content_type},
1913
+ { "content-type" => @content_type, "accept" => @content_type },
1316
1914
  Protocol::HTTP::Body::Buffered.wrap(encode_body(body))
1317
1915
  )
1318
1916
  )
@@ -1330,18 +1928,26 @@ module Zizq
1330
1928
  return nil if status == 204
1331
1929
  decode_body(response.body, content_type: ct)
1332
1930
  else
1333
- body = begin
1334
- decode_body(response.body, content_type: ct)
1335
- rescue
1336
- nil
1337
- end
1931
+ body =
1932
+ begin
1933
+ decode_body(response.body, content_type: ct)
1934
+ rescue StandardError
1935
+ nil
1936
+ end
1338
1937
  message = body&.fetch("error", nil) || "HTTP #{status}"
1339
- error_class = case status
1340
- when 404 then NotFoundError
1341
- when 400..499 then ClientError
1342
- when 500..599 then ServerError
1343
- else ResponseError
1344
- end
1938
+ error_class =
1939
+ case status
1940
+ when 404
1941
+ NotFoundError
1942
+ when 409
1943
+ ConflictError
1944
+ when 400..499
1945
+ ClientError
1946
+ when 500..599
1947
+ ServerError
1948
+ else
1949
+ ResponseError
1950
+ end
1345
1951
  raise error_class.new(message, status: status, body: body)
1346
1952
  end
1347
1953
  end