queekiq 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4310b94018510b1e6353861e3d6b282c2141f214b56fba92628f61291ac38f6b
4
- data.tar.gz: 52b6ddc84908e73f43e1253f20292ca59284b622503040096ff35463fd630547
3
+ metadata.gz: 92b27e1bc09a36c133ab56236cb3f0086c92e92b6cabee6ab5d4e742e156dbe2
4
+ data.tar.gz: 94945eaf96bc1f8a5c373505f61356bf52ea816349f2bfcb98c21e055242b7df
5
5
  SHA512:
6
- metadata.gz: 41275ff885c8b44f5d8d9c5583227355a7dc858846c4d5438c3543d2a4877c6810a35ba3eb3befaf1d2e3b4e9beb31f38e7cb638d08d323faa00bf36c1cacecc
7
- data.tar.gz: 4c471bd854b4a3ed23a4e460377c6936ddcc86c2c7377e9a0ce03e3d7e7de18b71deb6dcc6f923341a8840e595167f4cdbe5d65f8a010fd3395f1dc72ea4ab07
6
+ metadata.gz: 00b5b5204f50da5694d9b262ee28898a9127d20bfbc7cab1ed706d7004913305f5793b79b489bc43ebc09f3c49677a9b53f318d497cc480bd5ee307527603a1b
7
+ data.tar.gz: 302a3eb8b14294cf0bf8df6b813277734350c5f432fedcd52bb340ebcb074dca49ccd51758ad4f60afb3cfe0c55222179faaba2ba9421c9cbdae8db93233432e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0]
6
+
7
+ ### Added
8
+
9
+ - **Message envelope.** Every message carries `attempt`, `defers` and `enqueued_at`
10
+ (readable on `Queekiq::Message`), which survive deferrals and retries.
11
+ - **Serializers.** `serializer:` on `Queue` (`Serializers::Raw`, the default, or
12
+ `Serializers::JSON`, or your own `dump`/`load` object). Payloads are decoded
13
+ lazily, so an undecodable payload fails inside the handler and is retried and
14
+ dead-lettered instead of crashing the worker.
15
+ - **Retries with backoff.** A handler that raises is retried after `retry_backoff`
16
+ (exponential with jitter). New `Queekiq::Backoff`.
17
+ - **Dead-letter stream.** After `max_attempts` (default 5) failed attempts, or
18
+ `max_defers` deferrals (default: unlimited), a message moves to the queue's
19
+ dead stream with the reason and error. `Queue#dead_messages`, `#requeue_dead`,
20
+ `#delete_dead`, `#dead_size`. Messages whose workers keep dying are dead-lettered
21
+ too, without running the handler again.
22
+ - **Deferral backoff.** `defer_backoff` grows the delay each time a message is
23
+ deferred (default 200 ms up to 5 s).
24
+ - **Heartbeat.** While a handler runs, the worker keeps the message from being
25
+ reclaimed (`Queue#touch`, `heartbeat_interval_ms`), so `reclaim_after_ms` only
26
+ has to cover a worker dying, not your slowest job. A worker that lost its
27
+ message to a reclaim can't steal it back.
28
+ - **Resilient worker loop.** Lost Redis connections are retried with backoff
29
+ (`reconnect_backoff`); a stream or group that disappeared (Redis restarted
30
+ empty) is recreated.
31
+ - **Safer shutdown.** `stop` finishes the message in hand and, if that takes longer
32
+ than `shutdown_timeout` (default 25 s), abandons it so another worker reclaims it.
33
+ - **Observability.** `Queue#stats` (backlog, pending, oldest pending idle time,
34
+ deferred, dead, per-consumer pending) and instrumentation events
35
+ (`process`, `defer`, `retry`, `dead_letter`, `reclaim`, `connection_error`,
36
+ all `.queekiq`) via `Queekiq.subscribe`, or ActiveSupport::Notifications through
37
+ `config.instrumenter`. Log lines are now structured `key=value`.
38
+ - GitHub Actions CI: lint, specs on Ruby 3.1-3.4 / redis gem 4.8-6.x / Redis 7.0 and
39
+ 7.4, and a build-and-install check; Dependabot for actions and gems.
40
+
41
+ ### Changed
42
+
43
+ - `Queue#defer(message)` now takes the delivered message and acks it in the same
44
+ atomic step; a bare payload still works for scheduling something new. The
45
+ worker no longer calls `ack` after a deferral.
46
+ - Failed messages are retried with backoff instead of waiting out
47
+ `reclaim_after_ms`, and are dead-lettered after `max_attempts`. Pass
48
+ `max_attempts: nil` to retry forever.
49
+ - Deferral delays now grow (200 ms, 400 ms, ... up to 5 s). Use
50
+ `defer_backoff: Queekiq::Backoff.fixed(200)` for the old fixed delay.
51
+ - `Queue.new(defer_delay_ms:)` is replaced by `defer_backoff:`.
52
+ - `Message` is a class, not a Struct.
53
+ - `clear!` also deletes the dead stream.
54
+
55
+ ### Upgrading from 0.1
56
+
57
+ - Deferred entries are now stored as `attempt|defers|enqueued_at|payload`. A 0.1
58
+ worker would promote one of those with the whole string as its payload, so
59
+ stop all 0.1 workers before starting 0.2 ones. 0.2 reads everything 0.1 wrote.
60
+ - Specs that stub `Queue#defer` and expect the payload need to expect the
61
+ message: `have_received(:defer).with(having_attributes(payload: "..."))`.
62
+
5
63
  ## [0.1.0]
6
64
 
7
65
  - Initial extraction: `Queekiq::Queue` (Redis Streams + consumer group, reclaim,
data/README.md CHANGED
@@ -1,16 +1,20 @@
1
1
  # Queekiq
2
2
 
3
- A small, framework-agnostic work queue on **Redis Streams**.
3
+ A small, framework-agnostic work queue on **Redis Streams**. No dependencies
4
+ beyond the `redis` gem.
4
5
 
5
- - Consumer groups, so any number of worker processes share one queue.
6
- - At-least-once delivery: messages stay pending until acked, and messages
6
+ - **Parallel workers** through consumer groups: every message goes to exactly one worker.
7
+ - **At-least-once delivery**: messages stay pending until acked, and messages
7
8
  abandoned by a crashed worker are reclaimed by another.
8
- - **Deferral**: a handler can say "not yet" and the message comes back after a
9
- short delay, which is enough to build per-key ordering without locks.
10
- - A ready-made worker loop with graceful shutdown.
9
+ - **Retries with backoff and a dead-letter stream** for messages that keep failing.
10
+ - **Deferral**: a handler can say "not yet" and the message comes back shortly,
11
+ which is enough to build per-key ordering without locks.
12
+ - **A worker loop that survives**: heartbeats for long jobs, graceful shutdown,
13
+ reconnects when Redis goes away.
14
+ - **Observable**: queue stats, instrumentation events, structured logs.
11
15
 
12
- No dependency on Rails (it uses `Rails.logger` automatically if present).
13
- Requires Ruby >= 3.1, Redis >= 7.0 and the `redis` gem (4.8 up to 6.x).
16
+ Works with or without Rails (it uses `Rails.logger` automatically if present).
17
+ Requires Ruby >= 3.1, Redis >= 7.0 and the `redis` gem (4.8 up to 6.x; redis 6.x itself needs Ruby >= 3.2).
14
18
 
15
19
  ## Installation
16
20
 
@@ -26,22 +30,21 @@ Queekiq.configure do |c|
26
30
  c.redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
27
31
  end
28
32
 
29
- EMAILS = Queekiq::Queue.new("emails")
33
+ EMAILS = Queekiq::Queue.new("emails", serializer: Queekiq::Serializers::JSON)
30
34
 
31
35
  # Producer (web process, console, ...)
32
- EMAILS.enqueue(user.id) # payloads are strings; use JSON for structured data
36
+ EMAILS.enqueue("user_id" => user.id, "template" => "welcome")
33
37
 
34
38
  # Consumer (bin/email_worker)
35
39
  worker = Queekiq::Worker.new(queue: EMAILS) do |message|
36
- Mailer.deliver(message.payload)
40
+ Mailer.deliver(**message.payload.transform_keys(&:to_sym))
37
41
  :sent # any return value is the "outcome"
38
42
  end
39
43
  worker.start # traps INT/TERM, runs until stopped
40
44
  ```
41
45
 
42
- Run as many worker processes as you like; each message goes to exactly one of
43
- them. Consumer names default to `hostname-pid`, which is unique per process,
44
- including under Docker.
46
+ Run as many worker processes as you like. Consumer names default to
47
+ `hostname-pid`, which is unique per process, including under Docker.
45
48
 
46
49
  ## How it works
47
50
 
@@ -49,104 +52,219 @@ including under Docker.
49
52
  |---|---|---|
50
53
  | Messages | stream | `queekiq:{name}:stream` |
51
54
  | Workers | consumer group | `queekiq-<name>` |
52
- | Deferred payloads | sorted set (score = due time in ms) | `queekiq:{name}:deferred` |
55
+ | Deferred and retrying messages | sorted set (score = due time in ms) | `queekiq:{name}:deferred` |
56
+ | Messages that gave up | stream | `queekiq:{name}:dead` |
53
57
 
54
58
  Each worker iteration:
55
59
 
56
- 1. **Promote** deferred payloads that are due back onto the stream (atomically,
60
+ 1. **Promote** deferred messages that are due back onto the stream (atomically,
57
61
  in a Lua script, so none are lost or promoted twice).
58
62
  2. **Reclaim** pending messages idle for longer than `reclaim_after_ms`
59
63
  (default 30 s), i.e. ones whose worker died.
60
64
  3. **Read** new messages, blocking up to `block_ms` (default 1 s).
61
65
 
62
- Each message goes through your handler and is then acked.
66
+ Each message goes through your handler and is then settled:
67
+
68
+ | Handler | What happens |
69
+ |---|---|
70
+ | returns normally | the message is acked |
71
+ | returns an outcome in `defer_on` (default `:deferred`) | it comes back after a delay (`defer_backoff`); dead-lettered after `max_defers` |
72
+ | raises | it is retried after a delay (`retry_backoff`); dead-lettered after `max_attempts` |
73
+ | the worker dies mid-handler | another worker reclaims it after `reclaim_after_ms` |
63
74
 
64
75
  ### Delivery semantics
65
76
 
66
77
  Delivery is **at-least-once**. A message can be handled more than once (a worker
67
- dies after the handler finished but before the ack, or a handler is slower than
68
- `reclaim_after_ms`), so handlers must be idempotent. Set `reclaim_after_ms`
69
- comfortably above your slowest handler.
78
+ dies after the handler finished but before the ack), so handlers must be
79
+ idempotent. Ordering is never guaranteed by the queue itself; see Deferral.
80
+
81
+ ## Messages and serializers
82
+
83
+ The handler receives a `Queekiq::Message`:
84
+
85
+ | | |
86
+ |---|---|
87
+ | `payload` | what you enqueued, decoded by the serializer (lazily) |
88
+ | `raw` | the payload as stored, a String |
89
+ | `id` | the stream entry id |
90
+ | `attempt` | 1 on the first delivery; +1 per retry or per delivery that never finished |
91
+ | `defers` | how many times it has been deferred |
92
+ | `enqueued_at` | when it was first enqueued (epoch ms); `latency_ms` is the age |
70
93
 
71
- ### Outcomes and deferral
94
+ Serializers are anything with `dump(object) -> String` and `load(String) -> object`:
95
+ `Serializers::Raw` (default, plain strings, like Redis itself), `Serializers::JSON`
96
+ (hash keys come back as strings), or your own (MessagePack, Marshal, ...).
97
+ Because decoding is lazy, a payload that can't be decoded raises inside your
98
+ handler, and goes through retries and the dead stream like any other failure.
72
99
 
73
- The handler receives a `Queekiq::Message` (`#id`, `#payload`) and returns an
74
- outcome. If the outcome is in `defer_on` (default `[:deferred]`) the payload is
75
- scheduled to come back after the queue's `defer_delay_ms` (default 200 ms). The
76
- original delivery is acked either way.
100
+ ## Deferral and ordering
101
+
102
+ A handler returning `:deferred` means "not yet, try again shortly". The message
103
+ keeps its history, and each deferral waits a little longer (200 ms, 400 ms, ...
104
+ up to 5 s by default).
77
105
 
78
106
  That is the building block for ordering. For example, process jobs for the same
79
- account strictly in submission order, with the ordering rule living in your
80
- database:
107
+ account strictly in submission order, with the rule living in your database:
81
108
 
82
109
  ```ruby
83
110
  Queekiq::Worker.new(queue: queue) do |message|
84
111
  job = Job.find(message.payload)
85
112
  next :already_done unless job.pending?
86
- next :deferred if Job.earlier_pending_for_same_account?(job) # come back later
113
+ next :deferred if Job.earlier_pending_for_same_account?(job)
87
114
 
88
115
  job.run!
89
116
  :done
90
117
  end
91
118
  ```
92
119
 
93
- ### Errors
120
+ The ordering key can be anything your database can compare: a sequence number,
121
+ a ULID, a timestamp, or a state machine ("a refund can only apply to a captured
122
+ payment"). Use `max_defers` if a message that waits forever should end up in the
123
+ dead stream instead.
124
+
125
+ ## Failures, retries and dead letters
94
126
 
95
- If the handler raises, the worker logs it, calls `on_error` (if given, e.g. to
96
- report to an error tracker) and leaves the message **unacked**, so it is
97
- redelivered after `reclaim_after_ms`. A message that always raises will
98
- therefore be retried forever; handlers that should give up should rescue their
99
- own errors, record the failure and return an outcome instead.
127
+ A raising handler is logged, passed to `on_error` (e.g. to report to an error
128
+ tracker), and retried after `retry_backoff` (default 1 s, doubling, up to 5 min,
129
+ with jitter). After `max_attempts` (default 5) it is moved to the dead stream
130
+ with the reason and the error. A message whose workers keep *dying* is
131
+ dead-lettered the same way, without running the handler again.
100
132
 
101
133
  ```ruby
102
- Queekiq::Worker.new(queue: queue, on_error: ->(error, message) { Sentry.capture_exception(error) }) { ... }
134
+ queue.dead_size # => 1
135
+ dead = queue.dead_messages.first # DeadMessage
136
+ dead.raw; dead.reason; dead.error_class; dead.error_message; dead.attempt
137
+ queue.requeue_dead(dead.id) # back on the queue, attempt reset
138
+ queue.delete_dead(dead.id)
103
139
  ```
104
140
 
141
+ Set `max_attempts: nil` to retry forever. A handler that should never be
142
+ retried can rescue its own errors and return an outcome instead.
143
+
144
+ ## Long jobs
145
+
146
+ While a handler runs, the worker touches its message every `reclaim_after_ms / 3`
147
+ (`heartbeat_interval_ms`; `false` disables it). A slow job is therefore never
148
+ reclaimed and run twice just for being slow, while a worker that really died
149
+ stops heartbeating and is reclaimed after `reclaim_after_ms`. A worker that lost
150
+ its message (it was reclaimed anyway) can't take it back.
151
+
152
+ ## Shutdown
153
+
154
+ `worker.stop` (called for you on INT/TERM by `start`) lets the message in hand
155
+ finish. If that takes longer than `shutdown_timeout` (default 25 s), the worker
156
+ gives up on it and exits; the message stays pending and another worker reclaims
157
+ it. Set `shutdown_timeout:` below your platform's kill grace period (Docker's is
158
+ 10 s by default, Kubernetes' 30 s).
159
+
160
+ ## When Redis goes away
161
+
162
+ Connection errors don't crash the worker: it waits (`reconnect_backoff`, 0.5 s
163
+ doubling up to 30 s) and tries again. A stream or consumer group that vanished
164
+ (Redis restarted without persistence) is recreated. Messages being handled stay
165
+ pending and are reclaimed afterwards.
166
+
167
+ ## Observability
168
+
169
+ ```ruby
170
+ queue.stats
171
+ # => { backlog: 12, pending: 2, oldest_pending_idle_ms: 340, deferred: 3, dead: 0,
172
+ # consumers: [{ name: "web1-123", pending: 1, idle_ms: 5 }, ...] }
173
+ ```
174
+
175
+ `oldest_pending_idle_ms` is the one to alert on: a large value means workers are
176
+ stuck or dead. `backlog` is nil if Redis can't tell (after entries were deleted).
177
+
178
+ Workers emit events with these names, each with `queue:` and `consumer:` and a
179
+ `duration_ms:`:
180
+
181
+ | Event | Extra payload |
182
+ |---|---|
183
+ | `process.queekiq` | `message`, `outcome`, `latency_ms`, `exception` if the handler raised |
184
+ | `defer.queekiq` | `message`, `delay_ms` |
185
+ | `retry.queekiq` | `message`, `delay_ms`, `error` |
186
+ | `dead_letter.queekiq` | `message`, `reason`, `error` |
187
+ | `reclaim.queekiq` | `count` |
188
+ | `connection_error.queekiq` | `error`, `retry_in_ms`, `failures` |
189
+
190
+ ```ruby
191
+ Queekiq.subscribe("process.queekiq") do |_name, payload|
192
+ StatsD.timing("queekiq.process", payload[:duration_ms], tags: [ "queue:#{payload[:queue]}" ])
193
+ StatsD.timing("queekiq.latency", payload[:latency_ms]) if payload[:latency_ms]
194
+ end
195
+
196
+ # Or in Rails, use ActiveSupport::Notifications instead:
197
+ Queekiq.configure { |c| c.instrumenter = ActiveSupport::Notifications }
198
+ ```
199
+
200
+ Logs are one `key=value` line per event, e.g.
201
+ `[queekiq] event=retry queue=emails consumer=web1-123 id=1-0 payload=42 attempt=1 delay_ms=1043`.
202
+ Payloads are truncated to 100 characters, but don't put secrets in them.
203
+
105
204
  ## Configuration
106
205
 
107
- Global (`Queekiq.configure`): `redis_url`, `redis` (an existing client, e.g. a
108
- shared one), `logger` (defaults to `Rails.logger`, else stdout).
206
+ Global (`Queekiq.configure`): `redis_url`, `redis` (an existing client),
207
+ `logger` (defaults to `Rails.logger`, else stdout), `instrumenter`.
109
208
 
110
209
  `Queekiq::Queue.new(name, **options)`:
111
210
 
112
211
  | Option | Default | |
113
212
  |---|---|---|
114
- | `stream`, `group`, `deferred_key` | derived from `name` | Override to adopt existing keys. |
213
+ | `stream`, `group`, `deferred_key`, `dead_key` | derived from `name` | Override to adopt existing keys. |
115
214
  | `field` | `"payload"` | Stream field holding the payload. |
215
+ | `serializer` | `Serializers::Raw` | How payloads are stored. |
116
216
  | `reclaim_after_ms` | `30_000` | Idle time before a pending message is reclaimed. |
117
- | `defer_delay_ms` | `200` | Default delay for `#defer`. |
217
+ | `max_attempts` | `5` | Failed attempts before dead-lettering; `nil` = forever. |
218
+ | `max_defers` | `nil` | Deferrals before dead-lettering; `nil` = forever. |
219
+ | `retry_backoff` | `Backoff.new(base_ms: 1_000, max_ms: 300_000)` | Delay after a failure. |
220
+ | `defer_backoff` | `Backoff.new(base_ms: 200, max_ms: 5_000)` | Delay after a deferral. |
118
221
  | `delete_on_ack` | `false` | `XDEL` entries once acked so the stream doesn't grow without bound. |
119
222
  | `redis` | `Queekiq.redis` | A client dedicated to this queue. |
120
223
 
121
224
  `Queekiq::Worker.new(queue:, handler: nil, **options, &block)`:
122
- `consumer`, `logger`, `defer_on` (`[:deferred]`), `block_ms` (`1_000`, also the
123
- worst-case time to stop), `on_error`.
124
225
 
125
- Use one Redis connection per worker thread: a blocking read occupies its
126
- connection.
226
+ | Option | Default | |
227
+ |---|---|---|
228
+ | `consumer` | `hostname-pid` | Unique per worker process. |
229
+ | `defer_on` | `[:deferred]` | Outcomes that defer the message. |
230
+ | `block_ms` | `1_000` | How long one read blocks. |
231
+ | `on_error` | `nil` | `#call(error, message)`. |
232
+ | `heartbeat_interval_ms` | `reclaim_after_ms / 3` | `false` disables. |
233
+ | `shutdown_timeout` | `25` (seconds) | `nil` waits forever. |
234
+ | `reconnect_backoff` | `Backoff.new(base_ms: 500, max_ms: 30_000)` | Pause while Redis is down. |
235
+ | `logger` | `Queekiq.logger` | |
236
+
237
+ `Queekiq::Backoff.new(base_ms:, max_ms:, factor: 2, jitter: 0.2)` is exponential
238
+ with jitter; `Backoff.fixed(ms)` is constant; anything with `delay_ms(attempt)` works.
127
239
 
128
- On Redis Cluster, `stream` and `deferred_key` must share a hash tag (the
129
- defaults do).
240
+ Use one Redis connection per worker thread: a blocking read occupies its
241
+ connection. On Redis Cluster, the stream, deferred and dead keys must share a
242
+ hash tag (the defaults do).
130
243
 
131
244
  ## Low-level API
132
245
 
133
- `Queekiq::Queue` can be used without `Worker`: `enqueue`, `defer`,
134
- `promote_due!`, `ensure_group!`, `read`, `reclaim`, `ack`, plus `size`,
135
- `deferred_size` and `clear!` (for tests).
246
+ `Queekiq::Queue` can be used without `Worker`: `enqueue`, `defer`, `retry_later`,
247
+ `dead_letter`, `promote_due!`, `ensure_group!`, `read`, `reclaim`, `touch`, `ack`,
248
+ plus `stats`, `size`, `deferred_size`, `dead_size` and `clear!` (for tests).
136
249
 
137
250
  ## Development
138
251
 
139
252
  ```bash
140
253
  docker compose up -d redis # or any Redis >= 7 on REDIS_URL
141
254
  bundle install
142
- bundle exec rspec
255
+ bundle exec rake # specs
256
+ bundle exec rubocop
143
257
  ```
144
258
 
259
+ CI (GitHub Actions) runs the lint, the specs on Ruby 3.1-3.4, the redis gem 4.8,
260
+ 5.x and 6.x and Redis 7.0 and 7.4, and builds and installs the gem.
261
+ To run the specs against another redis gem locally: `REDIS_GEM_VERSION="~> 5.0" bundle update redis`.
262
+
145
263
  ## Not included (yet)
146
264
 
147
- Delivery-count limits / dead-letter stream, stream trimming beyond
148
- `delete_on_ack`, delayed enqueue of new work (only deferral of existing
149
- payloads), metrics.
265
+ Per-key rate limits or leases, sharded streams for strict per-key ordering,
266
+ scheduling brand-new work for later (only deferral and retries), replay and
267
+ multiple consumer groups, a web UI, and an in-memory backend for tests.
150
268
 
151
269
  ## License
152
270
 
@@ -0,0 +1,31 @@
1
+ module Queekiq
2
+ # Exponential delay with jitter and a cap: base, base*factor, base*factor^2,
3
+ # ... never more than max_ms, each randomly shifted by up to +/- `jitter`
4
+ # (a fraction, 0.2 = 20%).
5
+ #
6
+ # Anything responding to #delay_ms(attempt) can be used where a backoff is
7
+ # expected; Backoff.fixed(ms) gives a constant delay.
8
+ class Backoff
9
+ attr_reader :base_ms, :max_ms, :factor, :jitter
10
+
11
+ def self.fixed(ms)
12
+ new(base_ms: ms, max_ms: ms, factor: 1, jitter: 0)
13
+ end
14
+
15
+ def initialize(base_ms: 200, max_ms: 30_000, factor: 2, jitter: 0.2, random: Random)
16
+ @base_ms = base_ms
17
+ @max_ms = max_ms
18
+ @factor = factor.to_f
19
+ @jitter = jitter
20
+ @random = random
21
+ end
22
+
23
+ # attempt is 1-based: delay_ms(1) is the delay before the first retry.
24
+ def delay_ms(attempt)
25
+ steps = [ attempt.to_i - 1, 0 ].max
26
+ delay = [ base_ms * (factor**steps), max_ms ].min
27
+ delay *= 1 + (((@random.rand * 2) - 1) * jitter) if jitter.positive?
28
+ [ delay.round, 0 ].max
29
+ end
30
+ end
31
+ end
@@ -8,7 +8,7 @@ module Queekiq
8
8
  DEFAULT_REDIS_URL = "redis://localhost:6379/0".freeze
9
9
 
10
10
  attr_accessor :redis_url
11
- attr_writer :redis, :logger
11
+ attr_writer :redis, :logger, :instrumenter
12
12
 
13
13
  def initialize
14
14
  @redis_url = ENV.fetch("REDIS_URL", DEFAULT_REDIS_URL)
@@ -25,6 +25,12 @@ module Queekiq
25
25
  @logger || default_logger
26
26
  end
27
27
 
28
+ # Receives the events workers emit (see Instrumenter). Defaults to a
29
+ # built-in Instrumenter; anything with the same #instrument call shape works.
30
+ def instrumenter
31
+ @instrumenter ||= Instrumenter.new
32
+ end
33
+
28
34
  private
29
35
 
30
36
  def default_logger
@@ -0,0 +1,54 @@
1
+ module Queekiq
2
+ # A tiny in-process event bus, so metrics and tracing can hook in without
3
+ # Queekiq depending on any monitoring library.
4
+ #
5
+ # Queekiq.subscribe("process.queekiq") do |name, payload|
6
+ # StatsD.timing("queekiq.process", payload[:duration_ms])
7
+ # end
8
+ #
9
+ # Workers call `instrument(name, payload) { |payload| ... }`. The block's
10
+ # duration is added to the payload as :duration_ms and, if it raises, the error
11
+ # as :exception. Subscribers that raise are ignored.
12
+ #
13
+ # The call shape matches ActiveSupport::Notifications, so in Rails you can use
14
+ # it instead: `Queekiq.configure { |c| c.instrumenter = ActiveSupport::Notifications }`
15
+ # (then subscribe through ActiveSupport).
16
+ class Instrumenter
17
+ def initialize
18
+ @subscribers = []
19
+ end
20
+
21
+ # pattern: nil (every event), a String (exact event name) or a Regexp.
22
+ def subscribe(pattern = nil, &block)
23
+ raise ArgumentError, "a block is required" unless block
24
+
25
+ @subscribers << [ pattern, block ]
26
+ block
27
+ end
28
+
29
+ def instrument(name, payload = {})
30
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+ begin
32
+ block_given? ? yield(payload) : nil
33
+ rescue StandardError => e
34
+ payload[:exception] = [ e.class.name, e.message ]
35
+ raise
36
+ ensure
37
+ payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
38
+ publish(name, payload)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def publish(name, payload)
45
+ @subscribers.each do |pattern, block|
46
+ next unless pattern.nil? || pattern === name # rubocop:disable Style/CaseEquality
47
+
48
+ block.call(name, payload)
49
+ rescue StandardError
50
+ nil # a broken subscriber must never break message processing
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,5 +1,50 @@
1
1
  module Queekiq
2
- # One delivery from a queue. `id` is the Redis stream entry id (needed to
3
- # ack); `payload` is the string that was passed to Queue#enqueue.
4
- Message = Struct.new(:id, :payload, keyword_init: true)
2
+ # One delivery from a queue.
3
+ #
4
+ # id - the Redis stream entry id (needed to ack); nil for a message
5
+ # that was never delivered.
6
+ # payload - the enqueued object, decoded with the queue's serializer. It is
7
+ # decoded on first use, so a payload that can't be decoded fails
8
+ # inside the handler (and is retried / dead-lettered) instead of
9
+ # crashing the worker.
10
+ # raw - the payload exactly as stored in Redis (a String).
11
+ # attempt - 1 for the first delivery; goes up each time the message is
12
+ # redelivered after a failure or after its worker died.
13
+ # defers - how many times the message has been deferred so far.
14
+ # enqueued_at - when it was first enqueued, in epoch milliseconds (nil for
15
+ # entries written by Queekiq 0.1).
16
+ class Message
17
+ NOT_LOADED = Object.new.freeze
18
+ private_constant :NOT_LOADED
19
+
20
+ attr_reader :id, :raw, :attempt, :defers, :enqueued_at
21
+
22
+ # Pass `payload:` (it is serialized to get `raw`) or `raw:` (decoded lazily).
23
+ def initialize(id:, payload: NOT_LOADED, raw: nil, serializer: Serializers::Raw,
24
+ attempt: 1, defers: 0, enqueued_at: nil)
25
+ raise ArgumentError, "pass payload: or raw:" if payload.equal?(NOT_LOADED) && raw.nil?
26
+
27
+ @id = id
28
+ @serializer = serializer
29
+ @payload = payload
30
+ @raw = raw.nil? ? serializer.dump(payload) : raw
31
+ @attempt = attempt
32
+ @defers = defers
33
+ @enqueued_at = enqueued_at
34
+ end
35
+
36
+ def payload
37
+ @payload = @serializer.load(@raw) if @payload.equal?(NOT_LOADED)
38
+ @payload
39
+ end
40
+
41
+ # Milliseconds since the message was first enqueued, or nil if unknown.
42
+ def latency_ms(now_ms = (Time.now.to_f * 1000).to_i)
43
+ now_ms - enqueued_at if enqueued_at
44
+ end
45
+
46
+ def inspect
47
+ "#<#{self.class} id=#{id.inspect} raw=#{raw[0, 60].inspect} attempt=#{attempt} defers=#{defers}>"
48
+ end
49
+ end
5
50
  end