zizq 0.6.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -11
  3. data/lib/active_job/queue_adapters/zizq_adapter.rb +4 -23
  4. data/lib/zizq/bulk_enqueue.rb +61 -14
  5. data/lib/zizq/client.rb +580 -77
  6. data/lib/zizq/crontab.rb +36 -3
  7. data/lib/zizq/crontab_builder.rb +10 -1
  8. data/lib/zizq/crontab_entry_builder.rb +66 -14
  9. data/lib/zizq/enqueue_request.rb +22 -21
  10. data/lib/zizq/enqueue_with.rb +65 -8
  11. data/lib/zizq/error.rb +10 -0
  12. data/lib/zizq/job_config.rb +95 -12
  13. data/lib/zizq/query.rb +111 -39
  14. data/lib/zizq/resources/budget.rb +109 -0
  15. data/lib/zizq/resources/cron_group.rb +4 -0
  16. data/lib/zizq/resources/job.rb +108 -2
  17. data/lib/zizq/resources/job_template.rb +15 -0
  18. data/lib/zizq/resources.rb +1 -0
  19. data/lib/zizq/test/client.rb +6 -3
  20. data/lib/zizq/version.rb +1 -1
  21. data/lib/zizq.rb +191 -14
  22. data/sig/generated/zizq/bulk_enqueue.rbs +4 -16
  23. data/sig/generated/zizq/client.rbs +377 -8
  24. data/sig/generated/zizq/crontab.rbs +17 -1
  25. data/sig/generated/zizq/crontab_builder.rbs +9 -0
  26. data/sig/generated/zizq/crontab_entry_builder.rbs +4 -17
  27. data/sig/generated/zizq/enqueue_request.rbs +11 -4
  28. data/sig/generated/zizq/enqueue_with.rbs +4 -9
  29. data/sig/generated/zizq/error.rbs +10 -0
  30. data/sig/generated/zizq/job_config.rbs +65 -0
  31. data/sig/generated/zizq/query.rbs +79 -2
  32. data/sig/generated/zizq/resources/budget.rbs +97 -0
  33. data/sig/generated/zizq/resources/cron_group.rbs +3 -0
  34. data/sig/generated/zizq/resources/job.rbs +94 -0
  35. data/sig/generated/zizq/resources/job_template.rbs +11 -0
  36. data/sig/generated/zizq.rbs +111 -33
  37. data/sig/zizq.rbs +100 -7
  38. 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",
@@ -152,6 +177,9 @@ module Zizq
152
177
  # using the Zizq API's expected inputs. Callers should generally use
153
178
  # [`Zizq::enqueue`] instead.
154
179
  #
180
+ # Durations and timestamps are fractional seconds throughout to align with
181
+ # Ruby, as opposed to the server's integer milliseconds representation.
182
+ #
155
183
  # Returns a resource instance of the new job wrapping the API response.
156
184
  #
157
185
  # @rbs queue: String
@@ -165,6 +193,7 @@ module Zizq
165
193
  # @rbs unique_key: String?
166
194
  # @rbs unique_while: Zizq::unique_scope?
167
195
  # @rbs batch: Zizq::batch?
196
+ # @rbs budgets: Array[Zizq::budget_binding_params]?
168
197
  # @rbs return: Resources::Job
169
198
  def enqueue(
170
199
  queue:,
@@ -177,18 +206,20 @@ module Zizq
177
206
  retention: nil,
178
207
  unique_key: nil,
179
208
  unique_while: nil,
180
- batch: nil
209
+ batch: nil,
210
+ budgets: nil
181
211
  )
182
212
  body = { queue:, type:, payload: } #: Hash[Symbol, untyped]
183
213
  body[:priority] = priority if priority
184
214
  # ready_at is fractional seconds in Ruby; the server expects ms.
185
215
  body[:ready_at] = (ready_at.to_f * 1000).to_i if ready_at
186
216
  body[:retry_limit] = retry_limit if retry_limit
187
- body[:backoff] = backoff if backoff
188
- body[:retention] = retention if retention
217
+ body[:backoff] = wire_backoff(backoff) if backoff
218
+ body[:retention] = wire_retention(retention) if retention
189
219
  body[:unique_key] = unique_key if unique_key
190
220
  body[:unique_while] = unique_while.to_s if unique_while
191
221
  body[:batch] = batch if batch
222
+ body[:budgets] = budgets.map { |b| wire_binding(b) } if budgets&.any?
192
223
 
193
224
  response = post("/jobs", body)
194
225
  data = handle_response!(response, expected: [200, 201])
@@ -220,11 +251,16 @@ module Zizq
220
251
  :ready_at
221
252
  ]
222
253
  wire[:retry_limit] = job[:retry_limit] if job[:retry_limit]
223
- wire[:backoff] = job[:backoff] if job[:backoff]
224
- wire[:retention] = job[:retention] if job[:retention]
254
+ wire[:backoff] = wire_backoff(job[:backoff]) if job[:backoff]
255
+ wire[:retention] = wire_retention(job[:retention]) if job[
256
+ :retention
257
+ ]
225
258
  wire[:unique_key] = job[:unique_key] if job[:unique_key]
226
259
  wire[:unique_while] = job[:unique_while].to_s if job[:unique_while]
227
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
228
264
  wire
229
265
  end
230
266
  }
@@ -273,34 +309,30 @@ module Zizq
273
309
  priority: nil,
274
310
  ready_at: nil,
275
311
  attempts: nil,
312
+ budgets_key: nil,
276
313
  from: nil,
277
314
  order: nil,
278
315
  limit: nil
279
316
  )
280
- options = {
317
+ where = {
281
318
  id:,
282
319
  status:,
283
320
  queue:,
284
321
  type:,
285
322
  filter:,
286
- priority: encode_range(priority),
287
- ready_at: encode_range(ready_at) { |v| (v.to_f * 1000).to_i },
288
- attempts: encode_range(attempts),
289
- from:,
290
- order:,
291
- limit:
292
- }.compact #: Hash[Symbol, untyped]
293
-
294
- multi_keys = %i[id status queue type]
295
- params = build_where_params(options, multi_keys:)
296
-
297
- # An empty filter ([] or "") matches nothing — short-circuit.
298
- multi_keys.each do |key|
299
- if params[key] == ""
300
- return Resources::JobPage.new(self, { "jobs" => [], "pages" => {} })
301
- end
323
+ priority:,
324
+ ready_at:,
325
+ attempts:,
326
+ budgets_key:
327
+ }.compact #: Zizq::where_params
328
+ params = filter_params(where)
329
+
330
+ if params.nil?
331
+ return Resources::JobPage.new(self, { "jobs" => [], "pages" => {} })
302
332
  end
303
333
 
334
+ params = params.merge({ from:, order:, limit: }.compact)
335
+
304
336
  response = get("/jobs", params:)
305
337
  data = handle_response!(response, expected: 200)
306
338
  Resources::JobPage.new(self, data)
@@ -328,24 +360,22 @@ module Zizq
328
360
  filter: nil,
329
361
  priority: nil,
330
362
  ready_at: nil,
331
- attempts: nil
363
+ attempts: nil,
364
+ budgets_key: nil
332
365
  )
333
- options = {
366
+ where = {
334
367
  id:,
335
368
  status:,
336
369
  queue:,
337
370
  type:,
338
371
  filter:,
339
- priority: encode_range(priority),
340
- ready_at: encode_range(ready_at) { |v| (v.to_f * 1000).to_i },
341
- attempts: encode_range(attempts)
342
- }.compact #: Hash[Symbol, untyped]
343
-
344
- multi_keys = %i[id status queue type]
345
- params = build_where_params(options, multi_keys:)
346
-
347
- # An empty filter ([] or "") matches nothing — short-circuit.
348
- multi_keys.each { |key| return 0 if params[key] == "" }
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?
349
379
 
350
380
  response = get("/jobs/count", params:)
351
381
  data = handle_response!(response, expected: 200)
@@ -372,13 +402,8 @@ module Zizq
372
402
  # @rbs where: Zizq::where_params
373
403
  # @rbs return: Integer
374
404
  def delete_all_jobs(where: {})
375
- filter_params = validate_where(**where)
376
-
377
- multi_keys = %i[id status queue type]
378
- params = build_where_params(filter_params, multi_keys:)
379
-
380
- # An empty multi-value filter matches nothing — short-circuit.
381
- multi_keys.each { |key| return 0 if params[key] == "" }
405
+ params = filter_params(where)
406
+ return 0 if params.nil?
382
407
 
383
408
  response = delete("/jobs", params:)
384
409
  data = handle_response!(response, expected: 200)
@@ -458,13 +483,8 @@ module Zizq
458
483
  # @rbs apply: Zizq::apply_params
459
484
  # @rbs return: Integer
460
485
  def update_all_jobs(where: {}, apply: {})
461
- filter_params = validate_where(**where)
462
-
463
- multi_keys = %i[id status queue type]
464
- params = build_where_params(filter_params, multi_keys:)
465
-
466
- # An empty multi-value filter matches nothing — short-circuit.
467
- multi_keys.each { |key| return 0 if params[key] == "" }
486
+ params = filter_params(where)
487
+ return 0 if params.nil?
468
488
 
469
489
  body = validate_and_build_set(**apply)
470
490
  response = patch("/jobs", body, params:)
@@ -517,6 +537,336 @@ module Zizq
517
537
  data["queues"]
518
538
  end
519
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
+
520
870
  # List all cron group names.
521
871
  #
522
872
  # @rbs return: Array[String]
@@ -541,13 +891,19 @@ module Zizq
541
891
  # Entries not present in the request are removed. Entries with unchanged
542
892
  # expressions preserve their scheduling state.
543
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
+ #
544
898
  # @rbs name: String
545
899
  # @rbs paused: bool?
900
+ # @rbs timezone: String?
546
901
  # @rbs entries: Array[Zizq::cron_entry_params]
547
902
  # @rbs return: Resources::CronGroup
548
- def replace_cron_group(name, paused: nil, entries: [])
903
+ def replace_cron_group(name, paused: nil, timezone: nil, entries: [])
549
904
  body = {
550
905
  paused:,
906
+ timezone:,
551
907
  entries: entries.map { |entry| build_cron_entry(**entry) }
552
908
  }.compact
553
909
  response = put("/crons/#{enc(name)}", body)
@@ -606,7 +962,7 @@ module Zizq
606
962
 
607
963
  # Add a single entry to a cron group (creates the group if needed).
608
964
  #
609
- # 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
610
966
  # already exists.
611
967
  #
612
968
  # @rbs group: String
@@ -997,6 +1353,8 @@ module Zizq
997
1353
  # @rbs retention: Zizq::retention?
998
1354
  # @rbs unique_key: String?
999
1355
  # @rbs unique_while: Zizq::unique_scope?
1356
+ # @rbs batch: Zizq::batch?
1357
+ # @rbs budgets: Array[Zizq::budget_binding_params]?
1000
1358
  # @rbs return: Hash[Symbol, untyped]
1001
1359
  def build_cron_job(
1002
1360
  type: nil,
@@ -1007,15 +1365,19 @@ module Zizq
1007
1365
  backoff: nil,
1008
1366
  retention: nil,
1009
1367
  unique_key: nil,
1010
- unique_while: nil
1368
+ unique_while: nil,
1369
+ batch: nil,
1370
+ budgets: nil
1011
1371
  )
1012
1372
  job = { type:, queue:, payload: } #: Hash[Symbol, untyped]
1013
1373
  job[:priority] = priority if priority
1014
1374
  job[:retry_limit] = retry_limit if retry_limit
1015
- job[:backoff] = backoff if backoff
1016
- job[:retention] = retention if retention
1375
+ job[:backoff] = wire_backoff(backoff) if backoff
1376
+ job[:retention] = wire_retention(retention) if retention
1017
1377
  job[:unique_key] = unique_key if unique_key
1018
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?
1019
1381
  job
1020
1382
  end
1021
1383
 
@@ -1031,6 +1393,7 @@ module Zizq
1031
1393
  # @rbs priority: (Integer | Range[Integer?])?
1032
1394
  # @rbs ready_at: (Zizq::to_f | Range[Zizq::to_f?])?
1033
1395
  # @rbs attempts: (Integer | Range[Integer?])?
1396
+ # @rbs budgets_key: (String | Array[String])?
1034
1397
  # @rbs return: Hash[Symbol, untyped]
1035
1398
  def validate_where(
1036
1399
  id: nil,
@@ -1040,7 +1403,8 @@ module Zizq
1040
1403
  filter: nil,
1041
1404
  priority: nil,
1042
1405
  ready_at: nil,
1043
- attempts: nil
1406
+ attempts: nil,
1407
+ budgets_key: nil
1044
1408
  )
1045
1409
  {
1046
1410
  id:,
@@ -1050,7 +1414,8 @@ module Zizq
1050
1414
  filter:,
1051
1415
  priority: encode_range(priority),
1052
1416
  ready_at: encode_range(ready_at) { |v| (v.to_f * 1000).to_i },
1053
- attempts: encode_range(attempts)
1417
+ attempts: encode_range(attempts),
1418
+ budgets_key:
1054
1419
  }.compact
1055
1420
  end
1056
1421
 
@@ -1165,43 +1530,179 @@ module Zizq
1165
1530
  end
1166
1531
 
1167
1532
  unless backoff.equal?(UNCHANGED)
1168
- body[:backoff] = if backoff.equal?(RESET)
1169
- nil
1170
- else
1171
- {
1172
- exponent: backoff[:exponent].to_f,
1173
- base_ms: (backoff[:base].to_f * 1000).to_i,
1174
- jitter_ms: (backoff[:jitter].to_f * 1000).to_i
1175
- }
1176
- end
1533
+ body[:backoff] = backoff.equal?(RESET) ? nil : wire_backoff(backoff)
1177
1534
  end
1178
1535
 
1179
1536
  unless retention.equal?(UNCHANGED)
1180
1537
  body[:retention] = if retention.equal?(RESET)
1181
1538
  nil
1182
1539
  else
1183
- ret = {} #: Hash[Symbol, Integer]
1184
- ret[:completed_ms] = (
1185
- retention[:completed].to_f * 1000
1186
- ).to_i if retention[:completed]
1187
- ret[:dead_ms] = (retention[:dead].to_f * 1000).to_i if retention[
1188
- :dead
1189
- ]
1190
- ret
1540
+ wire_retention(retention)
1191
1541
  end
1192
1542
  end
1193
1543
 
1194
1544
  body
1195
1545
  end
1196
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
+
1197
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
+
1198
1698
  def build_where_params(options, multi_keys: []) #: (Hash[Symbol, untyped], ?multi_keys: Array[Symbol]) -> Hash[Symbol, untyped]
1199
1699
  params = {} #: Hash[Symbol, untyped]
1200
1700
  options.each do |key, value|
1201
- if multi_keys.include?(key) && value.is_a?(Array)
1202
- params[key] = value.join(",")
1701
+ params[wire_name(key)] = if multi_keys.include?(key) &&
1702
+ value.is_a?(Array)
1703
+ value.join(",")
1203
1704
  else
1204
- params[key] = value
1705
+ value
1205
1706
  end
1206
1707
  end
1207
1708
  params
@@ -1438,6 +1939,8 @@ module Zizq
1438
1939
  case status
1439
1940
  when 404
1440
1941
  NotFoundError
1942
+ when 409
1943
+ ConflictError
1441
1944
  when 400..499
1442
1945
  ClientError
1443
1946
  when 500..599