queekiq 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +153 -0
- data/lib/queekiq/configuration.rb +38 -0
- data/lib/queekiq/message.rb +5 -0
- data/lib/queekiq/queue.rb +147 -0
- data/lib/queekiq/version.rb +3 -0
- data/lib/queekiq/worker.rb +96 -0
- data/lib/queekiq.rb +33 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4310b94018510b1e6353861e3d6b282c2141f214b56fba92628f61291ac38f6b
|
|
4
|
+
data.tar.gz: 52b6ddc84908e73f43e1253f20292ca59284b622503040096ff35463fd630547
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 41275ff885c8b44f5d8d9c5583227355a7dc858846c4d5438c3543d2a4877c6810a35ba3eb3befaf1d2e3b4e9beb31f38e7cb638d08d323faa00bf36c1cacecc
|
|
7
|
+
data.tar.gz: 4c471bd854b4a3ed23a4e460377c6936ddcc86c2c7377e9a0ce03e3d7e7de18b71deb6dcc6f923341a8840e595167f4cdbe5d65f8a010fd3395f1dc72ea4ab07
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Queekiq
|
|
2
|
+
|
|
3
|
+
A small, framework-agnostic work queue on **Redis Streams**.
|
|
4
|
+
|
|
5
|
+
- Consumer groups, so any number of worker processes share one queue.
|
|
6
|
+
- At-least-once delivery: messages stay pending until acked, and messages
|
|
7
|
+
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.
|
|
11
|
+
|
|
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).
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "queekiq", github: "your-user/queekiq" # or path: "../queekiq" while developing
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# config/initializers/queekiq.rb (optional; defaults to ENV["REDIS_URL"])
|
|
25
|
+
Queekiq.configure do |c|
|
|
26
|
+
c.redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
EMAILS = Queekiq::Queue.new("emails")
|
|
30
|
+
|
|
31
|
+
# Producer (web process, console, ...)
|
|
32
|
+
EMAILS.enqueue(user.id) # payloads are strings; use JSON for structured data
|
|
33
|
+
|
|
34
|
+
# Consumer (bin/email_worker)
|
|
35
|
+
worker = Queekiq::Worker.new(queue: EMAILS) do |message|
|
|
36
|
+
Mailer.deliver(message.payload)
|
|
37
|
+
:sent # any return value is the "outcome"
|
|
38
|
+
end
|
|
39
|
+
worker.start # traps INT/TERM, runs until stopped
|
|
40
|
+
```
|
|
41
|
+
|
|
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.
|
|
45
|
+
|
|
46
|
+
## How it works
|
|
47
|
+
|
|
48
|
+
| Piece | Redis structure | Default key |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| Messages | stream | `queekiq:{name}:stream` |
|
|
51
|
+
| Workers | consumer group | `queekiq-<name>` |
|
|
52
|
+
| Deferred payloads | sorted set (score = due time in ms) | `queekiq:{name}:deferred` |
|
|
53
|
+
|
|
54
|
+
Each worker iteration:
|
|
55
|
+
|
|
56
|
+
1. **Promote** deferred payloads that are due back onto the stream (atomically,
|
|
57
|
+
in a Lua script, so none are lost or promoted twice).
|
|
58
|
+
2. **Reclaim** pending messages idle for longer than `reclaim_after_ms`
|
|
59
|
+
(default 30 s), i.e. ones whose worker died.
|
|
60
|
+
3. **Read** new messages, blocking up to `block_ms` (default 1 s).
|
|
61
|
+
|
|
62
|
+
Each message goes through your handler and is then acked.
|
|
63
|
+
|
|
64
|
+
### Delivery semantics
|
|
65
|
+
|
|
66
|
+
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.
|
|
70
|
+
|
|
71
|
+
### Outcomes and deferral
|
|
72
|
+
|
|
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.
|
|
77
|
+
|
|
78
|
+
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:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
Queekiq::Worker.new(queue: queue) do |message|
|
|
84
|
+
job = Job.find(message.payload)
|
|
85
|
+
next :already_done unless job.pending?
|
|
86
|
+
next :deferred if Job.earlier_pending_for_same_account?(job) # come back later
|
|
87
|
+
|
|
88
|
+
job.run!
|
|
89
|
+
:done
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Errors
|
|
94
|
+
|
|
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.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
Queekiq::Worker.new(queue: queue, on_error: ->(error, message) { Sentry.capture_exception(error) }) { ... }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
Global (`Queekiq.configure`): `redis_url`, `redis` (an existing client, e.g. a
|
|
108
|
+
shared one), `logger` (defaults to `Rails.logger`, else stdout).
|
|
109
|
+
|
|
110
|
+
`Queekiq::Queue.new(name, **options)`:
|
|
111
|
+
|
|
112
|
+
| Option | Default | |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `stream`, `group`, `deferred_key` | derived from `name` | Override to adopt existing keys. |
|
|
115
|
+
| `field` | `"payload"` | Stream field holding the payload. |
|
|
116
|
+
| `reclaim_after_ms` | `30_000` | Idle time before a pending message is reclaimed. |
|
|
117
|
+
| `defer_delay_ms` | `200` | Default delay for `#defer`. |
|
|
118
|
+
| `delete_on_ack` | `false` | `XDEL` entries once acked so the stream doesn't grow without bound. |
|
|
119
|
+
| `redis` | `Queekiq.redis` | A client dedicated to this queue. |
|
|
120
|
+
|
|
121
|
+
`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
|
+
|
|
125
|
+
Use one Redis connection per worker thread: a blocking read occupies its
|
|
126
|
+
connection.
|
|
127
|
+
|
|
128
|
+
On Redis Cluster, `stream` and `deferred_key` must share a hash tag (the
|
|
129
|
+
defaults do).
|
|
130
|
+
|
|
131
|
+
## Low-level API
|
|
132
|
+
|
|
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).
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
docker compose up -d redis # or any Redis >= 7 on REDIS_URL
|
|
141
|
+
bundle install
|
|
142
|
+
bundle exec rspec
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Not included (yet)
|
|
146
|
+
|
|
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.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
require "redis"
|
|
3
|
+
|
|
4
|
+
module Queekiq
|
|
5
|
+
# Global defaults shared by every Queue and Worker that doesn't get its own
|
|
6
|
+
# `redis:` / `logger:` argument.
|
|
7
|
+
class Configuration
|
|
8
|
+
DEFAULT_REDIS_URL = "redis://localhost:6379/0".freeze
|
|
9
|
+
|
|
10
|
+
attr_accessor :redis_url
|
|
11
|
+
attr_writer :redis, :logger
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@redis_url = ENV.fetch("REDIS_URL", DEFAULT_REDIS_URL)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A shared client, built lazily from `redis_url` unless one was assigned.
|
|
18
|
+
def redis
|
|
19
|
+
@redis ||= Redis.new(url: redis_url)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Falls back to Rails.logger when running inside Rails, otherwise to a
|
|
23
|
+
# logger on $stdout.
|
|
24
|
+
def logger
|
|
25
|
+
@logger || default_logger
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def default_logger
|
|
31
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
32
|
+
::Rails.logger
|
|
33
|
+
else
|
|
34
|
+
@default_logger ||= Logger.new($stdout)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
module Queekiq
|
|
2
|
+
# A durable work queue: a Redis Stream read through a consumer group, plus a
|
|
3
|
+
# sorted set of "deferred" payloads that are pushed back onto the stream once
|
|
4
|
+
# their delay has elapsed.
|
|
5
|
+
#
|
|
6
|
+
# queue = Queekiq::Queue.new("emails")
|
|
7
|
+
# queue.enqueue("user-42")
|
|
8
|
+
# queue.ensure_group!
|
|
9
|
+
# queue.read(consumer: "worker-1").each { |m| ...; queue.ack(m) }
|
|
10
|
+
#
|
|
11
|
+
# Delivery is at-least-once: a message stays pending until it is acked, and
|
|
12
|
+
# #reclaim hands pending messages that have been idle too long to another
|
|
13
|
+
# consumer. Handlers must therefore be idempotent.
|
|
14
|
+
#
|
|
15
|
+
# Requires Redis >= 7.0.
|
|
16
|
+
class Queue
|
|
17
|
+
DEFAULT_FIELD = "payload".freeze
|
|
18
|
+
DEFAULT_RECLAIM_AFTER_MS = 30_000
|
|
19
|
+
DEFAULT_DEFER_DELAY_MS = 200
|
|
20
|
+
|
|
21
|
+
# Moves due members of the deferred set onto the stream in one atomic step,
|
|
22
|
+
# so a crash can't lose a payload between "removed" and "re-enqueued", and
|
|
23
|
+
# two workers can't both promote the same one.
|
|
24
|
+
# KEYS: deferred set, stream ARGV: now_ms, limit, field name
|
|
25
|
+
PROMOTE_SCRIPT = <<~LUA.freeze
|
|
26
|
+
local due = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2])
|
|
27
|
+
for _, member in ipairs(due) do
|
|
28
|
+
redis.call('ZREM', KEYS[1], member)
|
|
29
|
+
redis.call('XADD', KEYS[2], '*', ARGV[3], member)
|
|
30
|
+
end
|
|
31
|
+
return #due
|
|
32
|
+
LUA
|
|
33
|
+
|
|
34
|
+
attr_reader :name, :stream, :group, :deferred_key, :field, :reclaim_after_ms, :defer_delay_ms
|
|
35
|
+
|
|
36
|
+
# name - used to derive the default Redis keys.
|
|
37
|
+
# stream/group/deferred_key
|
|
38
|
+
# - override the derived names, e.g. to adopt keys an
|
|
39
|
+
# application already uses. (On Redis Cluster the stream
|
|
40
|
+
# and deferred key must share a hash tag.)
|
|
41
|
+
# field - the stream field holding the payload.
|
|
42
|
+
# reclaim_after_ms - idle time after which a pending message may be reclaimed.
|
|
43
|
+
# defer_delay_ms - default delay for #defer.
|
|
44
|
+
# delete_on_ack - also XDEL entries once acked, so the stream doesn't
|
|
45
|
+
# grow forever. Only safe with a single consumer group
|
|
46
|
+
# (which is what Queekiq uses).
|
|
47
|
+
# redis - a client for this queue; defaults to Queekiq.redis.
|
|
48
|
+
def initialize(name, stream: nil, group: nil, deferred_key: nil, field: DEFAULT_FIELD,
|
|
49
|
+
reclaim_after_ms: DEFAULT_RECLAIM_AFTER_MS, defer_delay_ms: DEFAULT_DEFER_DELAY_MS,
|
|
50
|
+
delete_on_ack: false, redis: nil)
|
|
51
|
+
raise ArgumentError, "queue name can't be blank" if name.to_s.strip.empty?
|
|
52
|
+
|
|
53
|
+
@name = name.to_s
|
|
54
|
+
@stream = stream || "queekiq:{#{@name}}:stream"
|
|
55
|
+
@group = group || "queekiq-#{@name}"
|
|
56
|
+
@deferred_key = deferred_key || "queekiq:{#{@name}}:deferred"
|
|
57
|
+
@field = field
|
|
58
|
+
@reclaim_after_ms = reclaim_after_ms
|
|
59
|
+
@defer_delay_ms = defer_delay_ms
|
|
60
|
+
@delete_on_ack = delete_on_ack
|
|
61
|
+
@redis = redis
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def redis
|
|
65
|
+
@redis || Queekiq.redis
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Appends a message to the stream. `payload` is stored as a string (use
|
|
69
|
+
# JSON yourself for structured data). Returns the stream entry id.
|
|
70
|
+
def enqueue(payload)
|
|
71
|
+
redis.xadd(stream, { field => payload.to_s })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Schedules `payload` to be re-enqueued after `delay_ms`. The deferred set
|
|
75
|
+
# holds one entry per distinct payload: deferring the same payload twice
|
|
76
|
+
# just moves its due time.
|
|
77
|
+
def defer(payload, delay_ms: defer_delay_ms)
|
|
78
|
+
redis.zadd(deferred_key, now_ms + delay_ms, payload.to_s)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Re-enqueues up to `limit` deferred payloads whose delay has elapsed.
|
|
82
|
+
# Returns how many were promoted.
|
|
83
|
+
def promote_due!(limit: 50)
|
|
84
|
+
redis.eval(PROMOTE_SCRIPT, keys: [ deferred_key, stream ], argv: [ now_ms, limit, field ])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Creates the consumer group (and the stream if needed). Safe to call
|
|
88
|
+
# repeatedly. The group starts at the beginning of the stream, so messages
|
|
89
|
+
# enqueued before the group existed are still delivered.
|
|
90
|
+
def ensure_group!
|
|
91
|
+
redis.xgroup(:create, stream, group, "0", mkstream: true)
|
|
92
|
+
rescue Redis::CommandError => e
|
|
93
|
+
raise unless e.message.include?("BUSYGROUP")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Blocks up to `block_ms` for new messages for `consumer`.
|
|
97
|
+
# Returns an array of Message (empty when nothing arrived). Messages stay
|
|
98
|
+
# pending until #ack.
|
|
99
|
+
def read(consumer:, count: 1, block_ms: 5_000)
|
|
100
|
+
result = redis.xreadgroup(group, consumer, stream, ">", count: count, block: block_ms)
|
|
101
|
+
build_messages(result&.dig(stream) || [])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Takes over pending messages that have been idle for reclaim_after_ms
|
|
105
|
+
# (their consumer probably died) and returns them as Messages.
|
|
106
|
+
def reclaim(consumer:, count: 10)
|
|
107
|
+
result = redis.xautoclaim(stream, group, consumer, reclaim_after_ms, "0-0", count: count)
|
|
108
|
+
entries = result.is_a?(Hash) ? result["entries"] : result[1]
|
|
109
|
+
build_messages(entries || [])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Marks a message as done. Accepts a Message or a bare stream entry id.
|
|
113
|
+
def ack(message_or_id)
|
|
114
|
+
id = message_or_id.respond_to?(:id) ? message_or_id.id : message_or_id
|
|
115
|
+
redis.xack(stream, group, id)
|
|
116
|
+
redis.xdel(stream, id) if @delete_on_ack
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Number of entries currently in the stream.
|
|
120
|
+
def size
|
|
121
|
+
redis.xlen(stream)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Number of payloads waiting in the deferred set.
|
|
125
|
+
def deferred_size
|
|
126
|
+
redis.zcard(deferred_key)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Deletes the stream (and its group) and the deferred set. Meant for tests.
|
|
130
|
+
def clear!
|
|
131
|
+
redis.del(stream, deferred_key)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def now_ms
|
|
137
|
+
(Time.now.to_f * 1000).to_i
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def build_messages(entries)
|
|
141
|
+
entries.map do |id, fields|
|
|
142
|
+
payload = fields.fetch(field) { raise Error, "stream entry #{id} has no #{field.inspect} field" }
|
|
143
|
+
Message.new(id: id, payload: payload)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
|
|
3
|
+
module Queekiq
|
|
4
|
+
# The consumer loop. Each iteration it promotes due deferrals, picks up
|
|
5
|
+
# messages other consumers abandoned, then blocks briefly for new ones; every
|
|
6
|
+
# message goes through the handler and is acked afterwards.
|
|
7
|
+
#
|
|
8
|
+
# worker = Queekiq::Worker.new(queue: queue) do |message|
|
|
9
|
+
# process(message.payload) # return an outcome, e.g. :done or :deferred
|
|
10
|
+
# end
|
|
11
|
+
# worker.start # traps INT/TERM and runs until stopped
|
|
12
|
+
#
|
|
13
|
+
# The handler receives a Queekiq::Message and may return anything. If the
|
|
14
|
+
# outcome is one of `defer_on` (default: :deferred) the payload is put on the
|
|
15
|
+
# queue's deferred set to be retried later. After the handler returns, the
|
|
16
|
+
# message is acked whatever the outcome.
|
|
17
|
+
#
|
|
18
|
+
# If the handler raises, the error is logged, `on_error` is called, and the
|
|
19
|
+
# message is left unacked: it will be redelivered once the queue's
|
|
20
|
+
# reclaim_after_ms has passed. A handler that must not be retried forever
|
|
21
|
+
# should rescue its own errors and return an outcome instead.
|
|
22
|
+
class Worker
|
|
23
|
+
attr_reader :queue, :consumer
|
|
24
|
+
|
|
25
|
+
def self.default_consumer
|
|
26
|
+
"#{Socket.gethostname}-#{Process.pid}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# handler - anything responding to #call(message); alternatively pass a block.
|
|
30
|
+
# consumer - unique per worker process (default: hostname + pid).
|
|
31
|
+
# defer_on - outcomes that trigger Queue#defer.
|
|
32
|
+
# block_ms - how long one read blocks; also the worst-case stop latency.
|
|
33
|
+
# on_error - optional #call(error, message), e.g. to report to an error tracker.
|
|
34
|
+
def initialize(queue:, handler: nil, consumer: self.class.default_consumer, logger: nil,
|
|
35
|
+
defer_on: [ :deferred ], block_ms: 1_000, on_error: nil, &block)
|
|
36
|
+
@handler = handler || block
|
|
37
|
+
raise ArgumentError, "pass a handler: or a block" unless @handler
|
|
38
|
+
|
|
39
|
+
@queue = queue
|
|
40
|
+
@consumer = consumer
|
|
41
|
+
@logger = logger
|
|
42
|
+
@defer_on = Array(defer_on)
|
|
43
|
+
@block_ms = block_ms
|
|
44
|
+
@on_error = on_error
|
|
45
|
+
@running = true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def logger
|
|
49
|
+
@logger || Queekiq.logger
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Ask the loop to finish its current iteration and return.
|
|
53
|
+
def stop
|
|
54
|
+
@running = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Convenience for worker scripts: unbuffered stdout, graceful stop on
|
|
58
|
+
# INT/TERM, then #run.
|
|
59
|
+
def start(signals: %w[INT TERM])
|
|
60
|
+
$stdout.sync = true
|
|
61
|
+
signals.each { |signal| trap(signal) { stop } }
|
|
62
|
+
run
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def run
|
|
66
|
+
queue.ensure_group!
|
|
67
|
+
logger.info("[queekiq] #{consumer} started on #{queue.name}")
|
|
68
|
+
|
|
69
|
+
while @running
|
|
70
|
+
queue.promote_due!
|
|
71
|
+
handle(queue.reclaim(consumer: consumer))
|
|
72
|
+
handle(queue.read(consumer: consumer, block_ms: @block_ms))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
logger.info("[queekiq] #{consumer} stopped")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Processes messages in the order given. Public so it can be unit tested
|
|
79
|
+
# without a running loop.
|
|
80
|
+
def handle(messages)
|
|
81
|
+
messages.each { |message| process(message) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def process(message)
|
|
87
|
+
outcome = @handler.call(message)
|
|
88
|
+
queue.defer(message.payload) if @defer_on.include?(outcome)
|
|
89
|
+
logger.info("[queekiq] #{consumer} payload=#{message.payload} outcome=#{outcome.inspect}")
|
|
90
|
+
queue.ack(message)
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
logger.error("[queekiq] #{consumer} payload=#{message.payload} failed: #{e.class}: #{e.message}")
|
|
93
|
+
@on_error&.call(e, message)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/queekiq.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "queekiq/version"
|
|
2
|
+
require "queekiq/configuration"
|
|
3
|
+
require "queekiq/message"
|
|
4
|
+
require "queekiq/queue"
|
|
5
|
+
require "queekiq/worker"
|
|
6
|
+
|
|
7
|
+
module Queekiq
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def configuration
|
|
12
|
+
@configuration ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Queekiq.configure { |c| c.redis_url = "redis://redis:6379/1" }
|
|
16
|
+
def configure
|
|
17
|
+
yield configuration
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def redis
|
|
21
|
+
configuration.redis
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def logger
|
|
25
|
+
configuration.logger
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Drops all configuration, including the memoized Redis client.
|
|
29
|
+
def reset!
|
|
30
|
+
@configuration = nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: queekiq
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Marco Aviles
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: redis
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '4.8'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '7'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '4.8'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '7'
|
|
32
|
+
description: 'Queekiq is a small, framework-agnostic job queue built on Redis Streams:
|
|
33
|
+
consumer groups for parallel workers, reclaiming of abandoned messages, delayed
|
|
34
|
+
re-enqueueing (deferral) and a graceful consumer loop.'
|
|
35
|
+
email:
|
|
36
|
+
- gdmarav374@gmail.com
|
|
37
|
+
executables: []
|
|
38
|
+
extensions: []
|
|
39
|
+
extra_rdoc_files: []
|
|
40
|
+
files:
|
|
41
|
+
- CHANGELOG.md
|
|
42
|
+
- LICENSE.txt
|
|
43
|
+
- README.md
|
|
44
|
+
- lib/queekiq.rb
|
|
45
|
+
- lib/queekiq/configuration.rb
|
|
46
|
+
- lib/queekiq/message.rb
|
|
47
|
+
- lib/queekiq/queue.rb
|
|
48
|
+
- lib/queekiq/version.rb
|
|
49
|
+
- lib/queekiq/worker.rb
|
|
50
|
+
homepage: https://github.com/mhero/queekiq
|
|
51
|
+
licenses:
|
|
52
|
+
- MIT
|
|
53
|
+
metadata:
|
|
54
|
+
homepage_uri: https://github.com/mhero/queekiq
|
|
55
|
+
changelog_uri: https://github.com/mhero/queekiq/blob/main/CHANGELOG.md
|
|
56
|
+
rubygems_mfa_required: 'true'
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '3.1'
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 4.0.22
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: Durable Redis Streams work queue with consumer groups, deferral and a worker
|
|
74
|
+
loop.
|
|
75
|
+
test_files: []
|