zizq 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +94 -0
  4. data/bin/profile-worker +145 -0
  5. data/bin/zizq-worker +174 -0
  6. data/lib/active_job/queue_adapters/zizq_adapter.rb +109 -0
  7. data/lib/zizq/ack_processor.rb +132 -0
  8. data/lib/zizq/active_job_config.rb +122 -0
  9. data/lib/zizq/backoff.rb +50 -0
  10. data/lib/zizq/bulk_enqueue.rb +87 -0
  11. data/lib/zizq/client.rb +982 -0
  12. data/lib/zizq/configuration.rb +164 -0
  13. data/lib/zizq/enqueue_request.rb +178 -0
  14. data/lib/zizq/enqueue_with.rb +109 -0
  15. data/lib/zizq/error.rb +43 -0
  16. data/lib/zizq/job.rb +188 -0
  17. data/lib/zizq/job_config.rb +244 -0
  18. data/lib/zizq/lifecycle.rb +58 -0
  19. data/lib/zizq/middleware.rb +79 -0
  20. data/lib/zizq/query.rb +566 -0
  21. data/lib/zizq/resources/error_enumerator.rb +241 -0
  22. data/lib/zizq/resources/error_page.rb +19 -0
  23. data/lib/zizq/resources/error_record.rb +19 -0
  24. data/lib/zizq/resources/job.rb +124 -0
  25. data/lib/zizq/resources/job_page.rb +57 -0
  26. data/lib/zizq/resources/page.rb +77 -0
  27. data/lib/zizq/resources/resource.rb +45 -0
  28. data/lib/zizq/resources.rb +16 -0
  29. data/lib/zizq/version.rb +9 -0
  30. data/lib/zizq/worker.rb +467 -0
  31. data/lib/zizq.rb +269 -0
  32. data/sig/generated/zizq/ack_processor.rbs +73 -0
  33. data/sig/generated/zizq/active_job_config.rbs +74 -0
  34. data/sig/generated/zizq/backoff.rbs +34 -0
  35. data/sig/generated/zizq/bulk_enqueue.rbs +72 -0
  36. data/sig/generated/zizq/client.rbs +419 -0
  37. data/sig/generated/zizq/configuration.rbs +95 -0
  38. data/sig/generated/zizq/enqueue_request.rbs +94 -0
  39. data/sig/generated/zizq/enqueue_with.rbs +88 -0
  40. data/sig/generated/zizq/error.rbs +41 -0
  41. data/sig/generated/zizq/job.rbs +136 -0
  42. data/sig/generated/zizq/job_config.rbs +150 -0
  43. data/sig/generated/zizq/lifecycle.rbs +34 -0
  44. data/sig/generated/zizq/middleware.rbs +50 -0
  45. data/sig/generated/zizq/query.rbs +327 -0
  46. data/sig/generated/zizq/resources/error_enumerator.rbs +148 -0
  47. data/sig/generated/zizq/resources/error_page.rbs +13 -0
  48. data/sig/generated/zizq/resources/error_record.rbs +20 -0
  49. data/sig/generated/zizq/resources/job.rbs +89 -0
  50. data/sig/generated/zizq/resources/job_page.rbs +33 -0
  51. data/sig/generated/zizq/resources/page.rbs +47 -0
  52. data/sig/generated/zizq/resources/resource.rbs +26 -0
  53. data/sig/generated/zizq/version.rbs +5 -0
  54. data/sig/generated/zizq/worker.rbs +152 -0
  55. data/sig/generated/zizq.rbs +180 -0
  56. data/sig/zizq.rbs +111 -0
  57. metadata +134 -0
data/lib/zizq/query.rb ADDED
@@ -0,0 +1,566 @@
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
+ # Composable query builder for jobs in Zizq.
9
+ #
10
+ # Provides a chainable, immutable API for filtering, iterating, updating,
11
+ # and deleting jobs. Each filter method returns a new `Query` instance,
12
+ # leaving the original unchanged.
13
+ #
14
+ # `Query` is `Enumerable`— it lazily paginates through results, so
15
+ # standard Ruby methods like `count`, `map`, `select`, `first`, etc.
16
+ # work out of the box.
17
+ #
18
+ # Examples:
19
+ #
20
+ # # Count ready jobs on a queue
21
+ # Zizq::Query.new.by_queue("emails").by_status("ready").count
22
+ #
23
+ # # Move all jobs from one queue to another
24
+ # Zizq::Query.new.by_queue("old").update_all(queue: "new")
25
+ #
26
+ # # Delete dead jobs matching a payload filter
27
+ # Zizq::Query.new.by_status("dead").add_jq_filter(".user_id == 42").delete_all
28
+ #
29
+ # # Iterate in batches
30
+ # Zizq::Query.new.in_pages_of(100).each { |job| puts job.id }
31
+ #
32
+ # # Move all jobs from one queue to another in batches.
33
+ # Zizq::Query.new.by_queue("old").in_pages_of(100).update_all(queue: "new")
34
+ #
35
+ # # Find jobs by class and arguments
36
+ # Zizq::Query.new.by_job_class_and_args(SendEmailJob, 42, template: "welcome")
37
+ #
38
+ # # Find jobs by class and arguments (subset)
39
+ # Zizq::Query.new.by_job_class_and_args_subset(SendEmailJob, 42)
40
+ #
41
+ class Query
42
+ # Maximum page size the server can handle.
43
+ MAX_PAGE_SIZE = 2000 #: Integer
44
+
45
+ # @rbs skip
46
+ include Enumerable
47
+
48
+ # @rbs!
49
+ # include ::Enumerable[Zizq::Resources::Job]
50
+
51
+ # Initialize the query with some initial parameters.
52
+ #
53
+ # @rbs id: (String | Array[String])?
54
+ # @rbs queue: (String | Array[String])?
55
+ # @rbs type: (String | Array[String])?
56
+ # @rbs status: (String | Array[String])?
57
+ # @rbs jq_filter: String?
58
+ # @rbs order: Zizq::sort_direction?
59
+ # @rbs limit: Integer?
60
+ # @rbs page_size: Integer?
61
+ # @rbs return: void
62
+ def initialize(id: nil,
63
+ queue: nil,
64
+ type: nil,
65
+ status: nil,
66
+ jq_filter: nil,
67
+ order: nil,
68
+ limit: nil,
69
+ page_size: nil)
70
+ @id = id
71
+ @queue = queue
72
+ @type = type
73
+ @status = status
74
+ @jq_filter = jq_filter
75
+ @order = order
76
+ @limit = limit
77
+ @page_size = page_size
78
+ end
79
+
80
+ # Set the page size for paginated iteration.
81
+ #
82
+ # When set, `each_page` fetches pages of this size, and `each` fetches jobs
83
+ # in pages of this size. Also used by `update_all` and `delete_all` to
84
+ # batch operations by page.
85
+ #
86
+ # @rbs page_size: Integer?
87
+ # @rbs return: Query
88
+ def in_pages_of(page_size)
89
+ rebuild(page_size:)
90
+ end
91
+
92
+ # Filter by job ID (replaces any existing ID filter).
93
+ #
94
+ # @rbs id: (String | Array[String])?
95
+ # @rbs return: Query
96
+ def by_id(id)
97
+ rebuild(id:)
98
+ end
99
+
100
+ # Add a job ID to the existing ID filter.
101
+ #
102
+ # @rbs id: String | Array[String]
103
+ # @rbs return: Query
104
+ def add_id(id)
105
+ rebuild(id: Array(@id) + Array(id))
106
+ end
107
+
108
+ # Filter by queue name (replaces any existing queue filter).
109
+ #
110
+ # @rbs queue: (String | Array[String])?
111
+ # @rbs return: Query
112
+ def by_queue(queue)
113
+ rebuild(queue:)
114
+ end
115
+
116
+ # Add a queue to the existing queue filter.
117
+ #
118
+ # @rbs queue: String | Array[String]
119
+ # @rbs return: Query
120
+ def add_queue(queue)
121
+ rebuild(queue: Array(@queue) + Array(queue))
122
+ end
123
+
124
+ # Filter by job type (replaces any existing type filter).
125
+ #
126
+ # @rbs type: (String | Array[String])?
127
+ # @rbs return: Query
128
+ def by_type(type)
129
+ rebuild(type:)
130
+ end
131
+
132
+ # Add a type to the existing type filter.
133
+ #
134
+ # @rbs type: String | Array[String]
135
+ # @rbs return: Query
136
+ def add_type(type)
137
+ rebuild(type: Array(@type) + Array(type))
138
+ end
139
+
140
+ # Filter by status (replaces any existing status filter).
141
+ #
142
+ # @rbs status: (String | Array[String])
143
+ # @rbs return: Query
144
+ def by_status(status)
145
+ rebuild(status:)
146
+ end
147
+
148
+ # Add a status to the existing status filter.
149
+ #
150
+ # @rbs status: String | Array[String]
151
+ # @rbs return: Query
152
+ def add_status(status)
153
+ rebuild(status: Array(@status) + Array(status))
154
+ end
155
+
156
+ # Filter by job class and exact arguments.
157
+ #
158
+ # The job class must include `Zizq::Job` or for Active Job classes must
159
+ # extend `Zizq::ActiveJobConfig`.
160
+ #
161
+ # Sets the type filter to the class name and adds a jq payload filter
162
+ # for an exact match of the serialized arguments.
163
+ #
164
+ # @rbs job_class: Zizq::job_class
165
+ # @rbs *args: untyped
166
+ # @rbs **kwargs: untyped
167
+ # @rbs return: Query
168
+ def by_job_class_and_args(job_class, *args, **kwargs)
169
+ validate_job_class!(job_class)
170
+ name = job_class.name or raise ArgumentError, "anonymous classes are not supported"
171
+ by_type(name).add_jq_filter(job_class.zizq_payload_filter(*args, **kwargs))
172
+ end
173
+
174
+ # Filter by job class and a subset of arguments.
175
+ #
176
+ # Matches jobs whose positional args start with the given values and
177
+ # whose kwargs contain (at minimum) the given key/value pairs.
178
+ #
179
+ # The job class must include `Zizq::Job` or for Active Job classes must
180
+ # extend `Zizq::ActiveJobConfig`.
181
+ #
182
+ # @rbs job_class: Zizq::job_class
183
+ # @rbs *args: untyped
184
+ # @rbs **kwargs: untyped
185
+ # @rbs return: Query
186
+ def by_job_class_and_args_subset(job_class, *args, **kwargs)
187
+ validate_job_class!(job_class)
188
+ name = job_class.name or raise ArgumentError, "anonymous classes are not supported"
189
+ by_type(name).add_jq_filter(job_class.zizq_payload_subset_filter(*args, **kwargs))
190
+ end
191
+
192
+ # Replace the jq payload filter expression.
193
+ #
194
+ # @rbs jq_filter: String?
195
+ # @rbs return: Query
196
+ def by_jq_filter(jq_filter)
197
+ rebuild(jq_filter:)
198
+ end
199
+
200
+ # Add a jq payload filter, logically combines with any existing filter via
201
+ # "and".
202
+ #
203
+ # @rbs jq_filter: String
204
+ # @rbs return: Query
205
+ def add_jq_filter(jq_filter)
206
+ rebuild(jq_filter: [@jq_filter, "(#{jq_filter})"].compact.join(" and "))
207
+ end
208
+
209
+ # Set the sort order for iteration.
210
+ #
211
+ # @rbs order: Zizq::sort_direction?
212
+ # @rbs return: Query
213
+ def order(order)
214
+ rebuild(order:)
215
+ end
216
+
217
+ # Limit the total number of jobs returned.
218
+ #
219
+ # This is a total limit, imposed across potentially multiple page fetches.
220
+ # This limit also applies to `update_all` and `delete_all` operations.
221
+ #
222
+ # @rbs limit: Integer?
223
+ # @rbs return: Query
224
+ def limit(limit)
225
+ rebuild(limit:)
226
+ end
227
+
228
+ # Reverse the sort order.
229
+ #
230
+ # Returns a new query with the opposite order. If no order was set,
231
+ # defaults to descending (the server default is ascending).
232
+ #
233
+ # @rbs return: Query
234
+ def reverse_order
235
+ rebuild(order: @order == :desc ? :asc : :desc)
236
+ end
237
+
238
+ # Returns true if there are no matching jobs.
239
+ #
240
+ # Optimised: fetches a single job to check.
241
+ #
242
+ # @rbs return: bool
243
+ def empty?
244
+ first.nil?
245
+ end
246
+
247
+ # Returns true if there are any matching jobs.
248
+ #
249
+ # Without a block, optimised to fetch a single job. With a block,
250
+ # falls back to Enumerable (tests each job against the block).
251
+ #
252
+ # @rbs &block: ?(Resources::Job) -> bool
253
+ # @rbs return: bool
254
+ def any?
255
+ return super if block_given?
256
+
257
+ !first.nil?
258
+ end
259
+
260
+ # Returns true if there are no matching jobs.
261
+ #
262
+ # Without a block, optimised to fetch a single job. With a block,
263
+ # falls back to Enumerable (tests each job against the block).
264
+ #
265
+ # @rbs &block: ?(Resources::Job) -> bool
266
+ # @rbs return: bool
267
+ def none?
268
+ return super if block_given?
269
+
270
+ first.nil?
271
+ end
272
+
273
+ # Returns true if there is exactly one matching job.
274
+ #
275
+ # Without a block, optimised to fetch at most two jobs. With a block,
276
+ # falls back to Enumerable.
277
+ #
278
+ # @rbs &block: ?(Resources::Job) -> bool
279
+ # @rbs return: bool
280
+ def one?
281
+ return super if block_given?
282
+
283
+ limit(2).to_a.size == 1
284
+ end
285
+
286
+ # Iterate over matching jobs in reverse order.
287
+ #
288
+ # Optimised: pushes the reverse ordering to the server instead of
289
+ # fetching all jobs into memory and reversing.
290
+ #
291
+ # @rbs &block: ?(Resources::Job) -> void
292
+ # @rbs return: ::Enumerator[Zizq::Resources::Job, void]
293
+ def reverse_each(&block)
294
+ reverse_order.each(&block)
295
+ end
296
+
297
+ # Return the first matching job, or nil if none match.
298
+ #
299
+ # Optimised: fetches a single job from the server (`?limit=1`).
300
+ #
301
+ # @rbs return: Resources::Job?
302
+ def first
303
+ limit(1).each.first
304
+ end
305
+
306
+ # Return the last matching job, or nil if none match.
307
+ #
308
+ # Optimised: reverses the order and fetches a single job.
309
+ #
310
+ # @rbs return: Resources::Job?
311
+ def last
312
+ reverse_order.first
313
+ end
314
+
315
+ # Return the first `n` matching jobs.
316
+ #
317
+ # Optimised: sets the limit to `n` so the server only returns what's
318
+ # needed.
319
+ #
320
+ # @rbs n: Integer
321
+ # @rbs return: Array[Resources::Job]
322
+ def take(n)
323
+ limit(n).to_a
324
+ end
325
+
326
+ # Update the first matching job.
327
+ #
328
+ # Returns 1 if a job was updated, 0 if no jobs matched.
329
+ #
330
+ # @rbs queue: (String | singleton(Zizq::UNCHANGED))?
331
+ # @rbs priority: (Integer | singleton(Zizq::UNCHANGED))?
332
+ # @rbs ready_at: (Zizq::to_f | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
333
+ # @rbs retry_limit: (Integer | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
334
+ # @rbs backoff: (Zizq::backoff | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
335
+ # @rbs retention: (Zizq::retention | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
336
+ # @rbs return: Integer
337
+ def update_one(...)
338
+ limit(1).update_all(...)
339
+ end
340
+
341
+ # Delete the first matching job.
342
+ #
343
+ # Returns 1 if a job was deleted, 0 if no jobs matched.
344
+ #
345
+ # @rbs return: Integer
346
+ def delete_one
347
+ limit(1).delete_all
348
+ end
349
+
350
+ # Iterate over matching jobs, lazily paginating through results.
351
+ #
352
+ # Respects `limit` if set. Without a block, returns an `Enumerator`.
353
+ #
354
+ # @rbs &block: ?(Resources::Job) -> void
355
+ # @rbs return: ::Enumerator[Zizq::Resources::Job, void]
356
+ def each(&block)
357
+ enumerator = enum_for(:each)
358
+
359
+ if block_given?
360
+ remaining = @limit
361
+
362
+ each_page do |page|
363
+ page.jobs.each do |job|
364
+ if remaining
365
+ break if remaining <= 0
366
+ end
367
+
368
+ yield job
369
+
370
+ remaining -= 1 if remaining
371
+ end
372
+ end
373
+ end
374
+
375
+ enumerator
376
+ end
377
+
378
+ # Iterate over pages of matching jobs.
379
+ #
380
+ # Each page is a `Resources::JobPage`. Without a block, returns an
381
+ # `Enumerator`.
382
+ #
383
+ # If `limit` is set, terminates after the last page is reached that exceeds
384
+ # the limit, but does not truncate the page.
385
+ #
386
+ # @rbs &block: ?(Resources::JobPage) -> void
387
+ # @rbs return: ::Enumerator[Zizq::Resources::JobPage, void]
388
+ def each_page(&block)
389
+ enumerator = enum_for(:each_page)
390
+
391
+ if block_given?
392
+ page = Zizq.client.list_jobs(
393
+ id: @id,
394
+ queue: @queue,
395
+ type: @type,
396
+ status: @status,
397
+ filter: @jq_filter,
398
+ limit: [@page_size, @limit, (@page_size || @limit) && MAX_PAGE_SIZE].compact.min,
399
+ order: @order,
400
+ )
401
+
402
+ remaining = @limit
403
+
404
+ while page
405
+ yield page
406
+
407
+ if remaining
408
+ remaining -= page.jobs.size
409
+ break if remaining <= 0
410
+ end
411
+
412
+ page = page.next_page
413
+ end
414
+ end
415
+
416
+ enumerator
417
+ end
418
+
419
+ # Update all matching jobs with the given field values.
420
+ #
421
+ # When `page_size` or `limit` is set, iterates page by page and
422
+ # issues a bulk update per page using the job IDs on that page. For safety
423
+ # query parameters are included in the scope along with all IDs. Otherwise,
424
+ # issues a single bulk update with the query parameters.
425
+ #
426
+ # Returns the total number of updated jobs.
427
+ #
428
+ # @rbs queue: (String | singleton(Zizq::UNCHANGED))?
429
+ # @rbs priority: (Integer | singleton(Zizq::UNCHANGED))?
430
+ # @rbs ready_at: (Zizq::to_f | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
431
+ # @rbs retry_limit: (Integer | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
432
+ # @rbs backoff: (Zizq::backoff | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
433
+ # @rbs retention: (Zizq::retention | singleton(Zizq::RESET) | singleton(Zizq::UNCHANGED))?
434
+ # @rbs return: Integer
435
+ def update_all(queue: Zizq::UNCHANGED,
436
+ priority: Zizq::UNCHANGED,
437
+ ready_at: Zizq::UNCHANGED,
438
+ retry_limit: Zizq::UNCHANGED,
439
+ backoff: Zizq::UNCHANGED,
440
+ retention: Zizq::UNCHANGED)
441
+ where = {
442
+ id: @id,
443
+ queue: @queue,
444
+ type: @type,
445
+ status: @status,
446
+ filter: @jq_filter,
447
+ }
448
+
449
+ apply = {
450
+ queue:,
451
+ priority:,
452
+ ready_at:,
453
+ retry_limit:,
454
+ backoff:,
455
+ retention:,
456
+ }
457
+
458
+ if @limit || @page_size
459
+ remaining = @limit
460
+ updated = 0
461
+
462
+ each_page do |page|
463
+ if remaining
464
+ break if remaining <= 0
465
+ end
466
+
467
+ ids_on_page = page.jobs.map(&:id)
468
+ ids_on_page = ids_on_page.take(remaining) if remaining
469
+
470
+ updated += Zizq.client.update_all_jobs(
471
+ where: where.merge(id: ids_on_page),
472
+ apply:,
473
+ )
474
+
475
+ remaining -= ids_on_page.size if remaining
476
+ end
477
+
478
+ updated
479
+ else
480
+ Zizq.client.update_all_jobs(where:, apply:)
481
+ end
482
+ end
483
+
484
+ # Delete all matching jobs.
485
+ #
486
+ # When `page_size` or `limit` is set, iterates page by page and
487
+ # issues a bulk delete per page using the job IDs on that page. For safety
488
+ # query parameters are included in the scope along with all IDs. Otherwise,
489
+ # issues a single bulk delete with the query filters.
490
+ #
491
+ # When called in a bare query, this deletes *all* jobs from the server,
492
+ # which is useful in tests.
493
+ #
494
+ # Returns the total number of deleted jobs.
495
+ #
496
+ # @rbs return: Integer
497
+ def delete_all
498
+ where = {
499
+ id: @id,
500
+ queue: @queue,
501
+ type: @type,
502
+ status: @status,
503
+ filter: @jq_filter,
504
+ }
505
+
506
+ if @limit || @page_size
507
+ remaining = @limit
508
+ deleted = 0
509
+
510
+ each_page do |page|
511
+ if remaining
512
+ break if remaining <= 0
513
+ end
514
+
515
+ ids_on_page = page.jobs.map(&:id)
516
+ ids_on_page = ids_on_page.take(remaining) if remaining
517
+
518
+ deleted += Zizq.client.delete_all_jobs(
519
+ where: where.merge(id: ids_on_page),
520
+ )
521
+
522
+ remaining -= ids_on_page.size if remaining
523
+ end
524
+
525
+ deleted
526
+ else
527
+ Zizq.client.delete_all_jobs(where:)
528
+ end
529
+ end
530
+
531
+ private
532
+
533
+ # Build a new Query with the given overrides, preserving all other fields.
534
+ #
535
+ # @rbs return: Query
536
+ def rebuild(id: @id,
537
+ queue: @queue,
538
+ type: @type,
539
+ status: @status,
540
+ jq_filter: @jq_filter,
541
+ order: @order,
542
+ limit: @limit,
543
+ page_size: @page_size)
544
+ self.class.new(
545
+ id:,
546
+ queue:,
547
+ type:,
548
+ status:,
549
+ jq_filter:,
550
+ limit:,
551
+ order:,
552
+ page_size:,
553
+ )
554
+ end
555
+
556
+ # @rbs job_class: untyped
557
+ # @rbs return: void
558
+ def validate_job_class!(job_class)
559
+ unless job_class.is_a?(JobConfig)
560
+ raise ArgumentError,
561
+ "#{job_class} does not include Zizq::JobConfig " \
562
+ "(include Zizq::Job or extend Zizq::ActiveJobConfig)"
563
+ end
564
+ end
565
+ end
566
+ end