zizq 0.3.1 → 0.3.2

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: 8f79a2e4601f42c8c9334601383a3659ffa5063a5df3ce2575b1d56b4df17a89
4
- data.tar.gz: 730dbf9a98a6bb877234f3821e099e0363c5ebfad4905622106fa19b17db6610
3
+ metadata.gz: 5f0af940d511a8e1aa6d89df8ca13e98733430827d5f3202bcf7434a94ee02da
4
+ data.tar.gz: 9341d2815e344b3e4b8ca402185e3de01bd488cb5a3159f18e132d3c65cc18ca
5
5
  SHA512:
6
- metadata.gz: 49f4a260cfbc35570e10e8fa0811e922be0c826ac0126f20e530353ae4c58df13e5f1cf8637d0a2f7a799ec3a0c64d9082c1a66d859de21b748db05e2db22802
7
- data.tar.gz: 0e09b0848550330989701c703c5640e3df46e9e8a4229e3e15de99f033975356e24abee2e99fe537ed5b80c8a6edbfc9e302b518844ca9a6d99ce60766af5ccb
6
+ metadata.gz: a3e04dffd06a36db8ca940105675428390867bd8dee62b2bcf0ef7cbeef03fe080b35bf93061e9ec00b6834be0940e1d69d7bd837178718a0bd288b6895464c3
7
+ data.tar.gz: 9d5a7d77ca3e143dbcc8c5bb72a58eb1c60092335d206579646dac8df3cbf178a9627a0d8e7a68e394d2b07c32a60a82c915ab049175624760a314042d12f9b7
data/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Zizq — Official Ruby Client
2
2
 
3
+ This is the official Zizq client library for Ruby.
4
+
3
5
  Zizq is a simple, zero dependency, single binary job queue system that is both
4
6
  fast and durable. It is designed to work in any stack through a simple HTTP
5
7
  API.
6
8
 
7
- This is the official Zizq client library for Ruby.
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
10
  [![Gem Version](https://img.shields.io/gem/v/zizq.svg)](https://rubygems.org/gems/zizq)
11
11
 
@@ -32,13 +32,13 @@ This is the official Zizq client library for Ruby.
32
32
  Add it to your application's `Gemfile`:
33
33
 
34
34
  ```ruby
35
- gem 'zizq', '~> 0.3.1'
35
+ gem 'zizq', '~> 0.3.2'
36
36
  ```
37
37
 
38
38
  Or install it manually:
39
39
 
40
40
  ```shell
41
- $ gem install zizq -v 0.3.1
41
+ $ gem install zizq -v 0.3.2
42
42
  ```
43
43
 
44
44
  Ruby **3.2.8 or newer** is required. Client and server share version
@@ -55,12 +55,19 @@ require 'zizq'
55
55
 
56
56
  Zizq.configure do |c|
57
57
  c.url = 'https://zizq.your.network:7890'
58
- c.tls = { ca: '/path/to/server-ca-cert.pem' }
59
58
  c.logger = Logger.new('log/zizq.log')
59
+
60
+ c.tls.ca = '/path/to/server-ca-cert.pem'
61
+
62
+ # Optional worker defaults — applied to every Zizq::Worker
63
+ # instance and to runs of the `zizq-worker` executable. Explicit
64
+ # kwargs or CLI flags override these.
65
+ c.worker.queues = ['emails', 'payments']
66
+ c.worker.fiber_count = 25
60
67
  end
61
68
  ```
62
69
 
63
- For mutual TLS, add `client_cert:` and `client_key:` to the `tls` hash.
70
+ For mutual TLS, also set `c.tls.client_cert` and `c.tls.client_key`.
64
71
 
65
72
  > [!CAUTION]
66
73
  > If your server is exposed directly to the internet, it should require
@@ -149,20 +156,29 @@ end
149
156
  ### Running a worker
150
157
 
151
158
  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:
159
+ way is the `zizq-worker` executable bundled with the gem. **Rails apps need
160
+ no arguments** `zizq-worker` auto-detects `config/environment.rb` when run
161
+ from the app's root:
154
162
 
155
163
  ```shell
156
- $ bundle exec zizq-worker --threads 5 --fibers 2 config/environment.rb
157
- I, [...] INFO -- : Zizq worker starting: 5 threads, 2 fibers, prefetch=20
164
+ $ bundle exec zizq-worker
165
+ I, [...] INFO -- : Zizq worker starting: 1 threads, 25 fibers, prefetch=50
158
166
  I, [...] INFO -- : Connected. Listening for jobs.
159
167
  ```
160
168
 
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).
169
+ For Sinatra or other apps, pass the entrypoint explicitly:
170
+
171
+ ```shell
172
+ $ bundle exec zizq-worker app.rb
173
+ ```
174
+
175
+ Worker defaults (`queues`, `thread_count`, `fiber_count`, `prefetch`) come
176
+ from your `Zizq.configure { |c| c.worker.* }` block. CLI flags
177
+ (`--threads`, `--fibers`, `--queue`, `--all-queues`, etc.) override the
178
+ configured defaults when needed. Leave `--fibers 1` if your application
179
+ isn't fiber-safe — no `Async` context is loaded in that case. `INT` /
180
+ `TERM` trigger a graceful shutdown (drains in-flight jobs up to
181
+ `--shutdown-deadline`, default 30s).
166
182
 
167
183
  For more control — for example running the worker in-process alongside a
168
184
  Rack app — construct `Zizq::Worker` directly:
@@ -170,11 +186,9 @@ Rack app — construct `Zizq::Worker` directly:
170
186
  ```ruby
171
187
  require 'zizq'
172
188
 
173
- worker = Zizq::Worker.new(
174
- thread_count: 5,
175
- fiber_count: 2,
176
- queues: ['emails', 'payments'],
177
- )
189
+ # Picks up queues, fiber_count, etc. from Zizq.configure { |c| c.worker.* };
190
+ # any kwarg here overrides those defaults.
191
+ worker = Zizq::Worker.new(queues: ['emails', 'payments'])
178
192
 
179
193
  Signal.trap('INT') { worker.stop }
180
194
  worker.run # blocks until the worker stops
data/bin/zizq-worker CHANGED
@@ -11,58 +11,96 @@ require "zizq"
11
11
  # Default deadline for a graceful `stop` before escalating to `kill`.
12
12
  DEFAULT_SHUTDOWN_DEADLINE = 30.0
13
13
 
14
- # --- Defaults from env vars, falling back to hardcoded defaults ---
15
-
16
- thread_count = Integer(ENV.fetch("ZIZQ_THREADS", Zizq::Worker::DEFAULT_THREADS))
17
- fiber_count = Integer(ENV.fetch("ZIZQ_FIBERS", Zizq::Worker::DEFAULT_FIBERS))
14
+ # --- Defaults from env vars (nil = "not set, defer to Zizq.configure then Worker default") ---
15
+ #
16
+ # Note: env-var-unset values stay as nil rather than being seeded
17
+ # with the Worker's `DEFAULT_*` constants. This lets `Zizq.configure
18
+ # { |c| c.worker.<field> = ... }` win when neither the env nor the
19
+ # CLI explicitly set the value.
20
+
21
+ thread_count = ENV.key?("ZIZQ_THREADS") ? Integer(ENV["ZIZQ_THREADS"]) : nil
22
+ fiber_count = ENV.key?("ZIZQ_FIBERS") ? Integer(ENV["ZIZQ_FIBERS"]) : nil
18
23
  prefetch = ENV.key?("ZIZQ_PREFETCH") ? Integer(ENV["ZIZQ_PREFETCH"]) : nil
19
24
  shutdown_deadline = Float(ENV.fetch("ZIZQ_SHUTDOWN_DEADLINE", DEFAULT_SHUTDOWN_DEADLINE))
20
- retry_min_wait = Float(ENV.fetch("ZIZQ_RETRY_MIN_WAIT", Zizq::Worker::DEFAULT_RETRY_MIN_WAIT))
21
- retry_max_wait = Float(ENV.fetch("ZIZQ_RETRY_MAX_WAIT", Zizq::Worker::DEFAULT_RETRY_MAX_WAIT))
22
- retry_multiplier = Float(ENV.fetch("ZIZQ_RETRY_MULTIPLIER", Zizq::Worker::DEFAULT_RETRY_MULTIPLIER))
23
-
24
- queues = if ENV.key?("ZIZQ_QUEUES")
25
- ENV["ZIZQ_QUEUES"].split(",").map(&:strip).reject(&:empty?)
26
- else
27
- []
25
+ retry_min_wait = ENV.key?("ZIZQ_RETRY_MIN_WAIT") ? Float(ENV["ZIZQ_RETRY_MIN_WAIT"]) : nil
26
+ retry_max_wait = ENV.key?("ZIZQ_RETRY_MAX_WAIT") ? Float(ENV["ZIZQ_RETRY_MAX_WAIT"]) : nil
27
+ retry_multiplier = ENV.key?("ZIZQ_RETRY_MULTIPLIER") ? Float(ENV["ZIZQ_RETRY_MULTIPLIER"]) : nil
28
+
29
+ # Queue state: explicit list vs. all-queues vs. unset (defer to config).
30
+ # `queues_set_by_user` toggles to true once any CLI/env queue flag is seen.
31
+ queues = []
32
+ queues_set_by_user = false
33
+ all_queues = false
34
+
35
+ if ENV.key?("ZIZQ_QUEUES")
36
+ parsed = ENV["ZIZQ_QUEUES"].split(",").map(&:strip).reject(&:empty?)
37
+ queues = parsed
38
+ queues_set_by_user = true
28
39
  end
29
40
 
30
41
  # --- CLI flag parsing (overrides env var defaults) ---
31
42
 
32
43
  parser = OptionParser.new do |opts|
33
- opts.banner = "Usage: zizq-worker [OPTIONS] <ENTRYPOINT>"
44
+ opts.banner = "Usage: zizq-worker [OPTIONS] [ENTRYPOINT]"
34
45
 
35
46
  opts.separator ""
36
- opts.separator "Start a Zizq worker process. The ENTRYPOINT is a Ruby file that loads your"
37
- opts.separator "application (e.g. config/environment.rb for a Rails app)."
47
+ opts.separator "Start a Zizq worker process."
48
+ opts.separator ""
49
+ opts.separator "The ENTRYPOINT is a Ruby file that loads your application before the"
50
+ opts.separator "worker starts (so Zizq.configure runs and your job classes are loaded)."
51
+ opts.separator "Resolved in this order:"
52
+ opts.separator ""
53
+ opts.separator " 1. The ENTRYPOINT positional argument, if given."
54
+ opts.separator " 2. The ZIZQ_ENTRYPOINT environment variable, if set."
55
+ opts.separator " 3. config/environment.rb in the current directory, if it exists."
56
+ opts.separator " (This is the canonical Rails boot file, so Rails apps run with"
57
+ opts.separator " no entrypoint argument at all.)"
58
+ opts.separator ""
59
+ opts.separator "Configuration:"
60
+ opts.separator ""
61
+ opts.separator " Client config (url, format, TLS, logger) and worker defaults"
62
+ opts.separator " (queues, thread/fiber count, prefetch, etc.) belong in your"
63
+ opts.separator " Zizq.configure block inside the entrypoint, e.g.:"
38
64
  opts.separator ""
39
- opts.separator "Client configuration (url, format, logger) should be set in the"
40
- opts.separator "entrypoint via Zizq.configure."
65
+ opts.separator " Zizq.configure do |c|"
66
+ opts.separator " c.url = \"https://...\""
67
+ opts.separator " c.worker.queues = [\"emails\", \"webhooks\"]"
68
+ opts.separator " c.worker.fiber_count = 25"
69
+ opts.separator " end"
70
+ opts.separator ""
71
+ opts.separator " CLI flags and env vars below override whatever is configured there."
41
72
  opts.separator ""
42
73
  opts.separator "Options:"
43
74
 
44
- opts.on("-t", "--threads N", Integer, "Number of worker threads (default: #{Zizq::Worker::DEFAULT_THREADS}, env: ZIZQ_THREADS)") do |n|
75
+ opts.on("-t", "--threads N", Integer, "Number of worker threads (fallback default: #{Zizq::Worker::DEFAULT_THREADS}, env: ZIZQ_THREADS)") do |n|
45
76
  thread_count = n
46
77
  end
47
78
 
48
- opts.on("-f", "--fibers N", Integer, "Number of fibers per thread (default: #{Zizq::Worker::DEFAULT_FIBERS}, env: ZIZQ_FIBERS)") do |n|
79
+ opts.on("-f", "--fibers N", Integer, "Number of fibers per thread (fallback default: #{Zizq::Worker::DEFAULT_FIBERS}, env: ZIZQ_FIBERS)") do |n|
49
80
  fiber_count = n
50
81
  end
51
82
 
52
- opts.on("-p", "--prefetch N", Integer, "Prefetch count (default: 2*threads*fibers, env: ZIZQ_PREFETCH)") do |n|
83
+ opts.on("-p", "--prefetch N", Integer, "Prefetch count (fallback default: 2*threads*fibers, env: ZIZQ_PREFETCH)") do |n|
53
84
  prefetch = n
54
85
  end
55
86
 
56
87
  queues_from_cli = false
57
88
  opts.on("-q", "--queue QUEUE", "Queue to process (repeatable or comma-separated, env: ZIZQ_QUEUES)") do |q|
58
- # First -q flag replaces the env var default entirely
89
+ # First CLI `-q` replaces the env var default; subsequent ones
90
+ # accumulate. `queues_from_cli` distinguishes that from
91
+ # `queues_set_by_user` (which also tracks env-var input).
59
92
  unless queues_from_cli
60
93
  queues = []
61
94
  queues_from_cli = true
62
95
  end
96
+ queues_set_by_user = true
63
97
  queues.concat(q.split(",").map(&:strip).reject(&:empty?))
64
98
  end
65
99
 
100
+ opts.on("--all-queues", "Override any configured queues to process all queues (mutually exclusive with -q)") do
101
+ all_queues = true
102
+ end
103
+
66
104
  opts.on("--shutdown-deadline N", Float, "Graceful shutdown deadline in seconds before escalating to kill (default: #{DEFAULT_SHUTDOWN_DEADLINE}, env: ZIZQ_SHUTDOWN_DEADLINE)") do |n|
67
105
  shutdown_deadline = n
68
106
  end
@@ -100,22 +138,38 @@ end
100
138
 
101
139
  # --- Validate options ---
102
140
 
103
- if thread_count < 1
141
+ if thread_count && thread_count < 1
104
142
  warn "Error: --threads must be at least 1 (got #{thread_count})"
105
143
  exit 1
106
144
  end
107
145
 
108
- if fiber_count < 1
146
+ if fiber_count && fiber_count < 1
109
147
  warn "Error: --fibers must be at least 1 (got #{fiber_count})"
110
148
  exit 1
111
149
  end
112
150
 
113
- # --- Validate and load entrypoint ---
151
+ if all_queues && queues_set_by_user
152
+ warn "Error: --all-queues and --queue (or ZIZQ_QUEUES) are mutually exclusive."
153
+ exit 1
154
+ end
155
+
156
+ # --- Resolve and load entrypoint ---
157
+ #
158
+ # Precedence:
159
+ # 1. CLI argument: `zizq-worker path/to/entrypoint.rb`
160
+ # 2. ZIZQ_ENTRYPOINT env var
161
+ # 3. Auto-detect: `config/environment.rb` (Rails apps)
114
162
 
115
- entrypoint = ARGV[0]
163
+ entrypoint = ARGV[0] || ENV["ZIZQ_ENTRYPOINT"] || (
164
+ File.file?("config/environment.rb") ? "config/environment.rb" : nil
165
+ )
116
166
 
117
167
  if entrypoint.nil?
118
- warn "Error: missing required ENTRYPOINT argument."
168
+ warn "Error: no entrypoint found."
169
+ warn ""
170
+ warn "Specify one as a CLI argument or via the ZIZQ_ENTRYPOINT env var."
171
+ warn "Rails apps are detected automatically via the presence of"
172
+ warn "config/environment.rb in the current directory."
119
173
  warn ""
120
174
  warn parser.help
121
175
  exit 1
@@ -128,17 +182,33 @@ end
128
182
 
129
183
  require File.expand_path(entrypoint)
130
184
 
131
- # --- Start the worker ---
185
+ # --- Resolve queues ---
186
+ #
187
+ # --all-queues -> [] (explicit "all queues" override)
188
+ # -q / ZIZQ_QUEUES set -> the parsed list
189
+ # neither -> nil (defer to Zizq.configure → Worker default)
190
+
191
+ queues_value =
192
+ if all_queues
193
+ []
194
+ elsif queues_set_by_user
195
+ queues
196
+ end
132
197
 
133
- worker = Zizq::Worker.new(
198
+ # --- Start the worker ---
199
+ #
200
+ # `.compact` drops any unset kwarg so `Zizq::Worker#initialize`'s
201
+ # fallback chain (kwarg -> Zizq.configuration.worker -> DEFAULT_*)
202
+ # applies for whichever values weren't given on the CLI / via env.
203
+ worker = Zizq::Worker.new(**{
134
204
  thread_count:,
135
205
  fiber_count:,
136
206
  prefetch:,
137
- queues:,
207
+ queues: queues_value,
138
208
  retry_min_wait:,
139
209
  retry_max_wait:,
140
210
  retry_multiplier:,
141
- )
211
+ }.compact)
142
212
 
143
213
  # `Zizq::Worker#stop` is patient (waits forever for in-flight jobs and
144
214
  # acks to drain). We enforce the shutdown deadline at the CLI level: the
@@ -154,7 +224,10 @@ stopping = false
154
224
  %w[INT TERM].each do |signal|
155
225
  Signal.trap(signal) do
156
226
  if stopping
157
- exit(1)
227
+ # Second signal: hard exit. `exit!` (not `exit`) so SystemExit
228
+ # terminates the process from any thread context, including
229
+ # the signal trap.
230
+ exit!(1)
158
231
  else
159
232
  worker.stop
160
233
  stopping = true
@@ -164,7 +237,10 @@ stopping = false
164
237
  worker.logger.warn do
165
238
  "Worker did not stop within #{shutdown_deadline}s, killing..."
166
239
  end
167
- exit(1)
240
+ # `exit!` rather than `exit`: this runs in a watchdog thread,
241
+ # and `exit` would only raise SystemExit in *this* thread,
242
+ # leaving main (joining the hung worker thread) untouched.
243
+ exit!(1)
168
244
  end
169
245
  end
170
246
  end
@@ -29,19 +29,6 @@ module Zizq
29
29
  # Logger instance to which to write log messages.
30
30
  attr_accessor :logger #: Logger
31
31
 
32
- # TLS options for connecting to the server over HTTPS.
33
- #
34
- # All values may be PEM-encoded strings or file paths.
35
- #
36
- # {
37
- # ca: "path/to/ca-cert.pem", # CA certificate for server verification
38
- # client_cert: "path/to/client-cert.pem", # Client certificate for mTLS
39
- # client_key: "path/to/client-key.pem", # Client private key for mTLS
40
- # }
41
- #
42
- # Note: Mutual TLS support requires a Zizq Pro license on the server.
43
- attr_accessor :tls #: Zizq::tls_options?
44
-
45
32
  # Per-operation socket I/O timeout (seconds) for regular API calls
46
33
  # (enqueue, queries, mutations). Each socket read/write is bounded
47
34
  # by this value. A request whose handshake or any single read exceeds
@@ -76,12 +63,79 @@ module Zizq
76
63
  @format = :msgpack
77
64
  @logger = Logger.new($stdout, level: Logger::INFO)
78
65
  @tls = nil
66
+ @worker = nil
79
67
  @read_timeout = 30
80
68
  @stream_idle_timeout = 30
81
69
  @enqueue_middleware = Middleware::Chain.new(Identity.new)
82
70
  @dequeue_middleware = Middleware::Chain.new(Zizq::Job)
83
71
  end
84
72
 
73
+ # TLS settings for connecting to the server over HTTPS.
74
+ #
75
+ # Configure via the `c.tls` accessors inside a `Zizq.configure`
76
+ # block:
77
+ #
78
+ # Zizq.configure do |c|
79
+ # c.tls.ca = "/path/to/server-ca-cert.pem"
80
+ # c.tls.client_cert = "/path/to/client-cert.pem"
81
+ # c.tls.client_key = "/path/to/client-key.pem"
82
+ # end
83
+ #
84
+ # All values may be PEM-encoded strings or file paths. Set
85
+ # `c.tls = nil` to explicitly disable TLS.
86
+ #
87
+ # Note: Mutual TLS support requires a Zizq Pro license on the
88
+ # server.
89
+ def tls #: () -> TlsConfiguration
90
+ @tls ||= TlsConfiguration.new
91
+ end
92
+
93
+ def tls=(value) #: ((Hash[Symbol, String?] | TlsConfiguration)?) -> void
94
+ case value
95
+ when nil
96
+ @tls = nil
97
+ when TlsConfiguration
98
+ @tls = value
99
+ when Hash
100
+ @tls = TlsConfiguration.new(**value)
101
+ else
102
+ raise ArgumentError,
103
+ "Zizq.configure: tls= expects a Hash, Zizq::TlsConfiguration, or nil " \
104
+ "(got #{value.class})"
105
+ end
106
+ end
107
+
108
+ # Defaults for `Zizq::Worker` instances. Apps populate this in
109
+ # their `Zizq.configure` block:
110
+ #
111
+ # Zizq.configure do |c|
112
+ # c.worker.queues = ["emails"]
113
+ # c.worker.fiber_count = 25
114
+ # end
115
+ #
116
+ # Anything left unset here falls through to the Worker's
117
+ # hardcoded defaults; anything explicitly passed to `Worker.new`
118
+ # (or set via `zizq-worker` CLI flags / env vars) overrides
119
+ # whatever is configured here.
120
+ def worker #: () -> WorkerConfiguration
121
+ @worker ||= WorkerConfiguration.new
122
+ end
123
+
124
+ def worker=(value) #: ((Hash[Symbol, untyped] | WorkerConfiguration)?) -> void
125
+ case value
126
+ when nil
127
+ @worker = nil
128
+ when WorkerConfiguration
129
+ @worker = value
130
+ when Hash
131
+ @worker = WorkerConfiguration.new(**value)
132
+ else
133
+ raise ArgumentError,
134
+ "Zizq.configure: worker= expects a Hash, Zizq::WorkerConfiguration, or nil " \
135
+ "(got #{value.class})"
136
+ end
137
+ end
138
+
85
139
  # The job dispatcher.
86
140
  # This is the terminal of the dequeue middleware chain.
87
141
  # Defaults to `Zizq::Job` which finds and executes jobs written by mixing
@@ -130,21 +184,22 @@ module Zizq
130
184
  def ssl_context #: () -> OpenSSL::SSL::SSLContext?
131
185
  tls = @tls
132
186
  return nil unless tls
187
+ return nil if tls.to_h.values.all?(&:nil?)
133
188
 
134
189
  ctx = OpenSSL::SSL::SSLContext.new
135
190
 
136
- if (ca = tls[:ca])
191
+ if (ca = tls.ca)
137
192
  store = OpenSSL::X509::Store.new
138
193
  store.add_cert(load_cert(ca))
139
194
  ctx.cert_store = store
140
195
  ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
141
196
  end
142
197
 
143
- if (client_cert = tls[:client_cert])
198
+ if (client_cert = tls.client_cert)
144
199
  ctx.cert = load_cert(client_cert)
145
200
  end
146
201
 
147
- if (client_key = tls[:client_key])
202
+ if (client_key = tls.client_key)
148
203
  ctx.key = load_key(client_key)
149
204
  end
150
205
 
@@ -161,14 +216,13 @@ module Zizq
161
216
 
162
217
  private
163
218
 
164
- # @rbs tls: Zizq::tls_options
165
- def validate_tls!(tls) #: (Zizq::tls_options) -> void
166
- if tls[:client_cert] && !tls[:client_key]
167
- raise ArgumentError, "Zizq.configure: tls[:client_key] is required when tls[:client_cert] is set"
219
+ def validate_tls!(tls) #: (TlsConfiguration) -> void
220
+ if tls.client_cert && !tls.client_key
221
+ raise ArgumentError, "Zizq.configure: tls.client_key is required when tls.client_cert is set"
168
222
  end
169
223
 
170
- if tls[:client_key] && !tls[:client_cert]
171
- raise ArgumentError, "Zizq.configure: tls[:client_cert] is required when tls[:client_key] is set"
224
+ if tls.client_key && !tls.client_cert
225
+ raise ArgumentError, "Zizq.configure: tls.client_cert is required when tls.client_key is set"
172
226
  end
173
227
  end
174
228
 
@@ -0,0 +1,27 @@
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
+ # TLS settings for connecting to the Zizq server over HTTPS.
9
+ #
10
+ # Set inside a `Zizq.configure` block via the `c.tls` accessors:
11
+ #
12
+ # Zizq.configure do |c|
13
+ # c.tls.ca = "/path/to/ca-cert.pem"
14
+ # c.tls.client_cert = "/path/to/client-cert.pem"
15
+ # c.tls.client_key = "/path/to/client-key.pem"
16
+ # end
17
+ #
18
+ # All values may be PEM-encoded strings or file paths.
19
+ #
20
+ # Note: Mutual TLS support requires a Zizq Pro license on the server.
21
+ TlsConfiguration = Struct.new(
22
+ :ca, #: String?
23
+ :client_cert, #: String?
24
+ :client_key, #: String?
25
+ keyword_init: true
26
+ )
27
+ end
data/lib/zizq/version.rb CHANGED
@@ -5,5 +5,5 @@
5
5
  # frozen_string_literal: true
6
6
 
7
7
  module Zizq
8
- VERSION = "0.3.1" #: String
8
+ VERSION = "0.3.2" #: String
9
9
  end
data/lib/zizq/worker.rb CHANGED
@@ -15,7 +15,7 @@ module Zizq
15
15
  #
16
16
  # Total concurrency is calculated as `thread_count * fiber_count`.
17
17
  class Worker
18
- DEFAULT_THREADS = 5 #: Integer
18
+ DEFAULT_THREADS = 1 #: Integer
19
19
  DEFAULT_FIBERS = 1 #: Integer
20
20
  DEFAULT_RETRY_MIN_WAIT = 1
21
21
  DEFAULT_RETRY_MAX_WAIT = 30
@@ -50,11 +50,6 @@ module Zizq
50
50
  # pipeline full while ack round-trips are in flight.
51
51
  attr_reader :prefetch #: Integer
52
52
 
53
- # Proc to derive a worker ID string for each thread and fiber.
54
- #
55
- # When not present, the Zizq server assigns a random worker ID.
56
- attr_reader :worker_id_proc #: (^(Integer, Integer) -> String?)?
57
-
58
53
  # An instance of a Logger to be used for worker logging.
59
54
  attr_reader :logger #: Logger
60
55
 
@@ -66,45 +61,55 @@ module Zizq
66
61
  # their own `Zizq::Middleware::Chain` if middleware needs to be applied.
67
62
  attr_reader :dispatcher #: ^(Resources::Job) -> void
68
63
 
69
- # @rbs queues: Array[String]
70
- # @rbs thread_count: Integer
71
- # @rbs fiber_count: Integer
64
+ # All keyword arguments default to `nil` and follow a three-level
65
+ # fallback chain:
66
+ #
67
+ # 1. Explicit kwarg passed to `Worker.new`.
68
+ # 2. `Zizq.configuration.worker.<field>` set in the app's
69
+ # `Zizq.configure` block.
70
+ # 3. The Worker's hardcoded `DEFAULT_*` constants.
71
+ #
72
+ # @rbs queues: Array[String]?
73
+ # @rbs thread_count: Integer?
74
+ # @rbs fiber_count: Integer?
72
75
  # @rbs prefetch: Integer?
73
- # @rbs retry_min_wait: (Float | Integer)
74
- # @rbs retry_max_wait: (Float | Integer)
75
- # @rbs retry_multiplier: (Float | Integer)
76
- # @rbs worker_id: (^(Integer, Integer) -> String?)?
76
+ # @rbs retry_min_wait: (Float | Integer)?
77
+ # @rbs retry_max_wait: (Float | Integer)?
78
+ # @rbs retry_multiplier: (Float | Integer)?
77
79
  # @rbs logger: Logger?
78
80
  # @rbs dispatcher: (^(Resources::Job) -> void)?
79
81
  # @rbs return: void
80
82
  def initialize(
81
- queues: [],
82
- thread_count: DEFAULT_THREADS,
83
- fiber_count: DEFAULT_FIBERS,
83
+ queues: nil,
84
+ thread_count: nil,
85
+ fiber_count: nil,
84
86
  prefetch: nil,
85
- retry_min_wait: DEFAULT_RETRY_MIN_WAIT,
86
- retry_max_wait: DEFAULT_RETRY_MAX_WAIT,
87
- retry_multiplier: DEFAULT_RETRY_MULTIPLIER,
88
- worker_id: nil,
87
+ retry_min_wait: nil,
88
+ retry_max_wait: nil,
89
+ retry_multiplier: nil,
89
90
  logger: nil,
90
91
  dispatcher: nil
91
92
  )
92
- raise ArgumentError, "thread_count must be at least 1 (got #{thread_count})" if thread_count < 1
93
- raise ArgumentError, "fiber_count must be at least 1 (got #{fiber_count})" if fiber_count < 1
94
-
95
93
  Zizq.configuration.validate!
96
-
97
- @queues = queues
98
- @thread_count = thread_count
99
- @fiber_count = fiber_count
100
- @prefetch = prefetch || thread_count * fiber_count * 2
101
- @retry_min_wait = retry_min_wait
102
- @retry_max_wait = retry_max_wait
103
- @retry_multiplier = retry_multiplier
104
- @worker_id_proc = worker_id
94
+ config = Zizq.configuration.worker
95
+
96
+ @queues = queues || config.queues || []
97
+ @thread_count = thread_count || config.thread_count || DEFAULT_THREADS
98
+ @fiber_count = fiber_count || config.fiber_count || DEFAULT_FIBERS
99
+ @prefetch = prefetch || config.prefetch || @thread_count * @fiber_count * 2
100
+ @retry_min_wait = retry_min_wait || config.retry_min_wait || DEFAULT_RETRY_MIN_WAIT
101
+ @retry_max_wait = retry_max_wait || config.retry_max_wait || DEFAULT_RETRY_MAX_WAIT
102
+ @retry_multiplier = retry_multiplier || config.retry_multiplier || DEFAULT_RETRY_MULTIPLIER
105
103
  @logger = logger || Zizq.configuration.logger
106
104
  @dispatcher = dispatcher || Zizq.configuration.dequeue_middleware
107
105
 
106
+ if @thread_count < 1
107
+ raise ArgumentError, "thread_count must be at least 1 (got #{@thread_count})"
108
+ end
109
+ if @fiber_count < 1
110
+ raise ArgumentError, "fiber_count must be at least 1 (got #{@fiber_count})"
111
+ end
112
+
108
113
  reset_runtime_state
109
114
  end
110
115
 
@@ -360,14 +365,12 @@ module Zizq
360
365
  format("Worker %d:%d started", thread_idx, fiber_idx)
361
366
  end
362
367
 
363
- wid = resolve_worker_id(thread_idx, fiber_idx)
364
-
365
368
  loop do
366
369
  # pop returns nil when queue is closed and empty
367
370
  job = @dispatch_queue.pop
368
371
  break if job.nil?
369
372
 
370
- dispatch(job, wid)
373
+ dispatch(job)
371
374
  end
372
375
 
373
376
  logger.info do
@@ -392,7 +395,7 @@ module Zizq
392
395
  #
393
396
  # Delegates to the configured dispatcher (default: `Zizq::Job.dispatch`)
394
397
  # and reports success or failure.
395
- def dispatch(job, worker_id) #: (Resources::Job, String?) -> void
398
+ def dispatch(job) #: (Resources::Job) -> void
396
399
  job_id, job_type = job.id, job.type
397
400
 
398
401
  begin
@@ -460,8 +463,5 @@ module Zizq
460
463
  ))
461
464
  end
462
465
 
463
- def resolve_worker_id(thread_idx, fiber_idx) #: (Integer, Integer) -> String?
464
- worker_id_proc&.call(thread_idx, fiber_idx)
465
- end
466
466
  end
467
467
  end
@@ -0,0 +1,48 @@
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
+ # Defaults for `Zizq::Worker` instances. Accessed via
9
+ # `Zizq.configuration.worker` and typically populated inside an
10
+ # application's `Zizq.configure` block:
11
+ #
12
+ # Zizq.configure do |c|
13
+ # c.url = "https://..."
14
+ # c.worker.queues = ["emails", "webhooks"]
15
+ # c.worker.thread_count = 1
16
+ # c.worker.fiber_count = 25
17
+ # end
18
+ #
19
+ # Every field defaults to `nil`, meaning "use the Worker's own
20
+ # hardcoded default." Anything explicitly passed to `Worker.new` —
21
+ # or set via CLI flag / env var when launching `zizq-worker` —
22
+ # overrides whatever is set here.
23
+ #
24
+ # See `Zizq::Worker#initialize` for the full resolution order
25
+ # (explicit kwarg → Zizq.configuration.worker → `Worker::DEFAULT_*`).
26
+ #
27
+ # Fields:
28
+ #
29
+ # * `queues` — Queues to consume. `[]` means all queues.
30
+ # * `thread_count` — Number of worker threads.
31
+ # * `fiber_count` — Number of fibers per worker thread.
32
+ # * `prefetch` — Server-side prefetch limit. Defaults to
33
+ # `2 * threads * fibers`.
34
+ # * `retry_min_wait` — Minimum reconnect backoff in seconds.
35
+ # * `retry_max_wait` — Maximum reconnect backoff in seconds.
36
+ # * `retry_multiplier` — Multiplicative backoff factor between
37
+ # reconnect attempts.
38
+ WorkerConfiguration = Struct.new(
39
+ :queues, #: Array[String]?
40
+ :thread_count, #: Integer?
41
+ :fiber_count, #: Integer?
42
+ :prefetch, #: Integer?
43
+ :retry_min_wait, #: (Float | Integer)?
44
+ :retry_max_wait, #: (Float | Integer)?
45
+ :retry_multiplier, #: (Float | Integer)?
46
+ keyword_init: true
47
+ )
48
+ end
data/lib/zizq.rb CHANGED
@@ -29,7 +29,9 @@ module Zizq
29
29
  autoload :Lifecycle, "zizq/lifecycle"
30
30
  autoload :Query, "zizq/query"
31
31
  autoload :Resources, "zizq/resources"
32
+ autoload :TlsConfiguration, "zizq/tls_configuration"
32
33
  autoload :Worker, "zizq/worker"
34
+ autoload :WorkerConfiguration, "zizq/worker_configuration"
33
35
 
34
36
  # Sentinel indicating a field should not be included in the request.
35
37
  # Used as the default for update parameters.
@@ -22,19 +22,6 @@ module Zizq
22
22
  # Logger instance to which to write log messages.
23
23
  attr_accessor logger: Logger
24
24
 
25
- # TLS options for connecting to the server over HTTPS.
26
- #
27
- # All values may be PEM-encoded strings or file paths.
28
- #
29
- # {
30
- # ca: "path/to/ca-cert.pem", # CA certificate for server verification
31
- # client_cert: "path/to/client-cert.pem", # Client certificate for mTLS
32
- # client_key: "path/to/client-key.pem", # Client private key for mTLS
33
- # }
34
- #
35
- # Note: Mutual TLS support requires a Zizq Pro license on the server.
36
- attr_accessor tls: Zizq::tls_options?
37
-
38
25
  # Per-operation socket I/O timeout (seconds) for regular API calls
39
26
  # (enqueue, queries, mutations). Each socket read/write is bounded
40
27
  # by this value. A request whose handshake or any single read exceeds
@@ -66,6 +53,42 @@ module Zizq
66
53
 
67
54
  def initialize: () -> untyped
68
55
 
56
+ # TLS settings for connecting to the server over HTTPS.
57
+ #
58
+ # Configure via the `c.tls` accessors inside a `Zizq.configure`
59
+ # block:
60
+ #
61
+ # Zizq.configure do |c|
62
+ # c.tls.ca = "/path/to/server-ca-cert.pem"
63
+ # c.tls.client_cert = "/path/to/client-cert.pem"
64
+ # c.tls.client_key = "/path/to/client-key.pem"
65
+ # end
66
+ #
67
+ # All values may be PEM-encoded strings or file paths. Set
68
+ # `c.tls = nil` to explicitly disable TLS.
69
+ #
70
+ # Note: Mutual TLS support requires a Zizq Pro license on the
71
+ # server.
72
+ def tls: () -> untyped
73
+
74
+ def tls=: (untyped value) -> untyped
75
+
76
+ # Defaults for `Zizq::Worker` instances. Apps populate this in
77
+ # their `Zizq.configure` block:
78
+ #
79
+ # Zizq.configure do |c|
80
+ # c.worker.queues = ["emails"]
81
+ # c.worker.fiber_count = 25
82
+ # end
83
+ #
84
+ # Anything left unset here falls through to the Worker's
85
+ # hardcoded defaults; anything explicitly passed to `Worker.new`
86
+ # (or set via `zizq-worker` CLI flags / env vars) overrides
87
+ # whatever is configured here.
88
+ def worker: () -> untyped
89
+
90
+ def worker=: (untyped value) -> untyped
91
+
69
92
  # The job dispatcher.
70
93
  # This is the terminal of the dequeue middleware chain.
71
94
  # Defaults to `Zizq::Job` which finds and executes jobs written by mixing
@@ -100,8 +123,7 @@ module Zizq
100
123
 
101
124
  private
102
125
 
103
- # @rbs tls: Zizq::tls_options
104
- def validate_tls!: (Zizq::tls_options tls) -> untyped
126
+ def validate_tls!: (untyped tls) -> untyped
105
127
 
106
128
  # Load a certificate from a PEM string or file path.
107
129
  def load_cert: (untyped pem_or_path) -> untyped
@@ -0,0 +1,27 @@
1
+ # Generated from lib/zizq/tls_configuration.rb with RBS::Inline
2
+
3
+ module Zizq
4
+ # TLS settings for connecting to the Zizq server over HTTPS.
5
+ #
6
+ # Set inside a `Zizq.configure` block via the `c.tls` accessors:
7
+ #
8
+ # Zizq.configure do |c|
9
+ # c.tls.ca = "/path/to/ca-cert.pem"
10
+ # c.tls.client_cert = "/path/to/client-cert.pem"
11
+ # c.tls.client_key = "/path/to/client-key.pem"
12
+ # end
13
+ #
14
+ # All values may be PEM-encoded strings or file paths.
15
+ #
16
+ # Note: Mutual TLS support requires a Zizq Pro license on the server.
17
+ class TlsConfiguration < Struct[String?]
18
+ attr_accessor ca(): String?
19
+
20
+ attr_accessor client_cert(): String?
21
+
22
+ attr_accessor client_key(): String?
23
+
24
+ def self.new: (?ca: String?, ?client_cert: String?, ?client_key: String?) -> instance
25
+ | ({ ?ca: String?, ?client_cert: String?, ?client_key: String? }) -> instance
26
+ end
27
+ end
@@ -46,11 +46,6 @@ module Zizq
46
46
  # pipeline full while ack round-trips are in flight.
47
47
  attr_reader prefetch: Integer
48
48
 
49
- # Proc to derive a worker ID string for each thread and fiber.
50
- #
51
- # When not present, the Zizq server assigns a random worker ID.
52
- attr_reader worker_id_proc: (^(Integer, Integer) -> String?)?
53
-
54
49
  # An instance of a Logger to be used for worker logging.
55
50
  attr_reader logger: Logger
56
51
 
@@ -62,18 +57,25 @@ module Zizq
62
57
  # their own `Zizq::Middleware::Chain` if middleware needs to be applied.
63
58
  attr_reader dispatcher: ^(Resources::Job) -> void
64
59
 
65
- # @rbs queues: Array[String]
66
- # @rbs thread_count: Integer
67
- # @rbs fiber_count: Integer
60
+ # All keyword arguments default to `nil` and follow a three-level
61
+ # fallback chain:
62
+ #
63
+ # 1. Explicit kwarg passed to `Worker.new`.
64
+ # 2. `Zizq.configuration.worker.<field>` set in the app's
65
+ # `Zizq.configure` block.
66
+ # 3. The Worker's hardcoded `DEFAULT_*` constants.
67
+ #
68
+ # @rbs queues: Array[String]?
69
+ # @rbs thread_count: Integer?
70
+ # @rbs fiber_count: Integer?
68
71
  # @rbs prefetch: Integer?
69
- # @rbs retry_min_wait: (Float | Integer)
70
- # @rbs retry_max_wait: (Float | Integer)
71
- # @rbs retry_multiplier: (Float | Integer)
72
- # @rbs worker_id: (^(Integer, Integer) -> String?)?
72
+ # @rbs retry_min_wait: (Float | Integer)?
73
+ # @rbs retry_max_wait: (Float | Integer)?
74
+ # @rbs retry_multiplier: (Float | Integer)?
73
75
  # @rbs logger: Logger?
74
76
  # @rbs dispatcher: (^(Resources::Job) -> void)?
75
77
  # @rbs return: void
76
- def initialize: (?queues: Array[String], ?thread_count: Integer, ?fiber_count: Integer, ?prefetch: Integer?, ?retry_min_wait: Float | Integer, ?retry_max_wait: Float | Integer, ?retry_multiplier: Float | Integer, ?worker_id: (^(Integer, Integer) -> String?)?, ?logger: Logger?, ?dispatcher: (^(Resources::Job) -> void)?) -> void
78
+ def initialize: (?queues: Array[String]?, ?thread_count: Integer?, ?fiber_count: Integer?, ?prefetch: Integer?, ?retry_min_wait: (Float | Integer)?, ?retry_max_wait: (Float | Integer)?, ?retry_multiplier: (Float | Integer)?, ?logger: Logger?, ?dispatcher: (^(Resources::Job) -> void)?) -> void
77
79
 
78
80
  # Request a graceful shutdown.
79
81
  #
@@ -136,7 +138,7 @@ module Zizq
136
138
  #
137
139
  # Delegates to the configured dispatcher (default: `Zizq::Job.dispatch`)
138
140
  # and reports success or failure.
139
- def dispatch: (untyped job, untyped worker_id) -> untyped
141
+ def dispatch: (untyped job) -> untyped
140
142
 
141
143
  # @rbs job_id: String
142
144
  # @rbs return: void
@@ -146,7 +148,5 @@ module Zizq
146
148
  # @rbs error: Exception
147
149
  # @rbs return: void
148
150
  def push_nack: (String job_id, Exception error) -> void
149
-
150
- def resolve_worker_id: (untyped thread_idx, untyped fiber_idx) -> untyped
151
151
  end
152
152
  end
@@ -0,0 +1,52 @@
1
+ # Generated from lib/zizq/worker_configuration.rb with RBS::Inline
2
+
3
+ module Zizq
4
+ # Defaults for `Zizq::Worker` instances. Accessed via
5
+ # `Zizq.configuration.worker` and typically populated inside an
6
+ # application's `Zizq.configure` block:
7
+ #
8
+ # Zizq.configure do |c|
9
+ # c.url = "https://..."
10
+ # c.worker.queues = ["emails", "webhooks"]
11
+ # c.worker.thread_count = 1
12
+ # c.worker.fiber_count = 25
13
+ # end
14
+ #
15
+ # Every field defaults to `nil`, meaning "use the Worker's own
16
+ # hardcoded default." Anything explicitly passed to `Worker.new` —
17
+ # or set via CLI flag / env var when launching `zizq-worker` —
18
+ # overrides whatever is set here.
19
+ #
20
+ # See `Zizq::Worker#initialize` for the full resolution order
21
+ # (explicit kwarg → Zizq.configuration.worker → `Worker::DEFAULT_*`).
22
+ #
23
+ # Fields:
24
+ #
25
+ # * `queues` — Queues to consume. `[]` means all queues.
26
+ # * `thread_count` — Number of worker threads.
27
+ # * `fiber_count` — Number of fibers per worker thread.
28
+ # * `prefetch` — Server-side prefetch limit. Defaults to
29
+ # `2 * threads * fibers`.
30
+ # * `retry_min_wait` — Minimum reconnect backoff in seconds.
31
+ # * `retry_max_wait` — Maximum reconnect backoff in seconds.
32
+ # * `retry_multiplier` — Multiplicative backoff factor between
33
+ # reconnect attempts.
34
+ class WorkerConfiguration < Struct[Array[String]? | Integer? | (Float | Integer)?]
35
+ attr_accessor queues(): Array[String]?
36
+
37
+ attr_accessor thread_count(): Integer?
38
+
39
+ attr_accessor fiber_count(): Integer?
40
+
41
+ attr_accessor prefetch(): Integer?
42
+
43
+ attr_accessor retry_min_wait(): (Float | Integer)?
44
+
45
+ attr_accessor retry_max_wait(): (Float | Integer)?
46
+
47
+ attr_accessor retry_multiplier(): (Float | Integer)?
48
+
49
+ def self.new: (?queues: Array[String]?, ?thread_count: Integer?, ?fiber_count: Integer?, ?prefetch: Integer?, ?retry_min_wait: (Float | Integer)?, ?retry_max_wait: (Float | Integer)?, ?retry_multiplier: (Float | Integer)?) -> instance
50
+ | ({ ?queues: Array[String]?, ?thread_count: Integer?, ?fiber_count: Integer?, ?prefetch: Integer?, ?retry_min_wait: (Float | Integer)?, ?retry_max_wait: (Float | Integer)?, ?retry_multiplier: (Float | Integer)? }) -> instance
51
+ end
52
+ end
data/sig/zizq.rbs CHANGED
@@ -67,7 +67,8 @@ module Zizq
67
67
  ?paused: bool?
68
68
  }
69
69
 
70
- # TLS options for connecting over HTTPS. Values are PEM strings or file paths.
70
+ # TLS options hash accepted by `Configuration#tls=` for back-compat.
71
+ # Values are PEM strings or file paths.
71
72
  type tls_options = { ?ca: String, ?client_cert: String, ?client_key: String }
72
73
 
73
74
  # Any object that can dispatch a job. Must respond to #call(job).
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.1
4
+ version: 0.3.2
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-21 00:00:00.000000000 Z
11
+ date: 2026-05-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async-http
@@ -100,8 +100,10 @@ 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/tls_configuration.rb
103
104
  - lib/zizq/version.rb
104
105
  - lib/zizq/worker.rb
106
+ - lib/zizq/worker_configuration.rb
105
107
  - sig/generated/zizq.rbs
106
108
  - sig/generated/zizq/ack_processor.rbs
107
109
  - sig/generated/zizq/active_job_config.rbs
@@ -131,8 +133,10 @@ files:
131
133
  - sig/generated/zizq/resources/job_template.rbs
132
134
  - sig/generated/zizq/resources/page.rbs
133
135
  - sig/generated/zizq/resources/resource.rbs
136
+ - sig/generated/zizq/tls_configuration.rbs
134
137
  - sig/generated/zizq/version.rbs
135
138
  - sig/generated/zizq/worker.rbs
139
+ - sig/generated/zizq/worker_configuration.rbs
136
140
  - sig/zizq.rbs
137
141
  homepage: https://zizq.io
138
142
  licenses: