zizq 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/bin/profile-worker +145 -0
- data/bin/zizq-worker +174 -0
- data/lib/active_job/queue_adapters/zizq_adapter.rb +109 -0
- data/lib/zizq/ack_processor.rb +132 -0
- data/lib/zizq/active_job_config.rb +122 -0
- data/lib/zizq/backoff.rb +50 -0
- data/lib/zizq/bulk_enqueue.rb +87 -0
- data/lib/zizq/client.rb +982 -0
- data/lib/zizq/configuration.rb +164 -0
- data/lib/zizq/enqueue_request.rb +178 -0
- data/lib/zizq/enqueue_with.rb +109 -0
- data/lib/zizq/error.rb +43 -0
- data/lib/zizq/job.rb +188 -0
- data/lib/zizq/job_config.rb +244 -0
- data/lib/zizq/lifecycle.rb +58 -0
- data/lib/zizq/middleware.rb +79 -0
- data/lib/zizq/query.rb +566 -0
- data/lib/zizq/resources/error_enumerator.rb +241 -0
- data/lib/zizq/resources/error_page.rb +19 -0
- data/lib/zizq/resources/error_record.rb +19 -0
- data/lib/zizq/resources/job.rb +124 -0
- data/lib/zizq/resources/job_page.rb +57 -0
- data/lib/zizq/resources/page.rb +77 -0
- data/lib/zizq/resources/resource.rb +45 -0
- data/lib/zizq/resources.rb +16 -0
- data/lib/zizq/version.rb +9 -0
- data/lib/zizq/worker.rb +467 -0
- data/lib/zizq.rb +269 -0
- data/sig/generated/zizq/ack_processor.rbs +73 -0
- data/sig/generated/zizq/active_job_config.rbs +74 -0
- data/sig/generated/zizq/backoff.rbs +34 -0
- data/sig/generated/zizq/bulk_enqueue.rbs +72 -0
- data/sig/generated/zizq/client.rbs +419 -0
- data/sig/generated/zizq/configuration.rbs +95 -0
- data/sig/generated/zizq/enqueue_request.rbs +94 -0
- data/sig/generated/zizq/enqueue_with.rbs +88 -0
- data/sig/generated/zizq/error.rbs +41 -0
- data/sig/generated/zizq/job.rbs +136 -0
- data/sig/generated/zizq/job_config.rbs +150 -0
- data/sig/generated/zizq/lifecycle.rbs +34 -0
- data/sig/generated/zizq/middleware.rbs +50 -0
- data/sig/generated/zizq/query.rbs +327 -0
- data/sig/generated/zizq/resources/error_enumerator.rbs +148 -0
- data/sig/generated/zizq/resources/error_page.rbs +13 -0
- data/sig/generated/zizq/resources/error_record.rbs +20 -0
- data/sig/generated/zizq/resources/job.rbs +89 -0
- data/sig/generated/zizq/resources/job_page.rbs +33 -0
- data/sig/generated/zizq/resources/page.rbs +47 -0
- data/sig/generated/zizq/resources/resource.rbs +26 -0
- data/sig/generated/zizq/version.rbs +5 -0
- data/sig/generated/zizq/worker.rbs +152 -0
- data/sig/generated/zizq.rbs +180 -0
- data/sig/zizq.rbs +111 -0
- metadata +134 -0
|
@@ -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
|