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,244 @@
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 "digest"
8
+ require "json"
9
+
10
+ module Zizq
11
+ # Shared class-level configuration DSL for Zizq job classes.
12
+ #
13
+ # This module provides the queue, priority, retry, backoff, retention,
14
+ # and uniqueness configuration methods. It is extended onto job classes
15
+ # by `Zizq::Job` and can also be used with ActiveJob via
16
+ # `Zizq::ActiveJobConfig`.
17
+ #
18
+ # Modules including this module must implement `zizq_serialize` and
19
+ # `zizq_deserialize` to define how job arguments are serialized for the API.
20
+ module JobConfig
21
+ # @rbs!
22
+ # # The class name where this is included (invisible to steep without this).
23
+ # def name: () -> String?
24
+
25
+ # Serialize positional and keyword arguments into a JSON-serializable
26
+ # payload.
27
+ #
28
+ # Implemented by the including module.
29
+ def zizq_serialize(*args, **kwargs) #: (*untyped, **untyped) -> untyped
30
+ raise NotImplementedError, "#{self} must implement zizq_serialize"
31
+ end
32
+
33
+ # Deserialize positional and keyword arguments from the serialized payload.
34
+ #
35
+ # Implemented by the including module.
36
+ def zizq_deserialize(payload) #: untyped -> [Array[untyped], Hash[Symbol, untyped]]
37
+ raise NotImplementedError, "#{self} must implement zizq_deserialize"
38
+ end
39
+
40
+ # Generate a jq expression that exactly matches payloads with the given
41
+ # arguments.
42
+ #
43
+ # Implemented by the including module.
44
+ def zizq_payload_filter(*args, **kwargs) #: (*untyped, **untyped) -> String
45
+ raise NotImplementedError, "#{self} must implement zizq_payload_filter"
46
+ end
47
+
48
+ # Generate a jq expression that matches a subset of the given arguments.
49
+ #
50
+ # Implemented by the including module.
51
+ def zizq_payload_subset_filter(*args, **kwargs) #: (*untyped, **untyped) -> String
52
+ raise NotImplementedError, "#{self} must implement zizq_payload_subset_filter"
53
+ end
54
+
55
+ # Declare the default queue for this job class.
56
+ #
57
+ # If not called, defaults to "default". Jobs enqueued for this class will
58
+ # use the specified queue unless explicitly overridden during
59
+ # [`Zizq::enqueue`] or by overriding `::zizq_enqueue_options` on the job
60
+ # class.
61
+ def zizq_queue(name = nil) #: (?String?) -> String
62
+ if name
63
+ @zizq_queue = name
64
+ else
65
+ @zizq_queue || "default"
66
+ end
67
+ end
68
+
69
+ # Declare the default priority for this job class.
70
+ #
71
+ # If not called, defaults to the default priority on the Zizq server.
72
+ # Jobs enqueued for this class will use the specified priority unless
73
+ # explicitly overridden during [`Zizq::enqueue`] or by overriding
74
+ # `::zizq_enqueue_options` on the job class.
75
+ def zizq_priority(priority = nil) #: (?Integer?) -> Integer?
76
+ if priority
77
+ @zizq_priority = priority
78
+ else
79
+ @zizq_priority
80
+ end
81
+ end
82
+
83
+ # Declare the default retry limit for this job class.
84
+ #
85
+ # The job may fail up to the number of times specified by the retry limit
86
+ # and will exponentially backoff. Once the retry limit is reached, the
87
+ # job is killed and becomes part of the dead set.
88
+ #
89
+ # If not configured, the server's default is used.
90
+ def zizq_retry_limit(limit = nil) #: (?Integer?) -> Integer?
91
+ if limit
92
+ @zizq_retry_limit = limit
93
+ else
94
+ @zizq_retry_limit
95
+ end
96
+ end
97
+
98
+ # Declare the default backoff configuration for this job class.
99
+ #
100
+ # Times are specified in seconds (optionally fractional).
101
+ # In a Rails app `ActiveSupport::Duration` is supported too.
102
+ #
103
+ # All three parameters must be specified together and are used in the
104
+ # following exponential backoff formula:
105
+ #
106
+ # delay = base + attempts**exponent + rand(0.0..jitter)*attempts
107
+ #
108
+ # Example:
109
+ #
110
+ # zizq_backoff exponent: 4.0, base: 15, jitter: 30
111
+ #
112
+ # If not configured, the server's default backoff policy is used.
113
+ def zizq_backoff(exponent: nil, base: nil, jitter: nil) #: (?exponent: Numeric?, ?base: Numeric?, ?jitter: Numeric?) -> Zizq::backoff?
114
+ if exponent || base || jitter
115
+ unless exponent && base && jitter
116
+ raise ArgumentError, "all of exponent:, base:, jitter: are required"
117
+ end
118
+
119
+ @zizq_backoff = { exponent: exponent.to_f, base: base.to_f, jitter: jitter.to_f }
120
+ else
121
+ @zizq_backoff
122
+ end
123
+ end
124
+
125
+ # Declare the default retention configuration for this job class.
126
+ #
127
+ # Times are specified in seconds (optionally fractional).
128
+ # In a Rails app `ActiveSupport::Duration` is supported too.
129
+ #
130
+ # Both parameters are optional — only the ones provided will be sent
131
+ # to the server. Omitted values use the server's defaults.
132
+ #
133
+ # Example:
134
+ #
135
+ # zizq_retention completed: 0, dead: 7 * 86_400
136
+ #
137
+ # If not configured, the server's default is used.
138
+ def zizq_retention(completed: nil, dead: nil) #: (?completed: Numeric?, ?dead: Numeric?) -> Zizq::retention?
139
+ if completed || dead
140
+ result = {} #: Hash[Symbol, Float]
141
+
142
+ result[:completed] = completed.to_f if completed
143
+ result[:dead] = dead.to_f if dead
144
+
145
+ @zizq_retention = result
146
+ else
147
+ @zizq_retention
148
+ end
149
+ end
150
+
151
+ # Declare uniqueness for this job class.
152
+ #
153
+ # Requires a pro license.
154
+ #
155
+ # When enabled, duplicate jobs with the same unique key are rejected
156
+ # at enqueue time. The optional scope controls how long the
157
+ # uniqueness guard lasts:
158
+ #
159
+ # :queued — unique while "scheduled" or "ready" (server default)
160
+ # :active — unique while "scheduled", "ready", or "in_flight"
161
+ # :exists — unique until the job is reaped by the server
162
+ #
163
+ # Examples:
164
+ #
165
+ # zizq_unique true # unique, server default scope
166
+ # zizq_unique true, scope: :active # unique while active
167
+ # zizq_unique false # disable (e.g. in a subclass)
168
+ #
169
+ def zizq_unique(unique = nil, scope: nil) #: (?bool?, ?scope: Zizq::unique_scope?) -> bool
170
+ if unique.nil?
171
+ @zizq_unique || false
172
+ else
173
+ @zizq_unique = !!unique
174
+ @zizq_unique_scope = scope
175
+ @zizq_unique
176
+ end
177
+ end
178
+
179
+ # Declare or read the uniqueness scope for this job class.
180
+ #
181
+ # Usually set via `zizq_unique true, scope: :active` but can also
182
+ # be set independently.
183
+ def zizq_unique_scope(scope = nil) #: (?Zizq::unique_scope?) -> Zizq::unique_scope?
184
+ if scope
185
+ @zizq_unique_scope = scope
186
+ else
187
+ @zizq_unique_scope
188
+ end
189
+ end
190
+
191
+ # Compute the unique key for a job with the given arguments.
192
+ #
193
+ # The default implementation uses the class name and hashes the
194
+ # normalized serialized payload. Override this method to customize
195
+ # uniqueness — for example, to ignore certain arguments:
196
+ #
197
+ # def self.zizq_unique_key(user_id, template:)
198
+ # super(user_id) # unique per user, ignoring template
199
+ # end
200
+ def zizq_unique_key(*args, **kwargs) #: (*untyped, **untyped) -> String
201
+ payload = normalize_payload(zizq_serialize(*args, **kwargs))
202
+ "#{name}:#{Digest::SHA256.hexdigest(JSON.generate(payload))}"
203
+ end
204
+
205
+ # Build a `Zizq::EnqueueRequest` from the class-level job config.
206
+ #
207
+ # Subclasses can override this to implement dynamic logic such as
208
+ # priority based on arguments:
209
+ #
210
+ # def self.zizq_enqueue_request(user_id, template:)
211
+ # req = super
212
+ # req.priority = 0 if template == "urgent"
213
+ # req
214
+ # end
215
+ def zizq_enqueue_request(*args, **kwargs) #: (*untyped, **untyped) -> EnqueueRequest
216
+ EnqueueRequest.new(
217
+ type: name || raise(ArgumentError, "Cannot enqueue anonymous class"),
218
+ queue: zizq_queue,
219
+ payload: zizq_serialize(*args, **kwargs),
220
+ priority: zizq_priority,
221
+ retry_limit: zizq_retry_limit,
222
+ backoff: zizq_backoff,
223
+ retention: zizq_retention,
224
+ unique_while: zizq_unique ? zizq_unique_scope : nil,
225
+ unique_key: zizq_unique ? zizq_unique_key(*args, **kwargs) : nil
226
+ )
227
+ end
228
+
229
+ private
230
+
231
+ # Deep-sort all Hash keys so that serialization is deterministic
232
+ # regardless of insertion order or JSON library.
233
+ def normalize_payload(obj) #: (untyped) -> untyped
234
+ case obj
235
+ when Hash
236
+ obj.sort_by { |k, _| k.to_s }.map { |k, v| [k, normalize_payload(v)] }.to_h
237
+ when Array
238
+ obj.map { |v| normalize_payload(v) }
239
+ else
240
+ obj
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,58 @@
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
+ # Thread-safe state machine for coordinating worker shutdown.
9
+ #
10
+ # States:
11
+ # :running → normal operation
12
+ # :draining → stop accepting work, finish in-progress jobs
13
+ # :stopped → all work drained, safe to disconnect
14
+ #
15
+ # Transitions: running -> draining -> stopped (forward only).
16
+ #
17
+ # All transitions are signal-trap safe — they use only atomic symbol
18
+ # assignment and Queue#close for wakeups.
19
+ class Lifecycle
20
+ # @rbs return: void
21
+ def initialize
22
+ @state = :running #: :running | :draining | :stopped
23
+ @drain_latch = Thread::Queue.new
24
+ @stop_latch = Thread::Queue.new
25
+ end
26
+
27
+ # Non-blocking, lock-free check.
28
+ def running? #: () -> bool
29
+ @state == :running
30
+ end
31
+
32
+ # Transition to :draining.
33
+ def drain! #: () -> void
34
+ return unless @state == :running
35
+
36
+ @state = :draining
37
+ @drain_latch.close rescue nil
38
+ end
39
+
40
+ # Transition to :stopped.
41
+ def stop! #: () -> void
42
+ return if @state == :stopped
43
+
44
+ @state = :stopped
45
+ @stop_latch.close rescue nil
46
+ end
47
+
48
+ # Block until the state is no longer :running.
49
+ def wait_while_running #: () -> void
50
+ @drain_latch.pop
51
+ end
52
+
53
+ # Block until the state is :stopped.
54
+ def wait_until_stopped #: () -> void
55
+ @stop_latch.pop
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,79 @@
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
+ module Middleware
9
+ # A linked chain of middleware ending with a terminal.
10
+ #
11
+ # Each middleware must implement `#call(arg, chain)` where `chain` is
12
+ # the next link. The terminal implements `#call(arg)`.
13
+ #
14
+ # When no middleware is registered, `#call` delegates directly to the
15
+ # terminal with zero overhead.
16
+ #
17
+ # chain = Zizq::Middleware::Chain.new(dispatcher)
18
+ # chain.use(LoggingMiddleware.new)
19
+ # chain.use(MetricsMiddleware.new)
20
+ # chain.call(job)
21
+ # # MetricsMiddleware -> LoggingMiddleware -> dispatcher
22
+ #
23
+ # @rbs generic Arg -- the type flowing through the chain
24
+ # @rbs generic Ret -- the return type of the terminal
25
+ class Chain
26
+ # The terminal callable at the end of the chain.
27
+ attr_reader :terminal #: untyped
28
+
29
+ def initialize(terminal) #: (untyped) -> void
30
+ @terminal = terminal
31
+ @entries = [] #: Array[untyped]
32
+ @built = nil #: untyped?
33
+ end
34
+
35
+ # Replace the terminal, invalidating any built chain.
36
+ def terminal=(terminal) #: (untyped) -> void
37
+ @terminal = terminal
38
+ @built = nil
39
+ end
40
+
41
+ # Append a middleware to the chain.
42
+ def use(middleware) #: (untyped) -> void
43
+ @entries << middleware
44
+ @built = nil
45
+ end
46
+
47
+ # Execute the chain with the given argument.
48
+ def call(arg) #: (Arg) -> Ret
49
+ build.call(arg)
50
+ end
51
+
52
+ private
53
+
54
+ def build #: () -> untyped
55
+ @built ||= if @entries.empty?
56
+ @terminal
57
+ else
58
+ @entries.reverse.reduce(@terminal) do |next_link, mw|
59
+ Link.new(mw, next_link)
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ # A single link in the middleware chain, connecting a middleware to
66
+ # the next link (or terminal).
67
+ class Link
68
+ def initialize(middleware, next_link) #: (untyped, untyped) -> void
69
+ @middleware = middleware
70
+ @next_link = next_link
71
+ end
72
+
73
+ # Invoke this middleware, passing the next link for continuation.
74
+ def call(arg) #: (untyped) -> untyped
75
+ @middleware.call(arg, @next_link)
76
+ end
77
+ end
78
+ end
79
+ end