zizq 0.2.1 → 0.3.1

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: de36f4f8204a5d5b0c64e77b78249ed34ea6c667ceb6b03dbdca2a291cb0f330
4
- data.tar.gz: 7cbb62aac1788627bb1047b474e5ca915c5dca059bc6f00be7a2f5adca798a7a
3
+ metadata.gz: 8f79a2e4601f42c8c9334601383a3659ffa5063a5df3ce2575b1d56b4df17a89
4
+ data.tar.gz: 730dbf9a98a6bb877234f3821e099e0363c5ebfad4905622106fa19b17db6610
5
5
  SHA512:
6
- metadata.gz: 95b80cec6e0bb1704bd1244b37c4434b5d5467139e35c61bae39f342825a3e136a413aa55c0a0b7de379024bcdee3486e08cb211062a9d406011093a68de2899
7
- data.tar.gz: '09952b2563e139e9987473b53524ea993f1c803ea0320082c166132053919f3401cad65c8dc063923837c3b1fe8a505e3e27dcd9ce6a20724313c1d6f82c00e3'
6
+ metadata.gz: 49f4a260cfbc35570e10e8fa0811e922be0c826ac0126f20e530353ae4c58df13e5f1cf8637d0a2f7a799ec3a0c64d9082c1a66d859de21b748db05e2db22802
7
+ data.tar.gz: 0e09b0848550330989701c703c5640e3df46e9e8a4229e3e15de99f033975356e24abee2e99fe537ed5b80c8a6edbfc9e302b518844ca9a6d99ce60766af5ccb
data/README.md CHANGED
@@ -7,6 +7,7 @@ API.
7
7
  This is the official Zizq client library for Ruby.
8
8
 
9
9
  [![CI](https://github.com/zizq-labs/zizq-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/zizq-labs/zizq-ruby/actions/workflows/ci.yml)
10
+ [![Gem Version](https://img.shields.io/gem/v/zizq.svg)](https://rubygems.org/gems/zizq)
10
11
 
11
12
  ## Features
12
13
 
@@ -18,17 +19,66 @@ This is the official Zizq client library for Ruby.
18
19
  * Scheduled jobs
19
20
  * Configurable backoff policies
20
21
  * Configurable job retention policies
22
+ * Recurring jobs (cron)
21
23
  * Job introspection and management APIs, with support for `jq` query filters
22
24
  * Unique jobs
23
25
 
24
- ## Example
26
+ ## Installation
27
+
28
+ > [!NOTE]
29
+ > If you have not yet installed the Zizq server, follow the
30
+ > [Getting Started](https://zizq.io/docs/getting-started) guide first.
31
+
32
+ Add it to your application's `Gemfile`:
33
+
34
+ ```ruby
35
+ gem 'zizq', '~> 0.3.1'
36
+ ```
37
+
38
+ Or install it manually:
39
+
40
+ ```shell
41
+ $ gem install zizq -v 0.3.1
42
+ ```
43
+
44
+ Ruby **3.2.8 or newer** is required. Client and server share version
45
+ numbers — keep the client's major/minor at or below the server's.
46
+
47
+ ## Configuration
48
+
49
+ Out of the box, the client talks to a server at `http://localhost:7890` —
50
+ fine for local development. For anything else, configure it with
51
+ `Zizq.configure` in your application's bootstrap (e.g. a Rails initializer):
52
+
53
+ ```ruby
54
+ require 'zizq'
55
+
56
+ Zizq.configure do |c|
57
+ c.url = 'https://zizq.your.network:7890'
58
+ c.tls = { ca: '/path/to/server-ca-cert.pem' }
59
+ c.logger = Logger.new('log/zizq.log')
60
+ end
61
+ ```
62
+
63
+ For mutual TLS, add `client_cert:` and `client_key:` to the `tls` hash.
64
+
65
+ > [!CAUTION]
66
+ > If your server is exposed directly to the internet, it should require
67
+ > mutual TLS — otherwise anybody can talk to it.
68
+
69
+ ## Usage
25
70
 
26
71
  > [!TIP]
27
- > The client is very flexible and supports being used in a range of different
28
- > ways. Read the [full documentation](https://zizq.io/docs/clients/ruby/) on
29
- > the website for more details.
72
+ > This README is an overview. The
73
+ > [full documentation](https://zizq.io/docs/clients/ruby/) covers each
74
+ > feature in depth middleware, custom dispatchers, Active Job, job
75
+ > querying, and more.
76
+
77
+ ### Defining a job
30
78
 
31
- Mixin-based job class.
79
+ In most Ruby applications, a job is a plain class that includes `Zizq::Job`.
80
+ The class declares its defaults with the `zizq_*` DSL and implements
81
+ `#perform`:
32
82
 
33
83
  ```ruby
34
84
  class SendEmailJob
@@ -36,54 +86,133 @@ class SendEmailJob
36
86
 
37
87
  zizq_queue 'emails'
38
88
  zizq_priority 100
89
+ zizq_retry_limit 5
39
90
 
40
91
  def perform(user_id, template:)
41
- # your application logic here
92
+ user = User.find(user_id)
93
+ Mailer.deliver(user, template)
42
94
  end
43
95
  end
44
96
  ```
45
97
 
46
- Enqueueing a job.
98
+ Every default — `zizq_queue`, `zizq_priority`, `zizq_retry_limit`,
99
+ `zizq_backoff`, `zizq_retention`, `zizq_unique` — can be overridden per
100
+ enqueue. The job's class name (`"SendEmailJob"`) becomes the API-level job
101
+ type, so keep it stable once jobs are in flight.
102
+
103
+ ### Enqueuing jobs
104
+
105
+ Enqueue a job by passing the class and the arguments your `#perform` method
106
+ expects:
47
107
 
48
108
  ```ruby
49
- Zizq.enqueue(SendEmailJob, 42, template: 'welcome')
109
+ job = Zizq.enqueue(SendEmailJob, 42, template: 'welcome')
110
+ job.id # => "03fu0wm75gxgmfyfplwvazhex"
111
+ ```
112
+
113
+ Override defaults for a single call with `Zizq.enqueue_with`, or with a block
114
+ that mutates the request:
115
+
116
+ ```ruby
117
+ # Don't retry this one.
118
+ Zizq.enqueue_with(retry_limit: 0).enqueue(SendEmailJob, 42, template: 'welcome')
119
+
120
+ # Bump the priority via the block form.
121
+ Zizq.enqueue(SendEmailJob, 42, template: 'welcome') do |req|
122
+ req.priority = 1000
123
+ end
124
+ ```
125
+
126
+ Schedule a job for later with `delay` (seconds from now) or an absolute
127
+ `ready_at`:
128
+
129
+ ```ruby
130
+ Zizq.enqueue_with(delay: 3600).enqueue(SendEmailJob, 42, template: 'welcome')
131
+ Zizq.enqueue_with(ready_at: Time.new(2027, 3, 15, 14, 30)).enqueue(SendEmailJob, 42, template: 'welcome')
132
+ ```
133
+
134
+ To enqueue many jobs efficiently, `Zizq.enqueue_bulk` sends them in a single
135
+ atomic request — across queues and job types, and `enqueue_raw` enqueues can
136
+ be mixed in too:
137
+
138
+ ```ruby
139
+ Zizq.enqueue_bulk do |b|
140
+ signups.each { |user_id| b.enqueue(SendEmailJob, user_id, template: 'welcome') }
141
+ end
50
142
  ```
51
143
 
52
144
  > [!NOTE]
53
- > Jobs can also be enqueued and processed without `Zizq::Job`, which is
54
- > designed to support interoperability with any programming language.
145
+ > Jobs can also be enqueued without `Zizq::Job` via `Zizq.enqueue_raw` —
146
+ > designed for cross-language workflows where, for example, a Ruby app
147
+ > enqueues jobs consumed by a Go service.
148
+
149
+ ### Running a worker
55
150
 
56
- Using the included `zizq-worker` executable.
151
+ Jobs are processed by a worker, typically in a separate process. The simplest
152
+ way is the `zizq-worker` executable bundled with the gem — pass your
153
+ application's entrypoint so it can load your job classes:
57
154
 
58
155
  ```shell
59
- $ zizq-worker --threads 5 --fibers 2 app.rb
60
- I, [2026-03-24T15:25:57.738131 #1331422] INFO -- : Zizq worker starting: 5 threads, 2 fibers, prefetch=20
61
- I, [2026-03-24T15:25:57.738222 #1331422] INFO -- : Queues: (all)
62
- I, [2026-03-24T15:25:57.739861 #1331422] INFO -- : Worker 0:0 started
63
- I, [2026-03-24T15:25:57.739962 #1331422] INFO -- : Worker 0:1 started
64
- I, [2026-03-24T15:25:57.740131 #1331422] INFO -- : Worker 1:0 started
65
- I, [2026-03-24T15:25:57.740211 #1331422] INFO -- : Worker 1:1 started
66
- I, [2026-03-24T15:25:57.740352 #1331422] INFO -- : Worker 2:0 started
67
- I, [2026-03-24T15:25:57.740408 #1331422] INFO -- : Worker 2:1 started
68
- I, [2026-03-24T15:25:57.740532 #1331422] INFO -- : Worker 3:0 started
69
- I, [2026-03-24T15:25:57.740590 #1331422] INFO -- : Worker 3:1 started
70
- I, [2026-03-24T15:25:57.740722 #1331422] INFO -- : Worker 4:0 started
71
- I, [2026-03-24T15:25:57.740776 #1331422] INFO -- : Worker 4:1 started
72
- I, [2026-03-24T15:25:57.740844 #1331422] INFO -- : Zizq producer thread started
73
- I, [2026-03-24T15:25:57.740878 #1331422] INFO -- : Connecting to http://localhost:7890...
74
- I, [2026-03-24T15:25:57.792173 #1331422] INFO -- : Connected. Listening for jobs.
156
+ $ bundle exec zizq-worker --threads 5 --fibers 2 config/environment.rb
157
+ I, [...] INFO -- : Zizq worker starting: 5 threads, 2 fibers, prefetch=20
158
+ I, [...] INFO -- : Connected. Listening for jobs.
75
159
  ```
76
160
 
77
- > [!NOTE]
78
- > Workers can also be created directly in code. There is no requirement to use
79
- > `zizq-worker`.
161
+ A worker runs `threads × fibers` handlers concurrently. Leave `--fibers 1`
162
+ if your application isn't fiber-safe no `Async` context is loaded in that
163
+ case. Restrict to specific queues with `--queue`. `INT` / `TERM` trigger a
164
+ graceful shutdown (drains in-flight jobs up to `--shutdown-timeout`, default
165
+ 30s).
166
+
167
+ For more control — for example running the worker in-process alongside a
168
+ Rack app — construct `Zizq::Worker` directly:
169
+
170
+ ```ruby
171
+ require 'zizq'
172
+
173
+ worker = Zizq::Worker.new(
174
+ thread_count: 5,
175
+ fiber_count: 2,
176
+ queues: ['emails', 'payments'],
177
+ )
178
+
179
+ Signal.trap('INT') { worker.stop }
180
+ worker.run # blocks until the worker stops
181
+ ```
182
+
183
+ `#run` blocks until the worker terminates; `#stop` drains in-flight jobs
184
+ gracefully, `#kill` forces an immediate stop. On any unclean shutdown the
185
+ server returns unfinished jobs to the queue — no job is lost.
186
+
187
+ ### Recurring jobs (cron)
188
+
189
+ Define a cron schedule in your application's startup code. Definitions are
190
+ idempotent — every process can safely define the same schedule, and Zizq
191
+ keeps the server in sync by adding, replacing, and removing entries as the
192
+ definition changes. Cron requires a Pro license on the server.
193
+
194
+ ```ruby
195
+ Zizq.define_crontab('maintenance', timezone: 'Europe/London') do |cron|
196
+ # Every 15 minutes.
197
+ cron.define_entry('refresh_warehouse', '*/15 * * * *').enqueue(
198
+ RefreshWarehouseJob, incremental: true
199
+ )
200
+
201
+ # 9am London time, every day.
202
+ cron.define_entry('daily_digest', '0 9 * * *').enqueue(SendDailyDigestJob)
203
+ end
204
+ ```
205
+
206
+ Once defined, schedules can be inspected and managed via
207
+ `Zizq.crontab('maintenance')` — paused/resumed at the schedule level or per
208
+ entry, and deleted entirely when no longer needed.
80
209
 
81
210
  ## Resources
82
211
 
83
212
  * [Ruby Client Docs](https://zizq.io/docs/clients/ruby/)
84
213
  * [Getting Started Docs](https://zizq.io/docs/getting-started/)
85
214
  * [Zizq Command Reference](https://zizq.io/docs/cli/)
86
- * [Zizq Node.js Client Source](https://github.com/zizq-labs/zizq-node)
215
+ * [Zizq Ruby Client Source](https://github.com/zizq-labs/zizq-ruby)
87
216
  * [Zizq Source](https://github.com/zizq-labs/zizq)
88
217
 
89
218
  ## Support & Feedback
@@ -92,3 +221,7 @@ If you need help using Zizq,
92
221
  [create an issue](https://github.com/zizq-labs/zizq-ruby/issues) on the
93
222
  [zizq-ruby](https://github.com/zizq-labs/zizq-ruby) repo. Feedback is very
94
223
  welcome.
224
+
225
+ ## License
226
+
227
+ MIT — see [LICENSE](LICENSE).
@@ -22,7 +22,7 @@ module Zizq
22
22
  # Collect a job class enqueue. Accepts the same arguments as
23
23
  # `Zizq.enqueue`.
24
24
  #
25
- # @rbs job_class: Class & Zizq::job_class
25
+ # @rbs job_class: Class & Zizq::JobConfig
26
26
  # @rbs args: Array[untyped]
27
27
  # @rbs kwargs: Hash[Symbol, untyped]
28
28
  # @rbs &block: ?(EnqueueRequest) -> void
data/lib/zizq/client.rb CHANGED
@@ -50,15 +50,30 @@ module Zizq
50
50
  # Initialize a new instance of the client with the given base URL and
51
51
  # optional format options.
52
52
  #
53
+ # `read_timeout` and `stream_idle_timeout` are per-operation socket
54
+ # I/O timeouts (seconds). Each individual socket read/write is
55
+ # bounded by the timeout. The streaming `#take_jobs` endpoint uses
56
+ # `stream_idle_timeout` because the server sends heartbeats at
57
+ # periodic intervals which keeps the connection alive.
58
+ #
53
59
  # @rbs url: String
54
60
  # @rbs format: Zizq::format
55
61
  # @rbs ssl_context: OpenSSL::SSL::SSLContext?
62
+ # @rbs read_timeout: Numeric
63
+ # @rbs stream_idle_timeout: Numeric
56
64
  # @rbs return: void
57
- def initialize(url:, format: :msgpack, ssl_context: nil)
65
+ def initialize(url:,
66
+ format: :msgpack,
67
+ ssl_context: nil,
68
+ read_timeout: 30,
69
+ stream_idle_timeout: 30)
58
70
  @url = url.chomp("/")
59
71
  @format = format
60
72
 
61
- endpoint_options = { protocol: Async::HTTP::Protocol::HTTP2 } #: Hash[Symbol, untyped]
73
+ endpoint_options = {
74
+ protocol: Async::HTTP::Protocol::HTTP2,
75
+ timeout: read_timeout,
76
+ } #: Hash[Symbol, untyped]
62
77
  endpoint_options[:ssl_context] = ssl_context if ssl_context
63
78
 
64
79
  @endpoint = Async::HTTP::Endpoint.parse(
@@ -73,8 +88,14 @@ module Zizq
73
88
  # on separate threads with their own HTTP/2 clients, so they're
74
89
  # unaffected either way. HTTP/1.1 gives the stream a plain TCP
75
90
  # socket with no framing tax and measurably better throughput.
91
+ #
92
+ # The stream endpoint uses `stream_idle_timeout` for its socket
93
+ # timeout so server heartbeats (~3s) keep it alive while only
94
+ # genuinely dead connections (no data for the full window)
95
+ # trigger a reconnect.
76
96
  stream_endpoint_options = endpoint_options.merge(
77
97
  protocol: Async::HTTP::Protocol::HTTP11,
98
+ timeout: stream_idle_timeout,
78
99
  )
79
100
  @stream_endpoint = Async::HTTP::Endpoint.parse(
80
101
  @url,
@@ -204,7 +225,7 @@ module Zizq
204
225
 
205
226
  # Get a single job by ID.
206
227
  def get_job(id) #: (String) -> Resources::Job
207
- response = get("/jobs/#{id}")
228
+ response = get("/jobs/#{enc(id)}")
208
229
  data = handle_response!(response, expected: 200)
209
230
  Resources::Job.new(self, data)
210
231
  end
@@ -285,7 +306,7 @@ module Zizq
285
306
  # @rbs id: String
286
307
  # @rbs return: void
287
308
  def delete_job(id)
288
- response = delete("/jobs/#{id}")
309
+ response = delete("/jobs/#{enc(id)}")
289
310
  handle_response!(response, expected: [200, 204])
290
311
  nil
291
312
  end
@@ -342,7 +363,7 @@ module Zizq
342
363
  queue:, priority:, ready_at:,
343
364
  retry_limit:, backoff:, retention:
344
365
  )
345
- response = patch("/jobs/#{id}", body)
366
+ response = patch("/jobs/#{enc(id)}", body)
346
367
  data = handle_response!(response, expected: 200)
347
368
  Resources::Job.new(self, data)
348
369
  end
@@ -383,7 +404,7 @@ module Zizq
383
404
  # @rbs attempt: Integer
384
405
  # @rbs return: Resources::ErrorRecord
385
406
  def get_error(id, attempt:)
386
- response = get("/jobs/#{id}/errors/#{attempt}")
407
+ response = get("/jobs/#{enc(id)}/errors/#{enc(attempt.to_s)}")
387
408
  data = handle_response!(response, expected: 200)
388
409
  Resources::ErrorRecord.new(self, data)
389
410
  end
@@ -397,7 +418,7 @@ module Zizq
397
418
  # @rbs return: Resources::ErrorPage
398
419
  def list_errors(id, from: nil, order: nil, limit: nil)
399
420
  params = { from:, order:, limit: }.compact #: Hash[Symbol, untyped]
400
- response = get("/jobs/#{id}/errors", params:)
421
+ response = get("/jobs/#{enc(id)}/errors", params:)
401
422
  data = handle_response!(response, expected: 200)
402
423
  Resources::ErrorPage.new(self, data)
403
424
  end
@@ -422,6 +443,136 @@ module Zizq
422
443
  data["queues"]
423
444
  end
424
445
 
446
+ # List all cron group names.
447
+ #
448
+ # @rbs return: Array[String]
449
+ def list_cron_groups
450
+ response = get("/crons")
451
+ data = handle_response!(response, expected: 200)
452
+ data["crons"]
453
+ end
454
+
455
+ # Fetch a cron group and all its entries.
456
+ #
457
+ # @rbs name: String
458
+ # @rbs return: Resources::CronGroup
459
+ def get_cron_group(name)
460
+ response = get("/crons/#{enc(name)}")
461
+ data = handle_response!(response, expected: 200)
462
+ Resources::CronGroup.new(self, data)
463
+ end
464
+
465
+ # Create or replace an entire cron group.
466
+ #
467
+ # Entries not present in the request are removed. Entries with unchanged
468
+ # expressions preserve their scheduling state.
469
+ #
470
+ # @rbs name: String
471
+ # @rbs paused: bool?
472
+ # @rbs entries: Array[Zizq::cron_entry_params]
473
+ # @rbs return: Resources::CronGroup
474
+ def replace_cron_group(name, paused: nil, entries: [])
475
+ body = {
476
+ paused:,
477
+ entries: entries.map { |entry| build_cron_entry(**entry) }
478
+ }.compact
479
+ response = put("/crons/#{enc(name)}", body)
480
+ data = handle_response!(response, expected: 200)
481
+ Resources::CronGroup.new(self, data)
482
+ end
483
+
484
+ # Update group-level fields (currently just pause/unpause).
485
+ #
486
+ # @rbs name: String
487
+ # @rbs paused: bool?
488
+ # @rbs return: Resources::CronGroup
489
+ def update_cron_group(name, paused: nil)
490
+ response = patch("/crons/#{enc(name)}", { paused: }.compact)
491
+ data = handle_response!(response, expected: 200)
492
+ Resources::CronGroup.new(self, data)
493
+ end
494
+
495
+ # Delete a cron group and all its entries.
496
+ #
497
+ # @rbs name: String
498
+ # @rbs return: void
499
+ def delete_cron_group(name)
500
+ response = delete("/crons/#{enc(name)}")
501
+ handle_response!(response, expected: 204)
502
+ nil
503
+ end
504
+
505
+ # Fetch a single cron entry.
506
+ #
507
+ # @rbs group: String
508
+ # @rbs entry: String
509
+ # @rbs return: Resources::CronEntry
510
+ def get_cron_group_entry(group, entry)
511
+ response = get("/crons/#{enc(group)}/entries/#{enc(entry)}")
512
+ data = handle_response!(response, expected: 200)
513
+ Resources::CronEntry.new(self, data)
514
+ end
515
+
516
+ # Add a single entry to a cron group (creates the group if needed).
517
+ #
518
+ # Raises a ClientError (409 Conflict) if an entry with the same name
519
+ # already exists.
520
+ #
521
+ # @rbs group: String
522
+ # @rbs name: String
523
+ # @rbs expression: String
524
+ # @rbs job: Zizq::cron_job_params
525
+ # @rbs timezone: String?
526
+ # @rbs paused: bool?
527
+ # @rbs return: Resources::CronEntry
528
+ def add_cron_group_entry(group, name:, expression:, job:, timezone: nil, paused: nil)
529
+ body = build_cron_entry(name:, expression:, job:, timezone:, paused:)
530
+ response = post("/crons/#{enc(group)}/entries", body)
531
+ data = handle_response!(response, expected: 201)
532
+ Resources::CronEntry.new(self, data)
533
+ end
534
+
535
+ # Create or replace a single cron entry.
536
+ #
537
+ # Preserves scheduling state if the expression is unchanged.
538
+ #
539
+ # @rbs group: String
540
+ # @rbs entry: String
541
+ # @rbs expression: String
542
+ # @rbs job: Zizq::cron_job_params
543
+ # @rbs timezone: String?
544
+ # @rbs paused: bool?
545
+ # @rbs return: Resources::CronEntry
546
+ def replace_cron_group_entry(group, entry, expression:, job:, timezone: nil, paused: nil)
547
+ body = build_cron_entry(name: entry, expression:, job:, timezone:, paused:)
548
+ response = put("/crons/#{enc(group)}/entries/#{enc(entry)}", body)
549
+ data = handle_response!(response, expected: 200)
550
+ Resources::CronEntry.new(self, data)
551
+ end
552
+
553
+ # Update entry-level fields (currently just pause/unpause).
554
+ #
555
+ # @rbs group: String
556
+ # @rbs entry: String
557
+ # @rbs paused: bool
558
+ # @rbs return: Resources::CronEntry
559
+ def update_cron_group_entry(group, entry, paused:)
560
+ response = patch("/crons/#{enc(group)}/entries/#{enc(entry)}", { paused: })
561
+ data = handle_response!(response, expected: 200)
562
+ Resources::CronEntry.new(self, data)
563
+ end
564
+
565
+ # Delete a single cron entry.
566
+ #
567
+ # @rbs group: String
568
+ # @rbs entry: String
569
+ # @rbs return: void
570
+ def delete_cron_group_entry(group, entry)
571
+ response = delete("/crons/#{enc(group)}/entries/#{enc(entry)}")
572
+ handle_response!(response, expected: 204)
573
+ nil
574
+ end
575
+
425
576
  # Mark a job as successfully completed (ack).
426
577
  #
427
578
  # If this method (or [`#report_failure`]) is not called upon job
@@ -439,7 +590,7 @@ module Zizq
439
590
  # The Zizq server sends heartbeat messages to connected workers so that
440
591
  # it can quickly detect and handle disconnected clients.
441
592
  def report_success(id) #: (String) -> nil
442
- response = raw_post("/jobs/#{id}/success")
593
+ response = raw_post("/jobs/#{enc(id)}/success")
443
594
  handle_response!(response, expected: 204)
444
595
  nil
445
596
  end
@@ -503,7 +654,7 @@ module Zizq
503
654
  body[:retry_at] = (retry_at * 1000).to_i if retry_at
504
655
  body[:kill] = kill if kill
505
656
 
506
- response = post("/jobs/#{id}/failure", body)
657
+ response = post("/jobs/#{enc(id)}/failure", body)
507
658
  data = handle_response!(response, expected: 200)
508
659
  Resources::Job.new(self, data)
509
660
  end
@@ -579,7 +730,12 @@ module Zizq
579
730
  response.close rescue nil
580
731
  end
581
732
  end
582
- rescue SocketError, IOError, EOFError, Errno::ECONNRESET, Errno::EPIPE,
733
+ rescue SocketError,
734
+ IOError,
735
+ EOFError,
736
+ Errno::ECONNRESET,
737
+ Errno::EPIPE,
738
+ IO::TimeoutError,
583
739
  OpenSSL::SSL::SSLError => e
584
740
  raise ConnectionError, e.message
585
741
  end
@@ -660,6 +816,11 @@ module Zizq
660
816
 
661
817
  private
662
818
 
819
+ # URL-encode a single path segment.
820
+ def enc(value) #: (String) -> String
821
+ URI.encode_uri_component(value)
822
+ end
823
+
663
824
  # Build a relative path with optional query parameters.
664
825
  def build_path(path, params: {}) #: (String, ?params: Hash[Symbol, untyped]) -> String
665
826
  unless params.empty?
@@ -668,6 +829,57 @@ module Zizq
668
829
  path
669
830
  end
670
831
 
832
+ # Validate and build a cron entry body from keyword arguments.
833
+ #
834
+ # @rbs name: String
835
+ # @rbs expression: String
836
+ # @rbs job: Zizq::cron_job_params
837
+ # @rbs timezone: String?
838
+ # @rbs paused: bool?
839
+ # @rbs return: Hash[Symbol, untyped]
840
+ def build_cron_entry(name: nil, expression: nil, job: nil, timezone: nil, paused: nil)
841
+ {
842
+ name:,
843
+ expression:,
844
+ timezone:,
845
+ paused:,
846
+ job: build_cron_job(**job),
847
+ }.compact
848
+ end
849
+
850
+ # Validate and build a cron job template from keyword arguments.
851
+ #
852
+ # Uses keyword args so that unknown keys raise ArgumentError.
853
+ #
854
+ # @rbs type: String
855
+ # @rbs queue: String
856
+ # @rbs payload: untyped
857
+ # @rbs priority: Integer?
858
+ # @rbs retry_limit: Integer?
859
+ # @rbs backoff: Zizq::backoff?
860
+ # @rbs retention: Zizq::retention?
861
+ # @rbs unique_key: String?
862
+ # @rbs unique_while: Zizq::unique_scope?
863
+ # @rbs return: Hash[Symbol, untyped]
864
+ def build_cron_job(type: nil,
865
+ queue: nil,
866
+ payload: nil,
867
+ priority: nil,
868
+ retry_limit: nil,
869
+ backoff: nil,
870
+ retention: nil,
871
+ unique_key: nil,
872
+ unique_while: nil)
873
+ job = { type:, queue:, payload: } #: Hash[Symbol, untyped]
874
+ job[:priority] = priority if priority
875
+ job[:retry_limit] = retry_limit if retry_limit
876
+ job[:backoff] = backoff if backoff
877
+ job[:retention] = retention if retention
878
+ job[:unique_key] = unique_key if unique_key
879
+ job[:unique_while] = unique_while.to_s if unique_while
880
+ job
881
+ end
882
+
671
883
  # Validate and normalize filter parameters for bulk operations.
672
884
  #
673
885
  # Uses keyword arguments so that unknown keys raise ArgumentError.
@@ -942,6 +1154,18 @@ module Zizq
942
1154
  end
943
1155
  end
944
1156
 
1157
+ def put(path, body) #: (String, Hash[Symbol, untyped]) -> RawResponse
1158
+ request do |http|
1159
+ consume_response(
1160
+ http.put(
1161
+ build_path(path),
1162
+ {"content-type" => @content_type, "accept" => @content_type},
1163
+ Protocol::HTTP::Body::Buffered.wrap(encode_body(body))
1164
+ )
1165
+ )
1166
+ end
1167
+ end
1168
+
945
1169
  def delete(path, params: {}) #: (String, ?params: Hash[Symbol, untyped]) -> RawResponse
946
1170
  request do |http|
947
1171
  consume_response(
@@ -42,6 +42,27 @@ module Zizq
42
42
  # Note: Mutual TLS support requires a Zizq Pro license on the server.
43
43
  attr_accessor :tls #: Zizq::tls_options?
44
44
 
45
+ # Per-operation socket I/O timeout (seconds) for regular API calls
46
+ # (enqueue, queries, mutations). Each socket read/write is bounded
47
+ # by this value. A request whose handshake or any single read exceeds
48
+ # this raises `IO::TimeoutError`.
49
+ #
50
+ # Default: 30.
51
+ attr_accessor :read_timeout #: Numeric
52
+
53
+ # Per-operation socket I/O timeout (seconds) for the long-lived
54
+ # `#take_jobs` stream. The server sends heartbeats every ~3 seconds,
55
+ # so each read returns within that window and keeps the connection
56
+ # alive; the connection only times out if the server falls silent for
57
+ # longer than this. The Worker catches the resulting error and
58
+ # reconnects with backoff.
59
+ #
60
+ # Should be comfortably above the server's heartbeat interval to
61
+ # avoid false-positive disconnects.
62
+ #
63
+ # Default: 30.
64
+ attr_accessor :stream_idle_timeout #: Numeric
65
+
45
66
  # Middleware chain for enqueue. Each middleware receives an
46
67
  # `EnqueueRequest` and a chain to continue.
47
68
  attr_reader :enqueue_middleware #: Middleware::Chain[EnqueueRequest, EnqueueRequest]
@@ -55,6 +76,8 @@ module Zizq
55
76
  @format = :msgpack
56
77
  @logger = Logger.new($stdout, level: Logger::INFO)
57
78
  @tls = nil
79
+ @read_timeout = 30
80
+ @stream_idle_timeout = 30
58
81
  @enqueue_middleware = Middleware::Chain.new(Identity.new)
59
82
  @dequeue_middleware = Middleware::Chain.new(Zizq::Job)
60
83
  end
@@ -89,6 +112,14 @@ module Zizq
89
112
  raise ArgumentError, "Zizq.configure: format must be :msgpack or :json, got #{format.inspect}"
90
113
  end
91
114
 
115
+ unless read_timeout.is_a?(Numeric) && read_timeout > 0
116
+ raise ArgumentError, "Zizq.configure: read_timeout must be a positive number, got #{read_timeout.inspect}"
117
+ end
118
+
119
+ unless stream_idle_timeout.is_a?(Numeric) && stream_idle_timeout > 0
120
+ raise ArgumentError, "Zizq.configure: stream_idle_timeout must be a positive number, got #{stream_idle_timeout.inspect}"
121
+ end
122
+
92
123
  tls = @tls
93
124
  validate_tls!(tls) if tls
94
125
  end