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 +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +170 -52
- data/lib/queekiq/backoff.rb +31 -0
- data/lib/queekiq/configuration.rb +7 -1
- data/lib/queekiq/instrumenter.rb +54 -0
- data/lib/queekiq/message.rb +48 -3
- data/lib/queekiq/queue.rb +251 -37
- data/lib/queekiq/serializers.rb +36 -0
- data/lib/queekiq/version.rb +1 -1
- data/lib/queekiq/worker.rb +236 -32
- data/lib/queekiq.rb +25 -2
- metadata +12 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 92b27e1bc09a36c133ab56236cb3f0086c92e92b6cabee6ab5d4e742e156dbe2
|
|
4
|
+
data.tar.gz: 94945eaf96bc1f8a5c373505f61356bf52ea816349f2bfcb98c21e055242b7df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
6
|
-
- At-least-once delivery
|
|
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
|
-
- **
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
43
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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,
|
|
108
|
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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`, `
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
data/lib/queekiq/message.rb
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
module Queekiq
|
|
2
|
-
# One delivery from a queue.
|
|
3
|
-
#
|
|
4
|
-
|
|
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
|