zizq 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb5651af26b76aeb9da0d00636cb1c2f10479c544c52083d16436f46670ba346
4
- data.tar.gz: 30b0a1142d96c565d11ef4252f07305e6ef55ac264217a8540975236ae007958
3
+ metadata.gz: a94a61282aba9442ace4ca8a3a302f75a843b14a76bd7d7748f006d5c8bf0888
4
+ data.tar.gz: 202f0842b2a2b22d8fce753c197635ea456ac94955f6f266f806471d01d06a7f
5
5
  SHA512:
6
- metadata.gz: 73d90b0c47f1dcf3452d573829211d69f853b38aca8d0b7e14be74017ac9b802f3faa9151f11ae1fcc0644a574d0b91dc308eec7c8fd560dccf93e1ab31ef321
7
- data.tar.gz: '0843a45519c6d0c6256450e242887a08489fdfb84040f6f302ba615118e5dfb9171cc52db1e5407446d0caa4012ff3b136a583f421c19ea3cd0fe6ba92a38371'
6
+ metadata.gz: 03cb04925c7aac0aaaee30f86e27448641ec4864b903bedeee83cd61b0d0ed803655dd69c8ed586c4b83faa3d8d910c13c589398ca3157f130c569fa3edce5e9
7
+ data.tar.gz: b3a2c1232195698681ae743e04db5aff883c4e82e36bab9d1735b7459c7db5fdbcb9829171fa30318cec4ac01dc34432a79c2760824f4d2db93c89fbda12da58
data/README.md CHANGED
@@ -18,9 +18,28 @@ This is the official Zizq client library for Ruby.
18
18
  * Scheduled jobs
19
19
  * Configurable backoff policies
20
20
  * Configurable job retention policies
21
+ * Recurring jobs (cron)
21
22
  * Job introspection and management APIs, with support for `jq` query filters
22
23
  * Unique jobs
23
24
 
25
+ ## Installation
26
+
27
+ > [!NOTE]
28
+ > If you have not yet installed the Zizq server, follow the
29
+ > [Getting Started](https://zizq.io/docs/getting-started) guide first.
30
+
31
+ Add it to your application's `Gemfile`.
32
+
33
+ ``` ruby
34
+ gem 'zizq', '~> 0.3.0'
35
+ ```
36
+
37
+ Or install it manually:
38
+
39
+ ```shell
40
+ $ gem install zizq -v 0.3.0
41
+ ```
42
+
24
43
  ## Example
25
44
 
26
45
  > [!TIP]
@@ -9,8 +9,9 @@ require_relative "job_config"
9
9
  module Zizq
10
10
  # Zizq configuration DSL for ActiveJob classes.
11
11
  #
12
- # Extend this module in an ActiveJob subclass to gain access to Zizq
13
- # features like unique jobs, backoff, and retention:
12
+ # Extend this module in an ActiveJob subclass to allow enqueueing jobs via
13
+ # `Zizq.enqueue` and to gain access to Zizq features like unique jobs,
14
+ # backoff, and retention:
14
15
  #
15
16
  # class SendEmailJob < ApplicationJob
16
17
  # extend Zizq::ActiveJobConfig
@@ -36,17 +37,28 @@ module Zizq
36
37
  # @rbs!
37
38
  # # ActiveJob::Base.new — invisible to steep without this.
38
39
  # def new: (*untyped, **untyped) -> untyped
40
+ #
41
+ # # ActiveJob::Base.queue_name — invisible to steep without this.
42
+ # def queue_name: () -> String?
43
+
44
+ # Use ActiveJob's `queue_name` as the default queue, falling back to
45
+ # any explicit `zizq_queue` setting, then "default".
46
+ def zizq_queue(name = nil) #: (?String?) -> String
47
+ if name
48
+ super
49
+ else
50
+ @zizq_queue || queue_name || "default"
51
+ end
52
+ end
39
53
 
40
- # Serialize arguments using ActiveJob's serialization format.
54
+ # Serialize using ActiveJob's own format.
41
55
  #
42
56
  # Creates a temporary ActiveJob instance to produce the canonical
43
- # serialized form, including `_aj_ruby2_keywords` markers for kwargs.
44
- # This ensures unique key generation uses the same format as the
45
- # enqueued payload.
46
- #
47
- # This is needed so that unique job keys can be correctly generated.
48
- def zizq_serialize(*args, **kwargs) #: (*untyped, **untyped) -> Array[untyped]
49
- new(*args, **kwargs).serialize["arguments"]
57
+ # serialized form. Returns the full serialized hash (including
58
+ # `job_class`, `arguments`, `queue_name`, etc.) so that the payload
59
+ # stored in Zizq matches what `ActiveJob::Base.execute` expects.
60
+ def zizq_serialize(*args, **kwargs) #: (*untyped, **untyped) -> Hash[String, untyped]
61
+ new(*args, **kwargs).serialize
50
62
  end
51
63
 
52
64
  # Deserialization is handled by ActiveJob::Base.execute on the worker
@@ -56,6 +68,15 @@ module Zizq
56
68
  "ActiveJob handles deserialization via ActiveJob::Base.execute"
57
69
  end
58
70
 
71
+ # Override unique key generation to hash only the arguments portion
72
+ # of the serialized payload. The full payload contains volatile fields
73
+ # (job_id, enqueued_at, etc.) that change per instance.
74
+ def zizq_unique_key(*args, **kwargs) #: (*untyped, **untyped) -> String
75
+ arguments = new(*args, **kwargs).serialize["arguments"]
76
+ payload = normalize_payload(arguments)
77
+ "#{name}:#{Digest::SHA256.hexdigest(JSON.generate(payload))}"
78
+ end
79
+
59
80
  # Generate a jq expression that exactly matches payloads with the given
60
81
  # arguments.
61
82
  #
@@ -65,8 +86,8 @@ module Zizq
65
86
  #
66
87
  # .arguments == ["a","b",{"example":true,"_aj_ruby2_keywords":["example"]}]
67
88
  def zizq_payload_filter(*args, **kwargs) #: (*untyped, **untyped) -> String
68
- payload = zizq_serialize(*args, **kwargs)
69
- ".arguments == #{JSON.generate(payload)}"
89
+ arguments = zizq_serialize(*args, **kwargs)["arguments"]
90
+ ".arguments == #{JSON.generate(arguments)}"
70
91
  end
71
92
 
72
93
  # Generate a jq expression that matches jobs whose positional args
@@ -85,27 +106,27 @@ module Zizq
85
106
  # (.arguments[-1] | has("_aj_ruby2_keywords")) and
86
107
  # (.arguments[-1] | contains({"example":true}))
87
108
  def zizq_payload_subset_filter(*args, **kwargs) #: (*untyped, **untyped) -> String
88
- payload = zizq_serialize(*args, **kwargs)
109
+ arguments = zizq_serialize(*args, **kwargs)["arguments"]
89
110
 
90
111
  # ActiveJob flattens arguments into a single array, but marks kwargs with
91
112
  # "_aj_ruby2_keywords" => ["key1", "key2", ...] in the last element of
92
113
  # the array where kwargs are present. We need to detect this to generate
93
114
  # a suitable expression.
94
115
  serialized_args, serialized_kwargs =
95
- if payload.size > 0
116
+ if arguments.size > 0
96
117
  # See what the last argument looks like. It might be kwargs.
97
- maybe_kwargs = payload.pop
118
+ maybe_kwargs = arguments.pop
98
119
 
99
120
  # If it's got "_aj_ruby2_keywords" then it is kwargs.
100
121
  if maybe_kwargs.is_a?(Hash) && maybe_kwargs["_aj_ruby2_keywords"]
101
122
  # We only want the actual kwargs, not the marker.
102
- [payload, maybe_kwargs.except("_aj_ruby2_keywords")]
123
+ [arguments, maybe_kwargs.except("_aj_ruby2_keywords")]
103
124
  else
104
125
  # It wasn't kwargs, so put it back.
105
- [payload.push(maybe_kwargs), nil]
126
+ [arguments.push(maybe_kwargs), nil]
106
127
  end
107
128
  else
108
- [payload, nil]
129
+ [arguments, nil]
109
130
  end
110
131
 
111
132
  parts = [] #: Array[String]
@@ -22,7 +22,7 @@ module Zizq
22
22
  # Collect a job class enqueue. Accepts the same arguments as
23
23
  # `Zizq.enqueue`.
24
24
  #
25
- # @rbs job_class: Class & Zizq::job_class
25
+ # @rbs job_class: Class & Zizq::JobConfig
26
26
  # @rbs args: Array[untyped]
27
27
  # @rbs kwargs: Hash[Symbol, untyped]
28
28
  # @rbs &block: ?(EnqueueRequest) -> void
data/lib/zizq/client.rb CHANGED
@@ -204,7 +204,7 @@ module Zizq
204
204
 
205
205
  # Get a single job by ID.
206
206
  def get_job(id) #: (String) -> Resources::Job
207
- response = get("/jobs/#{id}")
207
+ response = get("/jobs/#{enc(id)}")
208
208
  data = handle_response!(response, expected: 200)
209
209
  Resources::Job.new(self, data)
210
210
  end
@@ -285,7 +285,7 @@ module Zizq
285
285
  # @rbs id: String
286
286
  # @rbs return: void
287
287
  def delete_job(id)
288
- response = delete("/jobs/#{id}")
288
+ response = delete("/jobs/#{enc(id)}")
289
289
  handle_response!(response, expected: [200, 204])
290
290
  nil
291
291
  end
@@ -342,7 +342,7 @@ module Zizq
342
342
  queue:, priority:, ready_at:,
343
343
  retry_limit:, backoff:, retention:
344
344
  )
345
- response = patch("/jobs/#{id}", body)
345
+ response = patch("/jobs/#{enc(id)}", body)
346
346
  data = handle_response!(response, expected: 200)
347
347
  Resources::Job.new(self, data)
348
348
  end
@@ -383,7 +383,7 @@ module Zizq
383
383
  # @rbs attempt: Integer
384
384
  # @rbs return: Resources::ErrorRecord
385
385
  def get_error(id, attempt:)
386
- response = get("/jobs/#{id}/errors/#{attempt}")
386
+ response = get("/jobs/#{enc(id)}/errors/#{enc(attempt.to_s)}")
387
387
  data = handle_response!(response, expected: 200)
388
388
  Resources::ErrorRecord.new(self, data)
389
389
  end
@@ -397,7 +397,7 @@ module Zizq
397
397
  # @rbs return: Resources::ErrorPage
398
398
  def list_errors(id, from: nil, order: nil, limit: nil)
399
399
  params = { from:, order:, limit: }.compact #: Hash[Symbol, untyped]
400
- response = get("/jobs/#{id}/errors", params:)
400
+ response = get("/jobs/#{enc(id)}/errors", params:)
401
401
  data = handle_response!(response, expected: 200)
402
402
  Resources::ErrorPage.new(self, data)
403
403
  end
@@ -422,6 +422,136 @@ module Zizq
422
422
  data["queues"]
423
423
  end
424
424
 
425
+ # List all cron group names.
426
+ #
427
+ # @rbs return: Array[String]
428
+ def list_cron_groups
429
+ response = get("/crons")
430
+ data = handle_response!(response, expected: 200)
431
+ data["crons"]
432
+ end
433
+
434
+ # Fetch a cron group and all its entries.
435
+ #
436
+ # @rbs name: String
437
+ # @rbs return: Resources::CronGroup
438
+ def get_cron_group(name)
439
+ response = get("/crons/#{enc(name)}")
440
+ data = handle_response!(response, expected: 200)
441
+ Resources::CronGroup.new(self, data)
442
+ end
443
+
444
+ # Create or replace an entire cron group.
445
+ #
446
+ # Entries not present in the request are removed. Entries with unchanged
447
+ # expressions preserve their scheduling state.
448
+ #
449
+ # @rbs name: String
450
+ # @rbs paused: bool?
451
+ # @rbs entries: Array[Zizq::cron_entry_params]
452
+ # @rbs return: Resources::CronGroup
453
+ def replace_cron_group(name, paused: nil, entries: [])
454
+ body = {
455
+ paused:,
456
+ entries: entries.map { |entry| build_cron_entry(**entry) }
457
+ }.compact
458
+ response = put("/crons/#{enc(name)}", body)
459
+ data = handle_response!(response, expected: 200)
460
+ Resources::CronGroup.new(self, data)
461
+ end
462
+
463
+ # Update group-level fields (currently just pause/unpause).
464
+ #
465
+ # @rbs name: String
466
+ # @rbs paused: bool?
467
+ # @rbs return: Resources::CronGroup
468
+ def update_cron_group(name, paused: nil)
469
+ response = patch("/crons/#{enc(name)}", { paused: }.compact)
470
+ data = handle_response!(response, expected: 200)
471
+ Resources::CronGroup.new(self, data)
472
+ end
473
+
474
+ # Delete a cron group and all its entries.
475
+ #
476
+ # @rbs name: String
477
+ # @rbs return: void
478
+ def delete_cron_group(name)
479
+ response = delete("/crons/#{enc(name)}")
480
+ handle_response!(response, expected: 204)
481
+ nil
482
+ end
483
+
484
+ # Fetch a single cron entry.
485
+ #
486
+ # @rbs group: String
487
+ # @rbs entry: String
488
+ # @rbs return: Resources::CronEntry
489
+ def get_cron_group_entry(group, entry)
490
+ response = get("/crons/#{enc(group)}/entries/#{enc(entry)}")
491
+ data = handle_response!(response, expected: 200)
492
+ Resources::CronEntry.new(self, data)
493
+ end
494
+
495
+ # Add a single entry to a cron group (creates the group if needed).
496
+ #
497
+ # Raises a ClientError (409 Conflict) if an entry with the same name
498
+ # already exists.
499
+ #
500
+ # @rbs group: String
501
+ # @rbs name: String
502
+ # @rbs expression: String
503
+ # @rbs job: Zizq::cron_job_params
504
+ # @rbs timezone: String?
505
+ # @rbs paused: bool?
506
+ # @rbs return: Resources::CronEntry
507
+ def add_cron_group_entry(group, name:, expression:, job:, timezone: nil, paused: nil)
508
+ body = build_cron_entry(name:, expression:, job:, timezone:, paused:)
509
+ response = post("/crons/#{enc(group)}/entries", body)
510
+ data = handle_response!(response, expected: 201)
511
+ Resources::CronEntry.new(self, data)
512
+ end
513
+
514
+ # Create or replace a single cron entry.
515
+ #
516
+ # Preserves scheduling state if the expression is unchanged.
517
+ #
518
+ # @rbs group: String
519
+ # @rbs entry: String
520
+ # @rbs expression: String
521
+ # @rbs job: Zizq::cron_job_params
522
+ # @rbs timezone: String?
523
+ # @rbs paused: bool?
524
+ # @rbs return: Resources::CronEntry
525
+ def replace_cron_group_entry(group, entry, expression:, job:, timezone: nil, paused: nil)
526
+ body = build_cron_entry(name: entry, expression:, job:, timezone:, paused:)
527
+ response = put("/crons/#{enc(group)}/entries/#{enc(entry)}", body)
528
+ data = handle_response!(response, expected: 200)
529
+ Resources::CronEntry.new(self, data)
530
+ end
531
+
532
+ # Update entry-level fields (currently just pause/unpause).
533
+ #
534
+ # @rbs group: String
535
+ # @rbs entry: String
536
+ # @rbs paused: bool
537
+ # @rbs return: Resources::CronEntry
538
+ def update_cron_group_entry(group, entry, paused:)
539
+ response = patch("/crons/#{enc(group)}/entries/#{enc(entry)}", { paused: })
540
+ data = handle_response!(response, expected: 200)
541
+ Resources::CronEntry.new(self, data)
542
+ end
543
+
544
+ # Delete a single cron entry.
545
+ #
546
+ # @rbs group: String
547
+ # @rbs entry: String
548
+ # @rbs return: void
549
+ def delete_cron_group_entry(group, entry)
550
+ response = delete("/crons/#{enc(group)}/entries/#{enc(entry)}")
551
+ handle_response!(response, expected: 204)
552
+ nil
553
+ end
554
+
425
555
  # Mark a job as successfully completed (ack).
426
556
  #
427
557
  # If this method (or [`#report_failure`]) is not called upon job
@@ -439,7 +569,7 @@ module Zizq
439
569
  # The Zizq server sends heartbeat messages to connected workers so that
440
570
  # it can quickly detect and handle disconnected clients.
441
571
  def report_success(id) #: (String) -> nil
442
- response = raw_post("/jobs/#{id}/success")
572
+ response = raw_post("/jobs/#{enc(id)}/success")
443
573
  handle_response!(response, expected: 204)
444
574
  nil
445
575
  end
@@ -503,7 +633,7 @@ module Zizq
503
633
  body[:retry_at] = (retry_at * 1000).to_i if retry_at
504
634
  body[:kill] = kill if kill
505
635
 
506
- response = post("/jobs/#{id}/failure", body)
636
+ response = post("/jobs/#{enc(id)}/failure", body)
507
637
  data = handle_response!(response, expected: 200)
508
638
  Resources::Job.new(self, data)
509
639
  end
@@ -660,6 +790,11 @@ module Zizq
660
790
 
661
791
  private
662
792
 
793
+ # URL-encode a single path segment.
794
+ def enc(value) #: (String) -> String
795
+ URI.encode_uri_component(value)
796
+ end
797
+
663
798
  # Build a relative path with optional query parameters.
664
799
  def build_path(path, params: {}) #: (String, ?params: Hash[Symbol, untyped]) -> String
665
800
  unless params.empty?
@@ -668,6 +803,57 @@ module Zizq
668
803
  path
669
804
  end
670
805
 
806
+ # Validate and build a cron entry body from keyword arguments.
807
+ #
808
+ # @rbs name: String
809
+ # @rbs expression: String
810
+ # @rbs job: Zizq::cron_job_params
811
+ # @rbs timezone: String?
812
+ # @rbs paused: bool?
813
+ # @rbs return: Hash[Symbol, untyped]
814
+ def build_cron_entry(name: nil, expression: nil, job: nil, timezone: nil, paused: nil)
815
+ {
816
+ name:,
817
+ expression:,
818
+ timezone:,
819
+ paused:,
820
+ job: build_cron_job(**job),
821
+ }.compact
822
+ end
823
+
824
+ # Validate and build a cron job template from keyword arguments.
825
+ #
826
+ # Uses keyword args so that unknown keys raise ArgumentError.
827
+ #
828
+ # @rbs type: String
829
+ # @rbs queue: String
830
+ # @rbs payload: untyped
831
+ # @rbs priority: Integer?
832
+ # @rbs retry_limit: Integer?
833
+ # @rbs backoff: Zizq::backoff?
834
+ # @rbs retention: Zizq::retention?
835
+ # @rbs unique_key: String?
836
+ # @rbs unique_while: Zizq::unique_scope?
837
+ # @rbs return: Hash[Symbol, untyped]
838
+ def build_cron_job(type: nil,
839
+ queue: nil,
840
+ payload: nil,
841
+ priority: nil,
842
+ retry_limit: nil,
843
+ backoff: nil,
844
+ retention: nil,
845
+ unique_key: nil,
846
+ unique_while: nil)
847
+ job = { type:, queue:, payload: } #: Hash[Symbol, untyped]
848
+ job[:priority] = priority if priority
849
+ job[:retry_limit] = retry_limit if retry_limit
850
+ job[:backoff] = backoff if backoff
851
+ job[:retention] = retention if retention
852
+ job[:unique_key] = unique_key if unique_key
853
+ job[:unique_while] = unique_while.to_s if unique_while
854
+ job
855
+ end
856
+
671
857
  # Validate and normalize filter parameters for bulk operations.
672
858
  #
673
859
  # Uses keyword arguments so that unknown keys raise ArgumentError.
@@ -942,6 +1128,18 @@ module Zizq
942
1128
  end
943
1129
  end
944
1130
 
1131
+ def put(path, body) #: (String, Hash[Symbol, untyped]) -> RawResponse
1132
+ request do |http|
1133
+ consume_response(
1134
+ http.put(
1135
+ build_path(path),
1136
+ {"content-type" => @content_type, "accept" => @content_type},
1137
+ Protocol::HTTP::Body::Buffered.wrap(encode_body(body))
1138
+ )
1139
+ )
1140
+ end
1141
+ end
1142
+
945
1143
  def delete(path, params: {}) #: (String, ?params: Hash[Symbol, untyped]) -> RawResponse
946
1144
  request do |http|
947
1145
  consume_response(