zizq 0.3.3 → 0.3.5
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 +4 -4
- data/README.md +34 -2
- data/lib/zizq/configuration.rb +11 -0
- data/lib/zizq/enqueue_request.rb +2 -2
- data/lib/zizq/test/client.rb +332 -0
- data/lib/zizq/test.rb +190 -0
- data/lib/zizq/version.rb +1 -1
- data/lib/zizq.rb +16 -7
- data/sig/generated/zizq/configuration.rbs +10 -0
- data/sig/generated/zizq/test/client.rbs +161 -0
- data/sig/generated/zizq/test.rbs +118 -0
- data/sig/generated/zizq.rbs +4 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d7eb0e9668c055e6f5b6b8ec6aacb5c09f8df5eedcea038acdb14d164a58acf
|
|
4
|
+
data.tar.gz: d30b95be8bab2eae5100b89fa7957278a20c301538e5630156bf9892739864a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dbe8d111b4083de907b4522ae4f0c110554a0ab5821a544d808c363868602178c15f983140436da6525b96ab30be159ac52309adfb05cb271739a7aee73174cc
|
|
7
|
+
data.tar.gz: 39606af98c372c50147922ba7640dcd9e58d449c63927008abbcc03ac2b926e68d4856764152f51f16fb26554241e801df1fb862542e405a1087bd2213d4eaab
|
data/README.md
CHANGED
|
@@ -32,13 +32,13 @@ API.
|
|
|
32
32
|
Add it to your application's `Gemfile`:
|
|
33
33
|
|
|
34
34
|
```ruby
|
|
35
|
-
gem 'zizq', '~> 0.3.
|
|
35
|
+
gem 'zizq', '~> 0.3.5'
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
Or install it manually:
|
|
39
39
|
|
|
40
40
|
```shell
|
|
41
|
-
$ gem install zizq -v 0.3.
|
|
41
|
+
$ gem install zizq -v 0.3.5
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
Ruby **3.2.8 or newer** is required. Client and server share version
|
|
@@ -221,6 +221,38 @@ Once defined, schedules can be inspected and managed via
|
|
|
221
221
|
`Zizq.crontab('maintenance')` — paused/resumed at the schedule level or per
|
|
222
222
|
entry, and deleted entirely when no longer needed.
|
|
223
223
|
|
|
224
|
+
### Testing
|
|
225
|
+
|
|
226
|
+
Set `c.test_mode = true` in your test helper and Zizq swaps the real
|
|
227
|
+
client out for an in-memory `Zizq::Test::Client` that buffers enqueues
|
|
228
|
+
instead of dispatching them. Tests can then assert on what was
|
|
229
|
+
enqueued and drain the buffer through the configured dispatcher —
|
|
230
|
+
no running server required.
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
# test/test_helper.rb (or spec/spec_helper.rb)
|
|
234
|
+
Zizq::Test.enable!
|
|
235
|
+
|
|
236
|
+
class ActiveSupport::TestCase
|
|
237
|
+
setup { Zizq::Test.reset! }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# In a test
|
|
241
|
+
def test_signup_fans_out
|
|
242
|
+
SignupService.new.run
|
|
243
|
+
|
|
244
|
+
assert Zizq::Test.enqueued?(SendWelcomeEmailJob, user_id: 42)
|
|
245
|
+
assert_equal 2, Zizq::Test.pending_jobs(only_queues: 'emails').size
|
|
246
|
+
|
|
247
|
+
# Drain the buffer through Zizq.configuration.dequeue_middleware
|
|
248
|
+
# (same path the real worker takes — registered middleware runs too).
|
|
249
|
+
Zizq::Test.dispatch_enqueued_jobs
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
See [Testing](https://zizq.io/docs/clients/ruby/testing.html) for
|
|
254
|
+
full details.
|
|
255
|
+
|
|
224
256
|
## Resources
|
|
225
257
|
|
|
226
258
|
* [Ruby Client Docs](https://zizq.io/docs/clients/ruby/)
|
data/lib/zizq/configuration.rb
CHANGED
|
@@ -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
|
data/lib/zizq/enqueue_request.rb
CHANGED
|
@@ -157,8 +157,8 @@ module Zizq
|
|
|
157
157
|
if backoff
|
|
158
158
|
params[:backoff] = {
|
|
159
159
|
exponent: backoff[:exponent].to_f,
|
|
160
|
-
base_ms: (backoff[:base].to_f * 1000).
|
|
161
|
-
jitter_ms: (backoff[:jitter].to_f * 1000).
|
|
160
|
+
base_ms: (backoff[:base].to_f * 1000).to_i,
|
|
161
|
+
jitter_ms: (backoff[:jitter].to_f * 1000).to_i
|
|
162
162
|
}
|
|
163
163
|
end
|
|
164
164
|
|
|
@@ -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
data/lib/zizq.rb
CHANGED
|
@@ -29,6 +29,7 @@ module Zizq
|
|
|
29
29
|
autoload :Lifecycle, "zizq/lifecycle"
|
|
30
30
|
autoload :Query, "zizq/query"
|
|
31
31
|
autoload :Resources, "zizq/resources"
|
|
32
|
+
autoload :Test, "zizq/test"
|
|
32
33
|
autoload :TlsConfiguration, "zizq/tls_configuration"
|
|
33
34
|
autoload :Worker, "zizq/worker"
|
|
34
35
|
autoload :WorkerConfiguration, "zizq/worker_configuration"
|
|
@@ -74,19 +75,27 @@ module Zizq
|
|
|
74
75
|
#
|
|
75
76
|
# The client is memoized so that persistent HTTP connections are reused
|
|
76
77
|
# across calls, reducing TCP connection overhead.
|
|
78
|
+
#
|
|
79
|
+
# When `configuration.test_mode` is set, a `Zizq::Test::Client` is
|
|
80
|
+
# returned instead — buffering enqueues in memory rather than
|
|
81
|
+
# talking to a real server.
|
|
77
82
|
def client #: () -> Client
|
|
78
83
|
@client ||= begin
|
|
79
84
|
@client_mutex.synchronize do
|
|
80
85
|
break @client if @client
|
|
81
86
|
|
|
82
87
|
configuration.validate!
|
|
83
|
-
@client =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
@client = if configuration.test_mode
|
|
89
|
+
Test::Client.new
|
|
90
|
+
else
|
|
91
|
+
Client.new(
|
|
92
|
+
url: configuration.url,
|
|
93
|
+
format: configuration.format,
|
|
94
|
+
ssl_context: configuration.ssl_context,
|
|
95
|
+
read_timeout: configuration.read_timeout,
|
|
96
|
+
stream_idle_timeout: configuration.stream_idle_timeout
|
|
97
|
+
)
|
|
98
|
+
end
|
|
90
99
|
end
|
|
91
100
|
end
|
|
92
101
|
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,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
|
data/sig/generated/zizq.rbs
CHANGED
|
@@ -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
|
+
version: 0.3.5
|
|
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-
|
|
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,8 @@ 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/test.rb
|
|
104
|
+
- lib/zizq/test/client.rb
|
|
103
105
|
- lib/zizq/tls_configuration.rb
|
|
104
106
|
- lib/zizq/version.rb
|
|
105
107
|
- lib/zizq/worker.rb
|
|
@@ -133,6 +135,8 @@ files:
|
|
|
133
135
|
- sig/generated/zizq/resources/job_template.rbs
|
|
134
136
|
- sig/generated/zizq/resources/page.rbs
|
|
135
137
|
- sig/generated/zizq/resources/resource.rbs
|
|
138
|
+
- sig/generated/zizq/test.rbs
|
|
139
|
+
- sig/generated/zizq/test/client.rbs
|
|
136
140
|
- sig/generated/zizq/tls_configuration.rbs
|
|
137
141
|
- sig/generated/zizq/version.rbs
|
|
138
142
|
- sig/generated/zizq/worker.rbs
|