zizq 0.3.4 → 0.3.6

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: 99a09e939c3261e843c176cd936a29e4e6f6a725ac802d93273a2608692b9616
4
- data.tar.gz: ef6a5a3af87980b71a68a10a16969c29f572ef55d4b628de89bde210f0949f69
3
+ metadata.gz: a95c96d7b844064db29aea81935d6ec6e3c6953fc47459fcfd6253b6b44aa1e7
4
+ data.tar.gz: 19a8e38ef04716ba02b21bb4ce1f8f2b3e597b1f9845ada71dde5ae7d31fecd3
5
5
  SHA512:
6
- metadata.gz: 4eae14c105f51ca5d3b8f8fbf51e635bee289d81a2d81603ba47ee85c29493daa67eee2d3fa07246ba91a2d98381f9e2f7db46ad23e13d191228df9c48043fd7
7
- data.tar.gz: 78382c2d8062b3b51a76fd44760b9116fe71b5f273b5d3ec8d458e6e8609fd6d8a22f5f54717920dc6197393f5ba56e05298ccf51fd5a5beefc1d52ee838a65d
6
+ metadata.gz: d4158e9e2b3f58e2c1b823b26f8c81a9ee5ab347d72683377909fed7b4d0d163caf3e27708d3e1f3cbd1af3018068fda34d97405ec6095732b0cda57909a82fc
7
+ data.tar.gz: 95c1453a0af6a82e8ad0abd5e2a8085550ce4cf5ef6b2871cb653fdd1068994f69b36bb04a5e30d2ec76c1ccc02a1e142621a5c5bde7de9bab84517917b90fef
data/README.md CHANGED
@@ -12,7 +12,7 @@ API.
12
12
  ## Features
13
13
 
14
14
  * Multi-thread and/or multi-fiber concurrent worker (via [`async`](https://github.com/socketry/async))
15
- * `Zizq::Job` based job classes, Active Job support, or completely custom
15
+ * `Zizq::Job` based job classes, Active Job support, or low-level/custom
16
16
  * Enqueue and process jobs from one language to another
17
17
  * Arbitrary named queues
18
18
  * Granular job priorities
@@ -22,6 +22,7 @@ API.
22
22
  * Recurring jobs (cron)
23
23
  * Job introspection and management APIs, with support for `jq` query filters
24
24
  * Unique jobs
25
+ * Testing helpers
25
26
 
26
27
  ## Installation
27
28
 
@@ -32,13 +33,13 @@ API.
32
33
  Add it to your application's `Gemfile`:
33
34
 
34
35
  ```ruby
35
- gem 'zizq', '~> 0.3.4'
36
+ gem 'zizq', '~> 0.3.6'
36
37
  ```
37
38
 
38
39
  Or install it manually:
39
40
 
40
41
  ```shell
41
- $ gem install zizq -v 0.3.4
42
+ $ gem install zizq -v 0.3.6
42
43
  ```
43
44
 
44
45
  Ruby **3.2.8 or newer** is required. Client and server share version
@@ -62,7 +63,7 @@ Zizq.configure do |c|
62
63
  # Optional worker defaults — applied to every Zizq::Worker
63
64
  # instance and to runs of the `zizq-worker` executable. Explicit
64
65
  # kwargs or CLI flags override these.
65
- c.worker.queues = ['emails', 'payments']
66
+ c.worker.queues = ['emails', 'payments']
66
67
  c.worker.fiber_count = 25
67
68
  end
68
69
  ```
@@ -148,10 +149,42 @@ Zizq.enqueue_bulk do |b|
148
149
  end
149
150
  ```
150
151
 
151
- > [!NOTE]
152
- > Jobs can also be enqueued without `Zizq::Job` via `Zizq.enqueue_raw` —
153
- > designed for cross-language workflows where, for example, a Ruby app
154
- > enqueues jobs consumed by a Go service.
152
+ Jobs can also be enqueued without `Zizq::Job` via `Zizq.enqueue_raw` —
153
+ designed for lower-level code style, and for cross-language workflows where,
154
+ for example, a Ruby app enqueues jobs consumed by a Go service.
155
+
156
+ ```ruby
157
+ Zizq.enqueue_raw(
158
+ type: "send_email",
159
+ queue: "comms",
160
+ payload: { user_id: 42, template: "welcome" }
161
+ )
162
+ ```
163
+
164
+ ### Cross-language and low-level dispatch { #router }
165
+
166
+ When a Ruby app needs to *process* jobs enqueued by another language
167
+ (or by `Zizq.enqueue_raw`), `Zizq::Router` maps `type` strings to
168
+ handler blocks operating on plain JSON payloads:
169
+
170
+ ```ruby
171
+ Zizq.configure do |c|
172
+ c.dispatcher = Zizq::Router.new do
173
+ route('send_email') do |payload|
174
+ Mailer.deliver(payload['user_id'], payload['template'])
175
+ end
176
+
177
+ # Apps that mix the two styles can fall back to Zizq::Job
178
+ # for anything not handled by an explicit route.
179
+ fallback { |job| Zizq::Job.call(job) }
180
+ end
181
+ end
182
+ ```
183
+
184
+ See [Custom Dispatchers](https://zizq.io/docs/clients/ruby/dispatchers.html)
185
+ for full details. Dispatchers in Zizq are just objects that implement `#call`
186
+ with a single `Zizq::Resources::Job` argument, and `Zizq::Router` is just a
187
+ dispatcher itself.
155
188
 
156
189
  ### Running a worker
157
190
 
@@ -221,6 +254,38 @@ Once defined, schedules can be inspected and managed via
221
254
  `Zizq.crontab('maintenance')` — paused/resumed at the schedule level or per
222
255
  entry, and deleted entirely when no longer needed.
223
256
 
257
+ ### Testing
258
+
259
+ Set `c.test_mode = true` in your test helper and Zizq swaps the real
260
+ client out for an in-memory `Zizq::Test::Client` that buffers enqueues
261
+ instead of dispatching them. Tests can then assert on what was
262
+ enqueued and drain the buffer through the configured dispatcher —
263
+ no running server required.
264
+
265
+ ```ruby
266
+ # test/test_helper.rb (or spec/spec_helper.rb)
267
+ Zizq::Test.enable!
268
+
269
+ class ActiveSupport::TestCase
270
+ setup { Zizq::Test.reset! }
271
+ end
272
+
273
+ # In a test
274
+ def test_signup_fans_out
275
+ SignupService.new.run
276
+
277
+ assert Zizq::Test.enqueued?(SendWelcomeEmailJob, user_id: 42)
278
+ assert_equal 2, Zizq::Test.pending_jobs(only_queues: 'emails').size
279
+
280
+ # Drain the buffer through Zizq.configuration.dequeue_middleware
281
+ # (same path the real worker takes — registered middleware runs too).
282
+ Zizq::Test.dispatch_enqueued_jobs
283
+ end
284
+ ```
285
+
286
+ See [Testing](https://zizq.io/docs/clients/ruby/testing.html) for
287
+ full details.
288
+
224
289
  ## Resources
225
290
 
226
291
  * [Ruby Client Docs](https://zizq.io/docs/clients/ruby/)
@@ -59,6 +59,16 @@ module Zizq
59
59
  # a `Resources::Job` and a chain to continue.
60
60
  attr_reader :dequeue_middleware #: Middleware::Chain[Resources::Job, void]
61
61
 
62
+ # When truthy, `Zizq.client` lazily resolves to a
63
+ # `Zizq::Test::Client` that buffers enqueues in memory rather than
64
+ # dispatching to a real server. Useful inside test suites — set it
65
+ # once in your test helper and the rest of the app's code uses
66
+ # `Zizq.enqueue` / `Zizq.enqueue_bulk` unchanged. Read operations
67
+ # (`Zizq.query`, `Zizq.queues`, `Client#get_job`, etc.) raise
68
+ # rather than silently returning empty results, so missing test
69
+ # setup is obvious.
70
+ attr_accessor :test_mode #: bool
71
+
62
72
  def initialize #: () -> void
63
73
  @url = "http://localhost:7890"
64
74
  @format = :msgpack
@@ -67,6 +77,7 @@ module Zizq
67
77
  @worker = nil
68
78
  @read_timeout = 30
69
79
  @stream_idle_timeout = 30
80
+ @test_mode = false
70
81
  @enqueue_middleware = Middleware::Chain.new(Identity.new)
71
82
  @dequeue_middleware = Middleware::Chain.new(Zizq::Job)
72
83
  end
@@ -0,0 +1,100 @@
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
+ # Dispatch jobs by `type` string, mapping each to a handler block.
9
+ #
10
+ # Designed for cross-language workflows: payloads are plain JSON
11
+ # values (Hashes / Arrays / strings / numbers), `type` is a String
12
+ # the producer agrees on with the consumer, and routes are
13
+ # registered explicitly — no `Zizq::Job` mixin involved.
14
+ #
15
+ # Zizq.configure do |c|
16
+ # c.dispatcher = Zizq::Router.new do
17
+ # route("send_email") do |payload|
18
+ # Mailer.deliver(payload["user_id"], payload["template"])
19
+ # end
20
+ #
21
+ # route("expire_tokens") do
22
+ # TokenSweeper.run
23
+ # end
24
+ #
25
+ # route("generate_report") do |payload, job|
26
+ # Reports.generate(payload["id"], attempts: job.attempts)
27
+ # end
28
+ #
29
+ # # `def` inside the block defines singleton methods on the
30
+ # # router. Route blocks captured *inside* the constructor
31
+ # # have lexical `self == router`, so they can call these
32
+ # # helpers; routes added outside (`router.route("…") { … }`)
33
+ # # keep their own lexical `self` and would need to go through
34
+ # # the router explicitly (`router.logger`).
35
+ # def logger
36
+ # Zizq.configuration.logger
37
+ # end
38
+ #
39
+ # # Anything else falls back. A common pattern is delegating
40
+ # # to `Zizq::Job` for the apps that mix the two styles.
41
+ # fallback { |job| Zizq::Job.call(job) }
42
+ # end
43
+ # end
44
+ #
45
+ # Routes can also be registered outside the constructor block:
46
+ #
47
+ # router = Zizq::Router.new
48
+ # router.route("send_email") { |payload| ... }
49
+ #
50
+ # Handlers are called as `handler.call(payload, job)`. Block-arity
51
+ # rules let `{ |payload| }` or `{ }` ignore either argument.
52
+ # Strict-arity lambdas need to declare both.
53
+ class Router
54
+ # Raised when a job arrives with a type that has no registered
55
+ # route and no fallback. Caught by Zizq's normal worker error
56
+ # path, which nacks the job for retry (or dead-letters it once
57
+ # the retry limit is hit).
58
+ class UnknownJobType < Zizq::Error; end
59
+
60
+ # @rbs &block: ?(self) [self: Router] -> void
61
+ def initialize(&block)
62
+ @routes = {} #: Hash[String, ^(untyped, Resources::Job) -> void]
63
+ @fallback = nil #: (^(Resources::Job) -> void)?
64
+ instance_eval(&block) if block
65
+ end
66
+
67
+ # Register `handler` for jobs whose `type` matches.
68
+ #
69
+ # @rbs type: String | Symbol
70
+ # @rbs &handler: (untyped, Resources::Job) -> void
71
+ def route(type, &handler)
72
+ @routes[type.to_s] = handler
73
+ end
74
+
75
+ # Register a fallback handler invoked when no route matches.
76
+ # Receives the full `Resources::Job` (not split into payload /
77
+ # job pair), since the canonical use is delegation to another
78
+ # dispatcher:
79
+ #
80
+ # fallback { |job| Zizq::Job.call(job) }
81
+ #
82
+ # @rbs &handler: (Resources::Job) -> void
83
+ def fallback(&handler)
84
+ @fallback = handler
85
+ end
86
+
87
+ # Dispatch a single job. Looks up the handler by `job.type`,
88
+ # falls back to the registered fallback if any, otherwise
89
+ # raises `UnknownJobType`.
90
+ def call(job) #: (Resources::Job) -> void
91
+ handler = @routes[job.type]
92
+
93
+ return handler.call(job.payload, job) if handler
94
+ return @fallback.call(job) if @fallback
95
+
96
+ raise UnknownJobType,
97
+ "no handler registered for job type #{job.type.inspect}"
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,332 @@
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 Test
9
+ # A `Zizq::Client` stand-in for use in test suites.
10
+ #
11
+ # Buffers `enqueue` / `enqueue_bulk` calls in memory and returns
12
+ # synthetic `Resources::Job` instances (with generated ids) so that
13
+ # callers depending on the regular client's return contract don't
14
+ # need to special-case test mode.
15
+ #
16
+ # Read operations (`get_queues`, `list_jobs`, `count_jobs`, …) are
17
+ # explicitly not supported in test mode and raise `NotSupported`.
18
+ # Tests that need those should either run against a real server or
19
+ # stub at a higher level.
20
+ #
21
+ # Activated indirectly via `Zizq.configuration.test_mode = true` —
22
+ # `Zizq.client` then lazily builds a `Test::Client` instead of a
23
+ # real `Client`.
24
+ class Client < Zizq::Client
25
+ # Raised when test-mode code reaches an operation that isn't
26
+ # supported (queries, queue listing, worker streams, etc.).
27
+ class NotSupported < Zizq::Error; end
28
+
29
+ # Length of a real scru128 id in its base-32 representation.
30
+ # Synthetic test ids are sized to match (`test` prefix + zero
31
+ # padded counter) so they fit anywhere a real id would.
32
+ ID_LENGTH = 25
33
+ ID_PREFIX = "test"
34
+
35
+ # The canonical Zizq lifecycle states. We mirror these so the
36
+ # `status` on a buffered job reflects what the real server would
37
+ # report. Test mode never retries — `in_flight` only ever
38
+ # transitions to `completed` or `dead`.
39
+ STATUS_SCHEDULED = "scheduled"
40
+ STATUS_READY = "ready"
41
+ STATUS_IN_FLIGHT = "in_flight"
42
+ STATUS_COMPLETED = "completed"
43
+ STATUS_DEAD = "dead"
44
+
45
+ # Default `filter:` lambda — passes every job. Named so the
46
+ # filter pipeline is always callable without nil-checking.
47
+ PASS_ALL_FILTER = ->(_job) { true } #: ^(Resources::Job) -> bool
48
+
49
+ # Paired view of a single enqueue: the original `EnqueueRequest`
50
+ # (with full submission metadata — `unique_key`, `unique_while`,
51
+ # retry config, etc.), the synthetic `Resources::Job` returned
52
+ # to callers, and the data hash that backs the job (so we can
53
+ # mutate its status through the lifecycle).
54
+ Entry = Struct.new(:request, :job, :data, keyword_init: true)
55
+
56
+ def initialize #: () -> void
57
+ # Skip the parent's HTTP setup — we don't open connections in
58
+ # test mode. The parent's @http and friends stay nil; methods
59
+ # that would touch them are overridden below.
60
+
61
+ @entries = [] #: Array[Entry]
62
+ @mutex = Mutex.new
63
+ end
64
+
65
+ # All buffered jobs, in submission order, optionally filtered.
66
+ # See `apply_filters` for the filter kwargs.
67
+ def enqueued_jobs(**filters) #: (**untyped) -> Array[Resources::Job]
68
+ @mutex.synchronize { apply_filters(@entries, **filters).map(&:job) }
69
+ end
70
+
71
+ # Original `EnqueueRequest`s in submission order. Useful when a
72
+ # test needs metadata that doesn't survive onto `Resources::Job`
73
+ # (`unique_key`, `unique_while`, `delay` before `ready_at`
74
+ # resolution, etc.). Same filter kwargs as `enqueued_jobs`.
75
+ def enqueued_requests(**filters) #: (**untyped) -> Array[EnqueueRequest]
76
+ @mutex.synchronize { apply_filters(@entries, **filters).map(&:request) }
77
+ end
78
+
79
+ # Jobs awaiting dispatch — `ready` and `scheduled` entries.
80
+ # `pending_jobs` is the set `drain` would attempt to run on its
81
+ # next call (modulo `ready_at` for `scheduled` entries).
82
+ def pending_jobs(**filters) #: (**untyped) -> Array[Resources::Job]
83
+ filter_by_status([STATUS_READY, STATUS_SCHEDULED], **filters)
84
+ end
85
+
86
+ def in_flight_jobs(**filters) #: (**untyped) -> Array[Resources::Job]
87
+ filter_by_status([STATUS_IN_FLIGHT], **filters)
88
+ end
89
+
90
+ def completed_jobs(**filters) #: (**untyped) -> Array[Resources::Job]
91
+ filter_by_status([STATUS_COMPLETED], **filters)
92
+ end
93
+
94
+ def dead_jobs(**filters) #: (**untyped) -> Array[Resources::Job]
95
+ filter_by_status([STATUS_DEAD], **filters)
96
+ end
97
+
98
+ # Reset the buffer. Called between tests via `Zizq::Test.reset!`.
99
+ def clear! #: () -> void
100
+ @mutex.synchronize { @entries.clear }
101
+ end
102
+
103
+ def close #: () -> void
104
+ end
105
+
106
+ # Dispatch every runnable entry (status `ready`, or `scheduled`
107
+ # with `ready_at` already elapsed) through the configured
108
+ # dequeue middleware chain (same path the real worker uses, so
109
+ # any registered middlewares run in tests too), looping until no
110
+ # more match the filters. Re-enqueues during dispatch fall
111
+ # through the loop naturally — they get drained too unless they
112
+ # fall outside the filter set.
113
+ #
114
+ # The per-iteration snapshot is taken under the mutex and marked
115
+ # `in_flight` atomically. Dispatch happens outside the mutex so
116
+ # handlers can re-enter the client without deadlocking. On
117
+ # success the entry moves to `completed`; on a raised exception
118
+ # it moves to `dead` and the exception re-raises (matching
119
+ # ActiveJob's `perform_enqueued_jobs` + Sidekiq's `drain`).
120
+ def drain(**filters) #: (**untyped) -> Integer
121
+ total = 0
122
+ loop do
123
+ snapshot = take_runnable_snapshot(**filters)
124
+ break if snapshot.empty?
125
+
126
+ snapshot.each { |entry| dispatch_entry(entry) }
127
+ total += snapshot.size
128
+ end
129
+ total
130
+ end
131
+
132
+ # @rbs override
133
+ def enqueue(queue:,
134
+ type:,
135
+ payload:,
136
+ priority: nil,
137
+ ready_at: nil,
138
+ retry_limit: nil,
139
+ backoff: nil,
140
+ retention: nil,
141
+ unique_key: nil,
142
+ unique_while: nil)
143
+ req = EnqueueRequest.new(
144
+ queue:,
145
+ type:,
146
+ payload:,
147
+ priority:,
148
+ ready_at:,
149
+ retry_limit:,
150
+ backoff:,
151
+ retention:,
152
+ unique_key:,
153
+ unique_while:,
154
+ )
155
+ @mutex.synchronize { record_unsynchronized(req) }.job
156
+ end
157
+
158
+ # @rbs override
159
+ def enqueue_bulk(jobs:)
160
+ @mutex.synchronize do
161
+ jobs.map do |params|
162
+ req = EnqueueRequest.new(
163
+ queue: params[:queue],
164
+ type: params[:type],
165
+ payload: params[:payload],
166
+ priority: params[:priority],
167
+ ready_at: params[:ready_at],
168
+ retry_limit: params[:retry_limit],
169
+ backoff: params[:backoff],
170
+ retention: params[:retention],
171
+ unique_key: params[:unique_key],
172
+ unique_while: params[:unique_while],
173
+ )
174
+ record_unsynchronized(req).job
175
+ end
176
+ end
177
+ end
178
+
179
+ # All read / mutation / streaming operations are deliberately
180
+ # unimplemented.
181
+ #
182
+ # Raising loudly beats silently returning empty results that
183
+ # hide missing test setup.
184
+ %i[
185
+ get_queues
186
+ list_jobs
187
+ count_jobs
188
+ get_job
189
+ delete_job
190
+ delete_all_jobs
191
+ update_job
192
+ update_all_jobs
193
+ take_jobs
194
+ get_error
195
+ list_errors
196
+ health
197
+ server_version
198
+ ].each do |method_name|
199
+ define_method(method_name) do |*, **, &_|
200
+ Kernel.raise(
201
+ NotSupported,
202
+ "Zizq::Test::Client##{method_name} is not supported in test mode. " \
203
+ "Test mode buffers enqueues only — point at a real server, or stub the call."
204
+ )
205
+ end
206
+ end
207
+
208
+ private
209
+
210
+ def record_unsynchronized(req) #: (EnqueueRequest) -> Entry
211
+ # Serialized format: ready_at is integer milliseconds.
212
+ # `req.ready_at` (from `to_enqueue_params`) is fractional
213
+ # seconds; convert here so `Resources::Job#ready_at`'s
214
+ # ms -> seconds round-trip produces the same value the
215
+ # caller passed in. When the client omits ready_at the server
216
+ # assigns `now`, so we do the same.
217
+ now_ms = (Time.now.to_f * 1000).to_i
218
+ ready_at_ms = req.ready_at ? (req.ready_at.to_f * 1000).to_i : now_ms
219
+
220
+ data = {
221
+ "id" => synthetic_id(@entries.size + 1),
222
+ "queue" => req.queue,
223
+ "type" => req.type,
224
+ "payload" => req.payload,
225
+ "priority" => req.priority,
226
+ "ready_at" => ready_at_ms,
227
+ "retry_limit" => req.retry_limit,
228
+ "status" => ready_at_ms > now_ms ? STATUS_SCHEDULED : STATUS_READY,
229
+ }
230
+ entry = Entry.new(request: req, job: Resources::Job.new(self, data), data: data)
231
+ @entries << entry
232
+ entry
233
+ end
234
+
235
+ def synthetic_id(counter) #: (Integer) -> String
236
+ "#{ID_PREFIX}#{counter.to_s.rjust(ID_LENGTH - ID_PREFIX.length, '0')}"
237
+ end
238
+
239
+ # Returns entries matching every named filter AND the predicate.
240
+ # All filter kwargs are optional; unset means "don't filter on
241
+ # this axis." Callers must hold `@mutex` — public accessors do
242
+ # so via `synchronize` before calling.
243
+ #
244
+ # * `only_queues:` / `except_queues:` — String, Array of Strings.
245
+ # * `only_types:` / `except_types:` — String, Class, or Array
246
+ # of those. Class names are matched against the wire-format
247
+ # `type` string via `.to_s`.
248
+ # * `filter:` — a lambda receiving a `Resources::Job`, returning
249
+ # truthy to keep. Defaults to `PASS_ALL_FILTER`.
250
+ #
251
+ # `only_*` and `except_*` AND together with the predicate.
252
+ def apply_filters(entries,
253
+ only_queues: nil,
254
+ except_queues: nil,
255
+ only_types: nil,
256
+ except_types: nil,
257
+ filter: PASS_ALL_FILTER) #: (Array[Entry], **untyped) -> Array[Entry]
258
+ only_queues = normalize_filter(only_queues)
259
+ except_queues = normalize_filter(except_queues)
260
+ only_types = normalize_filter(only_types)
261
+ except_types = normalize_filter(except_types)
262
+
263
+ entries.select do |entry|
264
+ queue = entry.data["queue"] #: String
265
+ type = entry.data["type"] #: String
266
+
267
+ (only_queues.empty? || only_queues.include?(queue)) &&
268
+ (except_queues.empty? || !except_queues.include?(queue)) &&
269
+ (only_types.empty? || only_types.include?(type)) &&
270
+ (except_types.empty? || !except_types.include?(type)) &&
271
+ filter.call(entry.job)
272
+ end
273
+ end
274
+
275
+ def filter_by_status(statuses, **filters) #: (Array[String], **untyped) -> Array[Resources::Job]
276
+ @mutex.synchronize do
277
+ apply_filters(@entries, **filters)
278
+ .select { |e| statuses.include?(e.data["status"]) }
279
+ .map(&:job)
280
+ end
281
+ end
282
+
283
+ # Lock, find runnable entries matching the filters, flip each
284
+ # to in_flight, return the snapshot. Holding the mutex through
285
+ # this is fine because we don't call user code — dispatch
286
+ # happens outside.
287
+ def take_runnable_snapshot(**filters) #: (**untyped) -> Array[Entry]
288
+ now_ms = (Time.now.to_f * 1000).to_i
289
+ @mutex.synchronize do
290
+ apply_filters(@entries, **filters)
291
+ .select { |e| runnable?(e, now_ms) }
292
+ .each { |entry| entry.data["status"] = STATUS_IN_FLIGHT }
293
+ end
294
+ end
295
+
296
+ def runnable?(entry, now_ms) #: (Entry, Integer) -> bool
297
+ case entry.data["status"]
298
+ when STATUS_READY then true
299
+ when STATUS_SCHEDULED then entry.data["ready_at"] <= now_ms
300
+ else false
301
+ end
302
+ end
303
+
304
+ # Accept a Class, String, or Array of those; emit an Array of
305
+ # Strings. Class names match the API's `type` string.
306
+ def normalize_filter(value) #: ((String | Class | Array[String | Class])?) -> Array[String]
307
+ case value
308
+ when nil then []
309
+ when Array then value.map { |x| x.to_s }
310
+ else [value.to_s]
311
+ end
312
+ end
313
+
314
+ # Mark the entry in_flight, dispatch through the full dequeue
315
+ # middleware chain (same path the real worker uses, so any
316
+ # registered middlewares run in tests too), settle to
317
+ # completed or dead. A raised exception re-raises after
318
+ # recording — same observable behaviour as Rails'
319
+ # `perform_enqueued_jobs`.
320
+ def dispatch_entry(entry) #: (Entry) -> void
321
+ entry.data["status"] = STATUS_IN_FLIGHT
322
+ begin
323
+ Zizq.configuration.dequeue_middleware.call(entry.job)
324
+ entry.data["status"] = STATUS_COMPLETED
325
+ rescue
326
+ entry.data["status"] = STATUS_DEAD
327
+ raise
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
data/lib/zizq/test.rb ADDED
@@ -0,0 +1,190 @@
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
+ # Test-mode helpers. Activated by setting `c.test_mode = true` in a
9
+ # `Zizq.configure` block; `Zizq.client` then lazily resolves to a
10
+ # `Zizq::Test::Client` that buffers enqueues instead of dispatching.
11
+ #
12
+ # Typical use in a test helper:
13
+ #
14
+ # Zizq.configure do |c|
15
+ # c.test_mode = true
16
+ # end
17
+ #
18
+ # class MyTestCase
19
+ # setup { Zizq::Test.reset! }
20
+ # end
21
+ #
22
+ # In a test:
23
+ #
24
+ # def test_signup_fans_out
25
+ # # Default buffered mode — assert what was enqueued.
26
+ # SignupService.new.run
27
+ # assert_equal 2, Zizq::Test.client.pending_jobs.size
28
+ #
29
+ # # Drain whatever's pending (handler re-enqueues fall through
30
+ # # naturally — drain loops until pending is empty).
31
+ # Zizq::Test.dispatch_enqueued_jobs
32
+ #
33
+ # # Block form: run the work, then drain. Matches ActiveJob's
34
+ # # `perform_enqueued_jobs do ... end`.
35
+ # Zizq::Test.dispatch_enqueued_jobs { SignupService.new.run }
36
+ #
37
+ # # Filter by queue and/or type when only a subset should fire.
38
+ # Zizq::Test.dispatch_enqueued_jobs(queue: "emails")
39
+ # Zizq::Test.dispatch_enqueued_jobs(type: SendEmailJob)
40
+ # end
41
+ module Test
42
+ autoload :Client, "zizq/test/client"
43
+
44
+ # Switch Zizq into test mode. After this, `Zizq.client` resolves
45
+ # to a `Zizq::Test::Client` that buffers enqueues in memory.
46
+ # Typically called once in a test helper.
47
+ def self.enable! #: () -> void
48
+ Zizq.configure { |c| c.test_mode = true }
49
+ end
50
+
51
+ # Switch back to the real client. The buffered state is dropped
52
+ # along with the test client (the next `Zizq.client` access
53
+ # builds a fresh `Zizq::Client`).
54
+ def self.disable! #: () -> void
55
+ Zizq.configure { |c| c.test_mode = false }
56
+ end
57
+
58
+ # The active test client. Raises if test mode is not enabled —
59
+ # better to fail loudly than return a stale or wrong client.
60
+ def self.client #: () -> Client
61
+ unless Zizq.configuration.test_mode
62
+ raise Client::NotSupported,
63
+ "Zizq.configuration.test_mode is not enabled; Zizq::Test.client has nothing to manage."
64
+ end
65
+ Zizq.client #: Client
66
+ end
67
+
68
+ # Reset buffered state between tests. Keeps the configured
69
+ # `test_mode` flag.
70
+ def self.reset! #: () -> void
71
+ client.clear!
72
+ end
73
+
74
+ # Dispatch pending jobs through the configured dequeue middleware
75
+ # chain (`Zizq.configuration.dequeue_middleware` — same path the
76
+ # real worker uses, so any registered middlewares run in tests
77
+ # too), looping until no more pending entries match the filters.
78
+ #
79
+ # * No block: drain whatever's pending now.
80
+ # * With block: yield first (test code enqueues), then drain.
81
+ # * `only_queues:` / `except_queues:` — String or Array of Strings.
82
+ # * `only_types:` / `except_types:` — String, Class, or Array of
83
+ # those (Class names match the serialized-format `type` via `.to_s`,
84
+ # so passing an ActiveJob class works directly).
85
+ # * `filter:` — a lambda `->(job)` returning truthy to keep an
86
+ # entry. Defaults to "pass all". Combines with the named
87
+ # filters via AND.
88
+ #
89
+ # Returns the number of jobs dispatched. A block exception
90
+ # propagates without draining; a handler exception during drain
91
+ # transitions that entry to `dead` and re-raises.
92
+ def self.dispatch_enqueued_jobs(**filters, &block) #: (**untyped) ?{ () -> void } -> Integer
93
+ yield if block
94
+ client.drain(**filters)
95
+ end
96
+
97
+ # Convenience proxies onto the active test client, so test code
98
+ # can read `Zizq::Test.pending_jobs(only_queues: "emails")`
99
+ # instead of routing through `Zizq::Test.client.pending_jobs(...)`.
100
+ # Each forwards `**filters` to the corresponding Client accessor;
101
+ # see `Test::Client` for the supported filter kwargs.
102
+ #
103
+ # All accept the same filter kwargs as `#dispatch_enqueued_jobs`.
104
+ %i[
105
+ enqueued_jobs
106
+ enqueued_requests
107
+ pending_jobs
108
+ in_flight_jobs
109
+ completed_jobs
110
+ dead_jobs
111
+ ].each do |name|
112
+ define_singleton_method(name) do |**filters|
113
+ client.public_send(name, **filters)
114
+ end
115
+ end
116
+
117
+ # Was a job of `job_class` enqueued (optionally with matching
118
+ # args)?
119
+ #
120
+ # Zizq::Test.enqueued?(SendEmailJob) # any args
121
+ # Zizq::Test.enqueued?(SendEmailJob, 42, template: "welcome") # exact args
122
+ #
123
+ # With no args, matches by class/type only. With args/kwargs,
124
+ # uses the class's own `zizq_serialize` to compute the expected
125
+ # payload — so it works for both `Zizq::Job` and AJ classes
126
+ # (AJ's volatile fields like `job_id` are ignored, only the
127
+ # `arguments` subset is compared).
128
+ #
129
+ # For anything fuzzier (matchers, subset matching, custom
130
+ # predicates), drop down to
131
+ # `client.enqueued_jobs(only_types: ..., filter: ->(job) { ... })`.
132
+ def self.enqueued?(job_class, *args, **kwargs) #: (Class, *untyped, **untyped) -> bool
133
+ enqueued_count(job_class, *args, **kwargs) > 0
134
+ end
135
+
136
+ # How many times was a job of `job_class` enqueued (optionally
137
+ # with matching args)? Same argument semantics as `#enqueued?`.
138
+ def self.enqueued_count(job_class, *args, **kwargs) #: (Class, *untyped, **untyped) -> Integer
139
+ type = job_class.to_s
140
+ return enqueued_raw_count(type: type) if args.empty? && kwargs.empty?
141
+
142
+ unless job_class.respond_to?(:zizq_serialize)
143
+ raise ArgumentError,
144
+ "#{job_class} doesn't implement zizq_serialize — " \
145
+ "include Zizq::Job or extend Zizq::ActiveJobConfig, " \
146
+ "or use Zizq::Test.enqueued_raw? for raw enqueues."
147
+ end
148
+
149
+ expected = job_class.zizq_serialize(*args, **kwargs)
150
+ client.enqueued_jobs(only_types: type).count do |job|
151
+ payloads_equivalent?(expected, job.payload)
152
+ end
153
+ end
154
+
155
+ # Was a raw job (queue + type + payload) enqueued? Each kwarg is
156
+ # optional; unspecified means "don't filter on this axis."
157
+ #
158
+ # Zizq::Test.enqueued_raw?(type: "send_email")
159
+ # Zizq::Test.enqueued_raw?(type: "send_email", payload: { user_id: 42 })
160
+ # Zizq::Test.enqueued_raw?(queue: "emails", type: "send_email")
161
+ def self.enqueued_raw?(queue: nil, type: nil, payload: nil) #: (?queue: String?, ?type: String?, ?payload: untyped) -> bool
162
+ enqueued_raw_count(queue: queue, type: type, payload: payload) > 0
163
+ end
164
+
165
+ # How many raw jobs match (queue + type + payload)? Same
166
+ # argument semantics as `#enqueued_raw?`.
167
+ def self.enqueued_raw_count(queue: nil, type: nil, payload: nil) #: (?queue: String?, ?type: String?, ?payload: untyped) -> Integer
168
+ filters = {}
169
+ filters[:only_queues] = queue if queue
170
+ filters[:only_types] = type if type
171
+ filters[:filter] = ->(job) { job.payload == payload } unless payload.nil?
172
+ client.enqueued_jobs(**filters).size
173
+ end
174
+
175
+ # Heuristic payload comparison that handles both `Zizq::Job`'s
176
+ # serialized format (`{"args" => [...], "kwargs" => {...}}`) and
177
+ # ActiveJob's (`{"job_class" => ..., "arguments" => [...],
178
+ # "job_id" => ..., "enqueued_at" => ..., ...}`). The AJ shape
179
+ # always has an `"arguments"` key while `Zizq::Job`'s doesn't, so
180
+ # we use that to pick whether to compare the full hash or only the
181
+ # `arguments` subset (dropping AJ's volatile per-enqueue fields).
182
+ def self.payloads_equivalent?(expected, actual) #: (untyped, untyped) -> bool
183
+ if expected.is_a?(Hash) && expected.key?("arguments")
184
+ actual.is_a?(Hash) && actual["arguments"] == expected["arguments"]
185
+ else
186
+ actual == expected
187
+ end
188
+ end
189
+ end
190
+ end
data/lib/zizq/version.rb CHANGED
@@ -5,5 +5,5 @@
5
5
  # frozen_string_literal: true
6
6
 
7
7
  module Zizq
8
- VERSION = "0.3.4" #: String
8
+ VERSION = "0.3.6" #: String
9
9
  end
data/lib/zizq.rb CHANGED
@@ -29,6 +29,8 @@ module Zizq
29
29
  autoload :Lifecycle, "zizq/lifecycle"
30
30
  autoload :Query, "zizq/query"
31
31
  autoload :Resources, "zizq/resources"
32
+ autoload :Router, "zizq/router"
33
+ autoload :Test, "zizq/test"
32
34
  autoload :TlsConfiguration, "zizq/tls_configuration"
33
35
  autoload :Worker, "zizq/worker"
34
36
  autoload :WorkerConfiguration, "zizq/worker_configuration"
@@ -74,19 +76,27 @@ module Zizq
74
76
  #
75
77
  # The client is memoized so that persistent HTTP connections are reused
76
78
  # across calls, reducing TCP connection overhead.
79
+ #
80
+ # When `configuration.test_mode` is set, a `Zizq::Test::Client` is
81
+ # returned instead — buffering enqueues in memory rather than
82
+ # talking to a real server.
77
83
  def client #: () -> Client
78
84
  @client ||= begin
79
85
  @client_mutex.synchronize do
80
86
  break @client if @client
81
87
 
82
88
  configuration.validate!
83
- @client = Client.new(
84
- url: configuration.url,
85
- format: configuration.format,
86
- ssl_context: configuration.ssl_context,
87
- read_timeout: configuration.read_timeout,
88
- stream_idle_timeout: configuration.stream_idle_timeout
89
- )
89
+ @client = if configuration.test_mode
90
+ Test::Client.new
91
+ else
92
+ Client.new(
93
+ url: configuration.url,
94
+ format: configuration.format,
95
+ ssl_context: configuration.ssl_context,
96
+ read_timeout: configuration.read_timeout,
97
+ stream_idle_timeout: configuration.stream_idle_timeout
98
+ )
99
+ end
90
100
  end
91
101
  end
92
102
  end
@@ -51,6 +51,16 @@ module Zizq
51
51
  # a `Resources::Job` and a chain to continue.
52
52
  attr_reader dequeue_middleware: Middleware::Chain[Resources::Job, void]
53
53
 
54
+ # When truthy, `Zizq.client` lazily resolves to a
55
+ # `Zizq::Test::Client` that buffers enqueues in memory rather than
56
+ # dispatching to a real server. Useful inside test suites — set it
57
+ # once in your test helper and the rest of the app's code uses
58
+ # `Zizq.enqueue` / `Zizq.enqueue_bulk` unchanged. Read operations
59
+ # (`Zizq.query`, `Zizq.queues`, `Client#get_job`, etc.) raise
60
+ # rather than silently returning empty results, so missing test
61
+ # setup is obvious.
62
+ attr_accessor test_mode: bool
63
+
54
64
  def initialize: () -> untyped
55
65
 
56
66
  # TLS settings for connecting to the server over HTTPS.
@@ -0,0 +1,81 @@
1
+ # Generated from lib/zizq/router.rb with RBS::Inline
2
+
3
+ module Zizq
4
+ # Dispatch jobs by `type` string, mapping each to a handler block.
5
+ #
6
+ # Designed for cross-language workflows: payloads are plain JSON
7
+ # values (Hashes / Arrays / strings / numbers), `type` is a String
8
+ # the producer agrees on with the consumer, and routes are
9
+ # registered explicitly — no `Zizq::Job` mixin involved.
10
+ #
11
+ # Zizq.configure do |c|
12
+ # c.dispatcher = Zizq::Router.new do
13
+ # route("send_email") do |payload|
14
+ # Mailer.deliver(payload["user_id"], payload["template"])
15
+ # end
16
+ #
17
+ # route("expire_tokens") do
18
+ # TokenSweeper.run
19
+ # end
20
+ #
21
+ # route("generate_report") do |payload, job|
22
+ # Reports.generate(payload["id"], attempts: job.attempts)
23
+ # end
24
+ #
25
+ # # `def` inside the block defines singleton methods on the
26
+ # # router. Route blocks captured *inside* the constructor
27
+ # # have lexical `self == router`, so they can call these
28
+ # # helpers; routes added outside (`router.route("…") { … }`)
29
+ # # keep their own lexical `self` and would need to go through
30
+ # # the router explicitly (`router.logger`).
31
+ # def logger
32
+ # Zizq.configuration.logger
33
+ # end
34
+ #
35
+ # # Anything else falls back. A common pattern is delegating
36
+ # # to `Zizq::Job` for the apps that mix the two styles.
37
+ # fallback { |job| Zizq::Job.call(job) }
38
+ # end
39
+ # end
40
+ #
41
+ # Routes can also be registered outside the constructor block:
42
+ #
43
+ # router = Zizq::Router.new
44
+ # router.route("send_email") { |payload| ... }
45
+ #
46
+ # Handlers are called as `handler.call(payload, job)`. Block-arity
47
+ # rules let `{ |payload| }` or `{ }` ignore either argument.
48
+ # Strict-arity lambdas need to declare both.
49
+ class Router
50
+ # Raised when a job arrives with a type that has no registered
51
+ # route and no fallback. Caught by Zizq's normal worker error
52
+ # path, which nacks the job for retry (or dead-letters it once
53
+ # the retry limit is hit).
54
+ class UnknownJobType < Zizq::Error
55
+ end
56
+
57
+ # @rbs &block: ?(self) [self: Router] -> void
58
+ def initialize: () ?{ (self) [self: Router] -> void } -> untyped
59
+
60
+ # Register `handler` for jobs whose `type` matches.
61
+ #
62
+ # @rbs type: String | Symbol
63
+ # @rbs &handler: (untyped, Resources::Job) -> void
64
+ def route: (String | Symbol type) { (untyped, Resources::Job) -> void } -> untyped
65
+
66
+ # Register a fallback handler invoked when no route matches.
67
+ # Receives the full `Resources::Job` (not split into payload /
68
+ # job pair), since the canonical use is delegation to another
69
+ # dispatcher:
70
+ #
71
+ # fallback { |job| Zizq::Job.call(job) }
72
+ #
73
+ # @rbs &handler: (Resources::Job) -> void
74
+ def fallback: () { (Resources::Job) -> void } -> untyped
75
+
76
+ # Dispatch a single job. Looks up the handler by `job.type`,
77
+ # falls back to the registered fallback if any, otherwise
78
+ # raises `UnknownJobType`.
79
+ def call: (untyped job) -> untyped
80
+ end
81
+ end
@@ -0,0 +1,161 @@
1
+ # Generated from lib/zizq/test/client.rb with RBS::Inline
2
+
3
+ module Zizq
4
+ module Test
5
+ # A `Zizq::Client` stand-in for use in test suites.
6
+ #
7
+ # Buffers `enqueue` / `enqueue_bulk` calls in memory and returns
8
+ # synthetic `Resources::Job` instances (with generated ids) so that
9
+ # callers depending on the regular client's return contract don't
10
+ # need to special-case test mode.
11
+ #
12
+ # Read operations (`get_queues`, `list_jobs`, `count_jobs`, …) are
13
+ # explicitly not supported in test mode and raise `NotSupported`.
14
+ # Tests that need those should either run against a real server or
15
+ # stub at a higher level.
16
+ #
17
+ # Activated indirectly via `Zizq.configuration.test_mode = true` —
18
+ # `Zizq.client` then lazily builds a `Test::Client` instead of a
19
+ # real `Client`.
20
+ class Client < Zizq::Client
21
+ # Raised when test-mode code reaches an operation that isn't
22
+ # supported (queries, queue listing, worker streams, etc.).
23
+ class NotSupported < Zizq::Error
24
+ end
25
+
26
+ # Length of a real scru128 id in its base-32 representation.
27
+ # Synthetic test ids are sized to match (`test` prefix + zero
28
+ # padded counter) so they fit anywhere a real id would.
29
+ ID_LENGTH: ::Integer
30
+
31
+ ID_PREFIX: ::String
32
+
33
+ # The canonical Zizq lifecycle states. We mirror these so the
34
+ # `status` on a buffered job reflects what the real server would
35
+ # report. Test mode never retries — `in_flight` only ever
36
+ # transitions to `completed` or `dead`.
37
+ STATUS_SCHEDULED: ::String
38
+
39
+ STATUS_READY: ::String
40
+
41
+ STATUS_IN_FLIGHT: ::String
42
+
43
+ STATUS_COMPLETED: ::String
44
+
45
+ STATUS_DEAD: ::String
46
+
47
+ # Default `filter:` lambda — passes every job. Named so the
48
+ # filter pipeline is always callable without nil-checking.
49
+ PASS_ALL_FILTER: ^(Resources::Job) -> bool
50
+
51
+ # Paired view of a single enqueue: the original `EnqueueRequest`
52
+ # (with full submission metadata — `unique_key`, `unique_while`,
53
+ # retry config, etc.), the synthetic `Resources::Job` returned
54
+ # to callers, and the data hash that backs the job (so we can
55
+ # mutate its status through the lifecycle).
56
+ class Entry < Struct[untyped]
57
+ attr_accessor request(): untyped
58
+
59
+ attr_accessor job(): untyped
60
+
61
+ attr_accessor data(): untyped
62
+
63
+ def self.new: (?request: untyped, ?job: untyped, ?data: untyped) -> instance
64
+ | ({ ?request: untyped, ?job: untyped, ?data: untyped }) -> instance
65
+ end
66
+
67
+ def initialize: () -> untyped
68
+
69
+ # All buffered jobs, in submission order, optionally filtered.
70
+ # See `apply_filters` for the filter kwargs.
71
+ def enqueued_jobs: (**untyped filters) -> untyped
72
+
73
+ # Original `EnqueueRequest`s in submission order. Useful when a
74
+ # test needs metadata that doesn't survive onto `Resources::Job`
75
+ # (`unique_key`, `unique_while`, `delay` before `ready_at`
76
+ # resolution, etc.). Same filter kwargs as `enqueued_jobs`.
77
+ def enqueued_requests: (**untyped filters) -> untyped
78
+
79
+ # Jobs awaiting dispatch — `ready` and `scheduled` entries.
80
+ # `pending_jobs` is the set `drain` would attempt to run on its
81
+ # next call (modulo `ready_at` for `scheduled` entries).
82
+ def pending_jobs: (**untyped filters) -> untyped
83
+
84
+ def in_flight_jobs: (**untyped filters) -> untyped
85
+
86
+ def completed_jobs: (**untyped filters) -> untyped
87
+
88
+ def dead_jobs: (**untyped filters) -> untyped
89
+
90
+ # Reset the buffer. Called between tests via `Zizq::Test.reset!`.
91
+ def clear!: () -> untyped
92
+
93
+ def close: () -> untyped
94
+
95
+ # Dispatch every runnable entry (status `ready`, or `scheduled`
96
+ # with `ready_at` already elapsed) through the configured
97
+ # dequeue middleware chain (same path the real worker uses, so
98
+ # any registered middlewares run in tests too), looping until no
99
+ # more match the filters. Re-enqueues during dispatch fall
100
+ # through the loop naturally — they get drained too unless they
101
+ # fall outside the filter set.
102
+ #
103
+ # The per-iteration snapshot is taken under the mutex and marked
104
+ # `in_flight` atomically. Dispatch happens outside the mutex so
105
+ # handlers can re-enter the client without deadlocking. On
106
+ # success the entry moves to `completed`; on a raised exception
107
+ # it moves to `dead` and the exception re-raises (matching
108
+ # ActiveJob's `perform_enqueued_jobs` + Sidekiq's `drain`).
109
+ def drain: (**untyped filters) -> untyped
110
+
111
+ # @rbs override
112
+ def enqueue: ...
113
+
114
+ # @rbs override
115
+ def enqueue_bulk: ...
116
+
117
+ private
118
+
119
+ def record_unsynchronized: (untyped req) -> untyped
120
+
121
+ def synthetic_id: (untyped counter) -> untyped
122
+
123
+ # Returns entries matching every named filter AND the predicate.
124
+ # All filter kwargs are optional; unset means "don't filter on
125
+ # this axis." Callers must hold `@mutex` — public accessors do
126
+ # so via `synchronize` before calling.
127
+ #
128
+ # * `only_queues:` / `except_queues:` — String, Array of Strings.
129
+ # * `only_types:` / `except_types:` — String, Class, or Array
130
+ # of those. Class names are matched against the wire-format
131
+ # `type` string via `.to_s`.
132
+ # * `filter:` — a lambda receiving a `Resources::Job`, returning
133
+ # truthy to keep. Defaults to `PASS_ALL_FILTER`.
134
+ #
135
+ # `only_*` and `except_*` AND together with the predicate.
136
+ def apply_filters: (untyped entries, ?only_queues: untyped, ?except_queues: untyped, ?only_types: untyped, ?except_types: untyped, ?filter: untyped) -> untyped
137
+
138
+ def filter_by_status: (untyped statuses, **untyped filters) -> untyped
139
+
140
+ # Lock, find runnable entries matching the filters, flip each
141
+ # to in_flight, return the snapshot. Holding the mutex through
142
+ # this is fine because we don't call user code — dispatch
143
+ # happens outside.
144
+ def take_runnable_snapshot: (**untyped filters) -> untyped
145
+
146
+ def runnable?: (untyped entry, untyped now_ms) -> untyped
147
+
148
+ # Accept a Class, String, or Array of those; emit an Array of
149
+ # Strings. Class names match the API's `type` string.
150
+ def normalize_filter: (untyped value) -> untyped
151
+
152
+ # Mark the entry in_flight, dispatch through the full dequeue
153
+ # middleware chain (same path the real worker uses, so any
154
+ # registered middlewares run in tests too), settle to
155
+ # completed or dead. A raised exception re-raises after
156
+ # recording — same observable behaviour as Rails'
157
+ # `perform_enqueued_jobs`.
158
+ def dispatch_entry: (untyped entry) -> untyped
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,118 @@
1
+ # Generated from lib/zizq/test.rb with RBS::Inline
2
+
3
+ module Zizq
4
+ # Test-mode helpers. Activated by setting `c.test_mode = true` in a
5
+ # `Zizq.configure` block; `Zizq.client` then lazily resolves to a
6
+ # `Zizq::Test::Client` that buffers enqueues instead of dispatching.
7
+ #
8
+ # Typical use in a test helper:
9
+ #
10
+ # Zizq.configure do |c|
11
+ # c.test_mode = true
12
+ # end
13
+ #
14
+ # class MyTestCase
15
+ # setup { Zizq::Test.reset! }
16
+ # end
17
+ #
18
+ # In a test:
19
+ #
20
+ # def test_signup_fans_out
21
+ # # Default buffered mode — assert what was enqueued.
22
+ # SignupService.new.run
23
+ # assert_equal 2, Zizq::Test.client.pending_jobs.size
24
+ #
25
+ # # Drain whatever's pending (handler re-enqueues fall through
26
+ # # naturally — drain loops until pending is empty).
27
+ # Zizq::Test.dispatch_enqueued_jobs
28
+ #
29
+ # # Block form: run the work, then drain. Matches ActiveJob's
30
+ # # `perform_enqueued_jobs do ... end`.
31
+ # Zizq::Test.dispatch_enqueued_jobs { SignupService.new.run }
32
+ #
33
+ # # Filter by queue and/or type when only a subset should fire.
34
+ # Zizq::Test.dispatch_enqueued_jobs(queue: "emails")
35
+ # Zizq::Test.dispatch_enqueued_jobs(type: SendEmailJob)
36
+ # end
37
+ module Test
38
+ # Switch Zizq into test mode. After this, `Zizq.client` resolves
39
+ # to a `Zizq::Test::Client` that buffers enqueues in memory.
40
+ # Typically called once in a test helper.
41
+ def self.enable!: () -> untyped
42
+
43
+ # Switch back to the real client. The buffered state is dropped
44
+ # along with the test client (the next `Zizq.client` access
45
+ # builds a fresh `Zizq::Client`).
46
+ def self.disable!: () -> untyped
47
+
48
+ # The active test client. Raises if test mode is not enabled —
49
+ # better to fail loudly than return a stale or wrong client.
50
+ def self.client: () -> untyped
51
+
52
+ # Reset buffered state between tests. Keeps the configured
53
+ # `test_mode` flag.
54
+ def self.reset!: () -> untyped
55
+
56
+ # Dispatch pending jobs through the configured dequeue middleware
57
+ # chain (`Zizq.configuration.dequeue_middleware` — same path the
58
+ # real worker uses, so any registered middlewares run in tests
59
+ # too), looping until no more pending entries match the filters.
60
+ #
61
+ # * No block: drain whatever's pending now.
62
+ # * With block: yield first (test code enqueues), then drain.
63
+ # * `only_queues:` / `except_queues:` — String or Array of Strings.
64
+ # * `only_types:` / `except_types:` — String, Class, or Array of
65
+ # those (Class names match the serialized-format `type` via `.to_s`,
66
+ # so passing an ActiveJob class works directly).
67
+ # * `filter:` — a lambda `->(job)` returning truthy to keep an
68
+ # entry. Defaults to "pass all". Combines with the named
69
+ # filters via AND.
70
+ #
71
+ # Returns the number of jobs dispatched. A block exception
72
+ # propagates without draining; a handler exception during drain
73
+ # transitions that entry to `dead` and re-raises.
74
+ def self.dispatch_enqueued_jobs: (**untyped filters) ?{ (?) -> untyped } -> untyped
75
+
76
+ # Was a job of `job_class` enqueued (optionally with matching
77
+ # args)?
78
+ #
79
+ # Zizq::Test.enqueued?(SendEmailJob) # any args
80
+ # Zizq::Test.enqueued?(SendEmailJob, 42, template: "welcome") # exact args
81
+ #
82
+ # With no args, matches by class/type only. With args/kwargs,
83
+ # uses the class's own `zizq_serialize` to compute the expected
84
+ # payload — so it works for both `Zizq::Job` and AJ classes
85
+ # (AJ's volatile fields like `job_id` are ignored, only the
86
+ # `arguments` subset is compared).
87
+ #
88
+ # For anything fuzzier (matchers, subset matching, custom
89
+ # predicates), drop down to
90
+ # `client.enqueued_jobs(only_types: ..., filter: ->(job) { ... })`.
91
+ def self.enqueued?: (untyped job_class, *untyped args, **untyped kwargs) -> untyped
92
+
93
+ # How many times was a job of `job_class` enqueued (optionally
94
+ # with matching args)? Same argument semantics as `#enqueued?`.
95
+ def self.enqueued_count: (untyped job_class, *untyped args, **untyped kwargs) -> untyped
96
+
97
+ # Was a raw job (queue + type + payload) enqueued? Each kwarg is
98
+ # optional; unspecified means "don't filter on this axis."
99
+ #
100
+ # Zizq::Test.enqueued_raw?(type: "send_email")
101
+ # Zizq::Test.enqueued_raw?(type: "send_email", payload: { user_id: 42 })
102
+ # Zizq::Test.enqueued_raw?(queue: "emails", type: "send_email")
103
+ def self.enqueued_raw?: (?queue: untyped, ?type: untyped, ?payload: untyped) -> untyped
104
+
105
+ # How many raw jobs match (queue + type + payload)? Same
106
+ # argument semantics as `#enqueued_raw?`.
107
+ def self.enqueued_raw_count: (?queue: untyped, ?type: untyped, ?payload: untyped) -> untyped
108
+
109
+ # Heuristic payload comparison that handles both `Zizq::Job`'s
110
+ # serialized format (`{"args" => [...], "kwargs" => {...}}`) and
111
+ # ActiveJob's (`{"job_class" => ..., "arguments" => [...],
112
+ # "job_id" => ..., "enqueued_at" => ..., ...}`). The AJ shape
113
+ # always has an `"arguments"` key while `Zizq::Job`'s doesn't, so
114
+ # we use that to pick whether to compare the full hash or only the
115
+ # `arguments` subset (dropping AJ's volatile per-enqueue fields).
116
+ def self.payloads_equivalent?: (untyped expected, untyped actual) -> untyped
117
+ end
118
+ end
@@ -33,6 +33,10 @@ module Zizq
33
33
  #
34
34
  # The client is memoized so that persistent HTTP connections are reused
35
35
  # across calls, reducing TCP connection overhead.
36
+ #
37
+ # When `configuration.test_mode` is set, a `Zizq::Test::Client` is
38
+ # returned instead — buffering enqueues in memory rather than
39
+ # talking to a real server.
36
40
  def self.client: () -> untyped
37
41
 
38
42
  # Resets all global state: configuration and shared client.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zizq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Corbyn <chris@zizq.io>
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-25 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async-http
@@ -100,6 +100,9 @@ files:
100
100
  - lib/zizq/resources/job_template.rb
101
101
  - lib/zizq/resources/page.rb
102
102
  - lib/zizq/resources/resource.rb
103
+ - lib/zizq/router.rb
104
+ - lib/zizq/test.rb
105
+ - lib/zizq/test/client.rb
103
106
  - lib/zizq/tls_configuration.rb
104
107
  - lib/zizq/version.rb
105
108
  - lib/zizq/worker.rb
@@ -133,6 +136,9 @@ files:
133
136
  - sig/generated/zizq/resources/job_template.rbs
134
137
  - sig/generated/zizq/resources/page.rbs
135
138
  - sig/generated/zizq/resources/resource.rbs
139
+ - sig/generated/zizq/router.rbs
140
+ - sig/generated/zizq/test.rbs
141
+ - sig/generated/zizq/test/client.rbs
136
142
  - sig/generated/zizq/tls_configuration.rbs
137
143
  - sig/generated/zizq/version.rbs
138
144
  - sig/generated/zizq/worker.rbs