zizq 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +94 -0
  4. data/bin/profile-worker +145 -0
  5. data/bin/zizq-worker +174 -0
  6. data/lib/active_job/queue_adapters/zizq_adapter.rb +109 -0
  7. data/lib/zizq/ack_processor.rb +132 -0
  8. data/lib/zizq/active_job_config.rb +122 -0
  9. data/lib/zizq/backoff.rb +50 -0
  10. data/lib/zizq/bulk_enqueue.rb +87 -0
  11. data/lib/zizq/client.rb +982 -0
  12. data/lib/zizq/configuration.rb +164 -0
  13. data/lib/zizq/enqueue_request.rb +178 -0
  14. data/lib/zizq/enqueue_with.rb +109 -0
  15. data/lib/zizq/error.rb +43 -0
  16. data/lib/zizq/job.rb +188 -0
  17. data/lib/zizq/job_config.rb +244 -0
  18. data/lib/zizq/lifecycle.rb +58 -0
  19. data/lib/zizq/middleware.rb +79 -0
  20. data/lib/zizq/query.rb +566 -0
  21. data/lib/zizq/resources/error_enumerator.rb +241 -0
  22. data/lib/zizq/resources/error_page.rb +19 -0
  23. data/lib/zizq/resources/error_record.rb +19 -0
  24. data/lib/zizq/resources/job.rb +124 -0
  25. data/lib/zizq/resources/job_page.rb +57 -0
  26. data/lib/zizq/resources/page.rb +77 -0
  27. data/lib/zizq/resources/resource.rb +45 -0
  28. data/lib/zizq/resources.rb +16 -0
  29. data/lib/zizq/version.rb +9 -0
  30. data/lib/zizq/worker.rb +467 -0
  31. data/lib/zizq.rb +269 -0
  32. data/sig/generated/zizq/ack_processor.rbs +73 -0
  33. data/sig/generated/zizq/active_job_config.rbs +74 -0
  34. data/sig/generated/zizq/backoff.rbs +34 -0
  35. data/sig/generated/zizq/bulk_enqueue.rbs +72 -0
  36. data/sig/generated/zizq/client.rbs +419 -0
  37. data/sig/generated/zizq/configuration.rbs +95 -0
  38. data/sig/generated/zizq/enqueue_request.rbs +94 -0
  39. data/sig/generated/zizq/enqueue_with.rbs +88 -0
  40. data/sig/generated/zizq/error.rbs +41 -0
  41. data/sig/generated/zizq/job.rbs +136 -0
  42. data/sig/generated/zizq/job_config.rbs +150 -0
  43. data/sig/generated/zizq/lifecycle.rbs +34 -0
  44. data/sig/generated/zizq/middleware.rbs +50 -0
  45. data/sig/generated/zizq/query.rbs +327 -0
  46. data/sig/generated/zizq/resources/error_enumerator.rbs +148 -0
  47. data/sig/generated/zizq/resources/error_page.rbs +13 -0
  48. data/sig/generated/zizq/resources/error_record.rbs +20 -0
  49. data/sig/generated/zizq/resources/job.rbs +89 -0
  50. data/sig/generated/zizq/resources/job_page.rbs +33 -0
  51. data/sig/generated/zizq/resources/page.rbs +47 -0
  52. data/sig/generated/zizq/resources/resource.rbs +26 -0
  53. data/sig/generated/zizq/version.rbs +5 -0
  54. data/sig/generated/zizq/worker.rbs +152 -0
  55. data/sig/generated/zizq.rbs +180 -0
  56. data/sig/zizq.rbs +111 -0
  57. metadata +134 -0
@@ -0,0 +1,164 @@
1
+ # Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ # rbs_inline: enabled
5
+ # frozen_string_literal: true
6
+
7
+ require "logger"
8
+ require "openssl"
9
+
10
+ module Zizq
11
+ # Global configuration for the Zizq client.
12
+ #
13
+ # The configuration stores only client-level concerns: server URL,
14
+ # serialization format, and logger. Worker-specific settings (queues,
15
+ # threads, etc.) are passed directly to the Worker.
16
+ #
17
+ # See: [`Zizq::configure]`.
18
+ # See: [`Zizq::configuration]`.
19
+ class Configuration
20
+ # Base URL of the Zizq server (default: "http://localhost:7890").
21
+ attr_accessor :url #: String
22
+
23
+ # Choice of content-type encoding used in communication with the Zizq
24
+ # server.
25
+ #
26
+ # One of: `:json`, `:msgpack` (default)
27
+ attr_accessor :format #: Zizq::format
28
+
29
+ # Logger instance to which to write log messages.
30
+ attr_accessor :logger #: Logger
31
+
32
+ # TLS options for connecting to the server over HTTPS.
33
+ #
34
+ # All values may be PEM-encoded strings or file paths.
35
+ #
36
+ # {
37
+ # ca: "path/to/ca-cert.pem", # CA certificate for server verification
38
+ # client_cert: "path/to/client-cert.pem", # Client certificate for mTLS
39
+ # client_key: "path/to/client-key.pem", # Client private key for mTLS
40
+ # }
41
+ #
42
+ # Note: Mutual TLS support requires a Zizq Pro license on the server.
43
+ attr_accessor :tls #: Zizq::tls_options?
44
+
45
+ # Middleware chain for enqueue. Each middleware receives an
46
+ # `EnqueueRequest` and a chain to continue.
47
+ attr_reader :enqueue_middleware #: Middleware::Chain[EnqueueRequest, EnqueueRequest]
48
+
49
+ # Middleware chain for dequeue/dispatch. Each middleware receives
50
+ # a `Resources::Job` and a chain to continue.
51
+ attr_reader :dequeue_middleware #: Middleware::Chain[Resources::Job, void]
52
+
53
+ def initialize #: () -> void
54
+ @url = "http://localhost:7890"
55
+ @format = :msgpack
56
+ @logger = Logger.new($stdout, level: Logger::INFO)
57
+ @tls = nil
58
+ @enqueue_middleware = Middleware::Chain.new(Identity.new)
59
+ @dequeue_middleware = Middleware::Chain.new(Zizq::Job)
60
+ end
61
+
62
+ # The job dispatcher.
63
+ # This is the terminal of the dequeue middleware chain.
64
+ # Defaults to `Zizq::Job` which finds and executes jobs written by mixing
65
+ # in the `Zizq::Job` module.
66
+ def dispatcher #: () -> Zizq::dispatcher
67
+ @dequeue_middleware.terminal
68
+ end
69
+
70
+ # Set the dispatcher to a custom dispatcher implementation.
71
+ #
72
+ # A dispatcher is any object that responds to `#call` with a
73
+ # `Zizq::Resources::Job` instance and performs that job through some
74
+ # application-specific logic.
75
+ #
76
+ # This is the terminal of the dequeue middleware chain.
77
+ #
78
+ # Any errors raised by the dispatcher will result in the normal
79
+ # backoff/retry behaviour. Jobs are acknowledged automatically on success.
80
+ def dispatcher=(dispatcher) #: (Zizq::dispatcher) -> void
81
+ @dequeue_middleware.terminal = dispatcher
82
+ end
83
+
84
+ # Validates that required configuration is present.
85
+ def validate! #: () -> void
86
+ raise ArgumentError, "Zizq.configure: url is required" if url.empty?
87
+
88
+ unless %i[msgpack json].include?(format)
89
+ raise ArgumentError, "Zizq.configure: format must be :msgpack or :json, got #{format.inspect}"
90
+ end
91
+
92
+ tls = @tls
93
+ validate_tls!(tls) if tls
94
+ end
95
+
96
+ # @private
97
+ # Build an OpenSSL::SSL::SSLContext from the TLS options, or nil if
98
+ # no TLS options are configured.
99
+ def ssl_context #: () -> OpenSSL::SSL::SSLContext?
100
+ tls = @tls
101
+ return nil unless tls
102
+
103
+ ctx = OpenSSL::SSL::SSLContext.new
104
+
105
+ if (ca = tls[:ca])
106
+ store = OpenSSL::X509::Store.new
107
+ store.add_cert(load_cert(ca))
108
+ ctx.cert_store = store
109
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
110
+ end
111
+
112
+ if (client_cert = tls[:client_cert])
113
+ ctx.cert = load_cert(client_cert)
114
+ end
115
+
116
+ if (client_key = tls[:client_key])
117
+ ctx.key = load_key(client_key)
118
+ end
119
+
120
+ ctx
121
+ end
122
+
123
+ # @private
124
+ # Identity terminal — returns the argument unchanged.
125
+ class Identity
126
+ def call(arg) #: (untyped) -> untyped
127
+ arg
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ # @rbs tls: Zizq::tls_options
134
+ def validate_tls!(tls) #: (Zizq::tls_options) -> void
135
+ if tls[:client_cert] && !tls[:client_key]
136
+ raise ArgumentError, "Zizq.configure: tls[:client_key] is required when tls[:client_cert] is set"
137
+ end
138
+
139
+ if tls[:client_key] && !tls[:client_cert]
140
+ raise ArgumentError, "Zizq.configure: tls[:client_cert] is required when tls[:client_key] is set"
141
+ end
142
+ end
143
+
144
+ # Load a certificate from a PEM string or file path.
145
+ def load_cert(pem_or_path) #: (String) -> OpenSSL::X509::Certificate
146
+ OpenSSL::X509::Certificate.new(resolve_pem(pem_or_path))
147
+ end
148
+
149
+ # Load a private key from a PEM string or file path.
150
+ def load_key(pem_or_path) #: (String) -> OpenSSL::PKey::PKey
151
+ OpenSSL::PKey.read(resolve_pem(pem_or_path))
152
+ end
153
+
154
+ # If the value looks like PEM data, return it as-is; otherwise treat
155
+ # it as a file path and read the contents.
156
+ def resolve_pem(value) #: (String) -> String
157
+ if value.include?("-----BEGIN ")
158
+ value
159
+ else
160
+ File.read(value)
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,178 @@
1
+ # Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ # rbs_inline: enabled
5
+ # frozen_string_literal: true
6
+
7
+ module Zizq
8
+ # Represents a job enqueue request.
9
+ #
10
+ # Contains all the information needed to enqueue a job. Built by
11
+ # `Job::ClassMethods#zizq_enqueue_options` or directly for raw enqueues.
12
+ # Mutable — callers can override values via the block form of
13
+ # `Zizq.enqueue`.
14
+ #
15
+ # Zizq.enqueue(MyJob, 42) { |req| req.priority = 0 }
16
+ #
17
+ class EnqueueRequest
18
+ # Job type string (e.g. class name).
19
+ attr_accessor :type #: String
20
+
21
+ # Target queue name.
22
+ attr_accessor :queue #: String
23
+
24
+ # Job payload (serialized arguments).
25
+ attr_accessor :payload #: untyped
26
+
27
+ # Job priority (lower = higher priority).
28
+ attr_accessor :priority #: Integer?
29
+
30
+ # Delay before the job becomes ready (seconds).
31
+ attr_accessor :delay #: Zizq::to_f?
32
+
33
+ # Absolute time when the job becomes ready (fractional seconds since epoch).
34
+ attr_accessor :ready_at #: Zizq::to_f?
35
+
36
+ # Maximum number of retries before the job is killed.
37
+ attr_accessor :retry_limit #: Integer?
38
+
39
+ # Backoff configuration (in seconds).
40
+ attr_accessor :backoff #: Zizq::backoff?
41
+
42
+ # Retention configuration (in seconds).
43
+ attr_accessor :retention #: Zizq::retention?
44
+
45
+ # Unique key for deduplication.
46
+ attr_accessor :unique_key #: String?
47
+
48
+ # Uniqueness scope.
49
+ attr_accessor :unique_while #: Zizq::unique_scope?
50
+
51
+ # @rbs type: String
52
+ # @rbs queue: String
53
+ # @rbs payload: untyped
54
+ # @rbs priority: Integer?
55
+ # @rbs delay: Zizq::to_f?
56
+ # @rbs ready_at: Zizq::to_f?
57
+ # @rbs retry_limit: Integer?
58
+ # @rbs backoff: Zizq::backoff?
59
+ # @rbs retention: Zizq::retention?
60
+ # @rbs unique_key: String?
61
+ # @rbs unique_while: Zizq::unique_scope?
62
+ # @rbs return: void
63
+ def initialize(type:,
64
+ queue:,
65
+ payload:,
66
+ priority: nil,
67
+ delay: nil,
68
+ ready_at: nil,
69
+ retry_limit: nil,
70
+ backoff: nil,
71
+ retention: nil,
72
+ unique_key: nil,
73
+ unique_while: nil)
74
+ update(
75
+ type:,
76
+ queue:,
77
+ payload:,
78
+ priority:,
79
+ delay:,
80
+ ready_at:,
81
+ retry_limit:,
82
+ backoff:,
83
+ retention:,
84
+ unique_key:,
85
+ unique_while:,
86
+ )
87
+ end
88
+
89
+ # Update one or more fields in place.
90
+ #
91
+ # Each keyword argument defaults to the current field value, so
92
+ # callers only need to name the fields they want to change. Returns
93
+ # `self` for chaining. Unknown keys raise `ArgumentError` — this is
94
+ # the signal that prevents typos like `:retries` from silently
95
+ # doing nothing.
96
+ #
97
+ # req.update(priority: 0, ready_at: Time.now + 60)
98
+ #
99
+ # Used by `Zizq::EnqueueWith` to apply scoped overrides, and can be
100
+ # called directly from enqueue blocks as an alternative to assigning
101
+ # individual attributes.
102
+ #
103
+ # @rbs type: String
104
+ # @rbs queue: String
105
+ # @rbs payload: untyped
106
+ # @rbs priority: Integer?
107
+ # @rbs delay: Zizq::to_f?
108
+ # @rbs ready_at: Zizq::to_f?
109
+ # @rbs retry_limit: Integer?
110
+ # @rbs backoff: Zizq::backoff?
111
+ # @rbs retention: Zizq::retention?
112
+ # @rbs unique_key: String?
113
+ # @rbs unique_while: Zizq::unique_scope?
114
+ # @rbs return: self
115
+ def update(type: @type,
116
+ queue: @queue,
117
+ payload: @payload,
118
+ priority: @priority,
119
+ delay: @delay,
120
+ ready_at: @ready_at,
121
+ retry_limit: @retry_limit,
122
+ backoff: @backoff,
123
+ retention: @retention,
124
+ unique_key: @unique_key,
125
+ unique_while: @unique_while)
126
+ @type = type
127
+ @queue = queue
128
+ @payload = payload
129
+ @priority = priority
130
+ @delay = delay
131
+ @ready_at = ready_at
132
+ @retry_limit = retry_limit
133
+ @backoff = backoff
134
+ @retention = retention
135
+ @unique_key = unique_key
136
+ @unique_while = unique_while
137
+ self
138
+ end
139
+
140
+ # Convert to the params expected by `Client#enqueue`.
141
+ #
142
+ # Handles seconds -> milliseconds conversion for time-based fields,
143
+ # delay -> ready_at resolution, and nil omission.
144
+ def to_enqueue_params #: () -> Hash[Symbol, untyped]
145
+ params = { queue:, type:, payload: } #: Hash[Symbol, untyped]
146
+ params[:priority] = priority if priority
147
+
148
+ effective_ready_at = if delay
149
+ Time.now.to_f + delay.to_f
150
+ else
151
+ ready_at
152
+ end
153
+ params[:ready_at] = effective_ready_at if effective_ready_at
154
+
155
+ params[:retry_limit] = retry_limit if retry_limit
156
+
157
+ if backoff
158
+ params[:backoff] = {
159
+ exponent: backoff[:exponent].to_f,
160
+ base_ms: (backoff[:base].to_f * 1000).to_f,
161
+ jitter_ms: (backoff[:jitter].to_f * 1000).to_f
162
+ }
163
+ end
164
+
165
+ if retention
166
+ ret = {} #: Hash[Symbol, Integer]
167
+ ret[:completed_ms] = (retention[:completed].to_f * 1000).to_i if retention[:completed]
168
+ ret[:dead_ms] = (retention[:dead].to_f * 1000).to_i if retention[:dead]
169
+ params[:retention] = ret
170
+ end
171
+
172
+ params[:unique_key] = unique_key if unique_key
173
+ params[:unique_while] = unique_while.to_s if unique_while
174
+
175
+ params
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,109 @@
1
+ # Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ # rbs_inline: enabled
5
+ # frozen_string_literal: true
6
+
7
+ module Zizq
8
+ # A scoped enqueue helper that applies a set of option overrides to every
9
+ # enqueue routed through it.
10
+ #
11
+ # This is sugar for the block form of `Zizq.enqueue`. The two forms below
12
+ # are equivalent:
13
+ #
14
+ # Zizq.enqueue(SendEmailJob, 42) { |req| req.ready_at = Time.now + 3600 }
15
+ # Zizq.enqueue_with(ready_at: Time.now + 3600).enqueue(SendEmailJob, 42)
16
+ #
17
+ # Chainable: successive `enqueue_with` calls merge, with later keys
18
+ # winning:
19
+ #
20
+ # Zizq.enqueue_with(queue: "hi").enqueue_with(priority: 0).enqueue(MyJob)
21
+ #
22
+ # This works inside a bulk block too, applying the overrides to just that one
23
+ # enqueue:
24
+ #
25
+ # Zizq.enqueue_bulk do |b|
26
+ # b.enqueue(MyJob, 1)
27
+ # b.enqueue_with(ready_at: Time.now + 3600).enqueue(OtherJob, 42)
28
+ # end
29
+ #
30
+ # Also wraps a whole bulk block when used at the top level, applying the
31
+ # overrides to every job in the batch:
32
+ #
33
+ # Zizq.enqueue_with(priority: 0).enqueue_bulk do |b|
34
+ # b.enqueue(MyJob, 1)
35
+ # b.enqueue(MyJob, 2)
36
+ # end
37
+ #
38
+ # A user block is still allowed and runs *after* the overrides, so it can
39
+ # override them further for that one call:
40
+ #
41
+ # Zizq.enqueue_with(priority: 100).enqueue(MyJob) { |req| req.priority = 0 }
42
+ #
43
+ # Instances are immutable — `enqueue_with` returns a new instance. Safe
44
+ # to stash and reuse:
45
+ #
46
+ # high_priority = Zizq.enqueue_with(queue: "hi", priority: 0)
47
+ # high_priority.enqueue(MyJob, 1)
48
+ # high_priority.enqueue(OtherJob, 2)
49
+ #
50
+ class EnqueueWith
51
+ # @rbs target: Zizq::enqueue_target
52
+ # @rbs overrides: Hash[Symbol, untyped]
53
+ # @rbs return: void
54
+ def initialize(target, overrides)
55
+ @target = target
56
+ @overrides = overrides.freeze
57
+ end
58
+
59
+ # Merge additional overrides into this scope, returning a new instance.
60
+ # Later keys win.
61
+ #
62
+ # @rbs overrides: Hash[Symbol, untyped]
63
+ # @rbs return: EnqueueWith
64
+ def enqueue_with(**overrides)
65
+ self.class.new(@target, @overrides.merge(overrides))
66
+ end
67
+
68
+ # Enqueue a job class via the underlying target, applying the scoped
69
+ # overrides before invoking any caller-supplied block.
70
+ #
71
+ # @rbs job_class: Class & Zizq::job_class
72
+ # @rbs args: Array[untyped]
73
+ # @rbs kwargs: Hash[Symbol, untyped]
74
+ # @rbs &block: ?(EnqueueRequest) -> void
75
+ # @rbs return: untyped
76
+ def enqueue(job_class, *args, **kwargs, &block)
77
+ @target.enqueue(job_class, *args, **kwargs) do |req|
78
+ req.update(**@overrides)
79
+ block&.call(req)
80
+ end
81
+ end
82
+
83
+ # Enqueue a raw request via the underlying target, with overrides
84
+ # merged into the kwargs (explicit kwargs take precedence).
85
+ #
86
+ # @rbs queue: String
87
+ # @rbs type: String
88
+ # @rbs payload: untyped
89
+ # @rbs opts: Hash[Symbol, untyped]
90
+ # @rbs return: untyped
91
+ def enqueue_raw(queue:, type:, payload:, **opts)
92
+ @target.enqueue_raw(queue:, type:, payload:, **@overrides.merge(opts))
93
+ end
94
+
95
+ # Wrap a bulk block so that every enqueue inside it inherits the
96
+ # scoped overrides. Works uniformly against both the top-level
97
+ # `Zizq` module (starts a new bulk batch) and a `BulkEnqueue`
98
+ # instance (appends to the existing batch), because `BulkEnqueue`
99
+ # implements `enqueue_bulk` as a no-op that yields itself.
100
+ #
101
+ # @rbs &block: (EnqueueWith) -> void
102
+ # @rbs return: untyped
103
+ def enqueue_bulk(&block)
104
+ @target.enqueue_bulk do |b|
105
+ block.call(self.class.new(b, @overrides))
106
+ end
107
+ end
108
+ end
109
+ end
data/lib/zizq/error.rb ADDED
@@ -0,0 +1,43 @@
1
+ # Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ # rbs_inline: enabled
5
+ # frozen_string_literal: true
6
+
7
+ module Zizq
8
+ # Base error class for all Zizq errors.
9
+ class Error < StandardError; end
10
+
11
+ # Network-level failure (connection refused, DNS, timeout etc).
12
+ class ConnectionError < Error; end
13
+
14
+ # HTTP error — the server returned a non-success status code.
15
+ # Carries the status code and parsed body.
16
+ class ResponseError < Error
17
+ # The HTTP response status from the Zizq server.
18
+ attr_reader :status #: Integer
19
+
20
+ # The decoded body of the error response.
21
+ attr_reader :body #: Hash[String, untyped]?
22
+
23
+ # Create a new ResponseError with the given error message, response status
24
+ # and decoded response body.
25
+ def initialize(message, status:, body: nil) #: (String, status: Integer, ?body: Hash[String, untyped]?) -> void
26
+ @status = status
27
+ @body = body
28
+ super(message)
29
+ end
30
+ end
31
+
32
+ # 4xx client error.
33
+ class ClientError < ResponseError; end
34
+
35
+ # 404 specifically — job not found, etc.
36
+ class NotFoundError < ClientError; end
37
+
38
+ # 5xx server error.
39
+ class ServerError < ResponseError; end
40
+
41
+ # Streaming take-jobs connection interrupted.
42
+ class StreamError < Error; end
43
+ end
data/lib/zizq/job.rb ADDED
@@ -0,0 +1,188 @@
1
+ # Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ # rbs_inline: enabled
5
+ # frozen_string_literal: true
6
+
7
+ require_relative "job_config"
8
+
9
+ module Zizq
10
+ # Mixin which all valid job classes must include.
11
+ #
12
+ # This module must be included in a class to make it a valid Zizq job. The
13
+ # class name becomes the job type, and the worker resolves types back to
14
+ # classes via `Object.const_get` (which naturally triggers any autoload
15
+ # logic).
16
+ #
17
+ # class SendEmailJob
18
+ # include Zizq::Job
19
+ #
20
+ # zizq_queue "emails" # optional, defaults to "default"
21
+ #
22
+ # def perform(user_id, template:)
23
+ # puts "Sending #{template} email to user #{user_id}"
24
+ # end
25
+ # end
26
+ #
27
+ # The job can be configured through class methods to set the queue, priority
28
+ # etc. Classes can also override `::zizq_enqueue_options` to implement
29
+ # dynamically configured jobs based on their arguments.
30
+ module Job
31
+ def self.included(base) #: (Class) -> void
32
+ base.extend(ClassMethods)
33
+ end
34
+
35
+ # Default dispatcher for Zizq jobs.
36
+ #
37
+ # Resolves the job class from the type string, deserializes the
38
+ # payload, and calls `#perform`. Any object that responds to
39
+ # `#call(job)` can replace this as a custom dispatcher via
40
+ # `Zizq.configure { |c| c.dispatcher = MyDispatcher.new }`.
41
+ #
42
+ # The contract is simple: return normally → ack, raise → nack.
43
+ #
44
+ # @rbs job: Resources::Job
45
+ # @rbs return: void
46
+ def self.call(job)
47
+ job_class = Object.const_get(job.type)
48
+
49
+ unless job_class.is_a?(Class) && job_class.include?(Zizq::Job)
50
+ raise "#{job.type} does not include Zizq::Job"
51
+ end
52
+
53
+ zizq_job_class = job_class #: Zizq::job_class
54
+ instance = zizq_job_class.new
55
+ instance.set_zizq_job(job)
56
+
57
+ args, kwargs = zizq_job_class.zizq_deserialize(
58
+ job.payload || { "args" => [], "kwargs" => {} }
59
+ )
60
+
61
+ instance.perform(*args, **kwargs)
62
+ end
63
+
64
+ module ClassMethods
65
+ include JobConfig
66
+
67
+ # Serialize positional and keyword arguments for the `#perform` method
68
+ # into a payload hash suitable for sending to the server.
69
+ #
70
+ # The result must be a JSON encodable Hash.
71
+ #
72
+ # The default implementation generates a hash of the form:
73
+ #
74
+ # { "args" => [ 42, "Hello" ], "kwargs" => { "template": "example" } }
75
+ #
76
+ # If you override this method you almost certainly need to override
77
+ # `::zizq_deserialize`, `::zizq_payload_filter` and
78
+ # `::zizq_payload_subset_filter` too.
79
+ #
80
+ # Any failure to deserialize the arguments will cause the job to fail and
81
+ # backoff according to the backoff policy.
82
+ def zizq_serialize(*args, **kwargs) #: (*untyped, **untyped) -> Hash[String, untyped]
83
+ { "args" => args, "kwargs" => kwargs.transform_keys(&:to_s) }
84
+ end
85
+
86
+ # Deserialize a payload hash back into positional and keyword arguments.
87
+ #
88
+ # The payload is a JSON decoded Hash.
89
+ #
90
+ # The default implementation receives a Hash of the form:
91
+ #
92
+ # { "args" => [ 42, "Hello" ], "kwargs" => { "template": "example" } }
93
+ #
94
+ # And returns an array for `args` and `kwargs` of the form:
95
+ #
96
+ # [ [ 42, "Hello" ], {template: "example"} ]
97
+ #
98
+ # Because the default implementation uses a JSON decoded Hash, any symbol
99
+ # keys that were present at enqueue-time will be string keys after
100
+ # decoding.
101
+ #
102
+ # Any failure to deserialize the arguments will cause the job to fail and
103
+ # backoff according to the backoff policy.
104
+ def zizq_deserialize(payload) #: (Hash[String, untyped]) -> [Array[untyped], Hash[Symbol, untyped]]
105
+ args = payload.fetch("args")
106
+ kwargs = payload.fetch("kwargs").transform_keys(&:to_sym)
107
+ [args, kwargs]
108
+ end
109
+
110
+ # Generate a jq expression that exactly matches payloads with the given
111
+ # arguments.
112
+ #
113
+ # This is used for filtering in Zizq::Query.
114
+ #
115
+ # Generates an expression of the form:
116
+ #
117
+ # . == {"args":["a","b","c"],"kwargs":{"example":true,"other":false}}
118
+ def zizq_payload_filter(*args, **kwargs) #: (*untyped, **untyped) -> String
119
+ payload = zizq_serialize(*args, **kwargs)
120
+ ". == #{JSON.generate(payload)}"
121
+ end
122
+
123
+ # Generate a jq expression that matches jobs whose positional args
124
+ # start with the given values and whose kwargs contain the given
125
+ # key/value pairs.
126
+ #
127
+ # This is used for filtering in Zizq::Query.
128
+ #
129
+ # Generates expressions of the form:
130
+ #
131
+ # (.args[0:2] == ["a","b"]) and (.kwargs | contains({"example":true}))
132
+ def zizq_payload_subset_filter(*args, **kwargs) #: (*untyped, **untyped) -> String
133
+ payload = zizq_serialize(*args, **kwargs)
134
+ serialized_args = payload.fetch("args")
135
+ serialized_kwargs = payload.fetch("kwargs")
136
+
137
+ [
138
+ "(.args[0:#{serialized_args.size}] == #{JSON.generate(serialized_args)})",
139
+ "(.kwargs | contains(#{JSON.generate(serialized_kwargs)}))"
140
+ ].join(" and ")
141
+ end
142
+ end
143
+
144
+ # This is your job's main entrypoint when it is run by the worker.
145
+ #
146
+ # Override this method in your job class to define the work to perform.
147
+ # Declare any positional and keyword arguments your job needs.
148
+ #
149
+ # Strong recommendation: stick to keyword arguments because they are much
150
+ # easier to evolve over time in a backwards compatible way with any already
151
+ # enqueued jobs.
152
+ def perform(*args, **kwargs) #: (*untyped, **untyped) -> void
153
+ raise NotImplementedError, "#{self.class.name}#perform must be implemented"
154
+ end
155
+
156
+ # --- Metadata helpers ---
157
+ #
158
+ # These delegate to the Resources::Job instance set by the worker
159
+ # before calling #perform, giving the job access to its server-side
160
+ # metadata.
161
+
162
+ # The unique job ID assigned by the server.
163
+ def zizq_id = @zizq_job&.id #: () -> String?
164
+
165
+ # How many times this job has previously been attempted (0 on the first
166
+ # run, 1 on the second, etc...).
167
+ def zizq_attempts = @zizq_job&.attempts #: () -> Integer?
168
+
169
+ # The queue this job was dequeued from.
170
+ def zizq_queue = @zizq_job&.queue #: () -> String?
171
+
172
+ # The priority this job was enqueued with.
173
+ def zizq_priority = @zizq_job&.priority #: () -> Integer?
174
+
175
+ # Time at which this job was dequeued (fractional seconds since the Unix
176
+ # epoch). This can be converted to `Time` by using `Time.at(dequeued_at)`
177
+ # but that is intentionally left to the caller due to time zone
178
+ # considerations.
179
+ def zizq_dequeued_at = @zizq_job&.dequeued_at #: () -> Float?
180
+
181
+ # @api private
182
+ # Set by the worker before calling #perform. Receives the full
183
+ # Resources::Job object so all metadata is available through delegation.
184
+ def set_zizq_job(job) #: (Resources::Job) -> void
185
+ @zizq_job = job
186
+ end
187
+ end
188
+ end