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 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
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0]
6
+
7
+ - Initial extraction: `Queekiq::Queue` (Redis Streams + consumer group, reclaim,
8
+ deferral), `Queekiq::Worker` (consumer loop), `Queekiq::Configuration`.
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,5 @@
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)
5
+ 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,3 @@
1
+ module Queekiq
2
+ VERSION = "0.1.0".freeze
3
+ 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: []