quiq 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/.github/workflows/ruby.yml +20 -0
- data/.gitignore +2 -0
- data/Gemfile.lock +10 -17
- data/README.md +126 -9
- data/bin/quiq +16 -10
- data/bin/quiqload +46 -0
- data/lib/quiq.rb +18 -14
- data/lib/quiq/client.rb +9 -6
- data/lib/quiq/config.rb +19 -13
- data/lib/quiq/job.rb +51 -0
- data/lib/quiq/queue.rb +65 -0
- data/lib/quiq/redis.rb +17 -0
- data/lib/quiq/scheduler.rb +51 -0
- data/lib/quiq/server.rb +14 -17
- data/lib/quiq/version.rb +1 -1
- data/lib/quiq/worker.rb +15 -9
- data/quiq.gemspec +6 -4
- data/testapp/Gemfile +1 -0
- data/testapp/Gemfile.lock +23 -14
- data/testapp/app/jobs/http_job.rb +14 -0
- data/testapp/app/jobs/test_job.rb +2 -2
- data/testapp/config/initializers/quiq.rb +2 -1
- metadata +16 -20
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f90fff6709ecb676daed0452eb08a59829a574b6a0d7b5819c9efe210a022453
|
4
|
+
data.tar.gz: 8391e3cc14ff6dc6ee97370f114bc314655714c983f04176cae17b1c251bbfb6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 87eceeb1209250a2932c1ef82c766c0fd9e333ede059ffffaebad9dca2e503d9ff9ff550f3a2424dd2db97bcbd090bf81cdede5d7ab589439bab3e6bc1f70f65
|
7
|
+
data.tar.gz: d3cc1741271ac08ef7abdc4e77262b411f0ae712c6415a62153227855094ff8e23ad35644496149cb6c914064395c5fe9dc7f1cfbb9afb2e74a5de468db97f5a
|
@@ -0,0 +1,20 @@
|
|
1
|
+
name: Ruby
|
2
|
+
|
3
|
+
on: [push]
|
4
|
+
|
5
|
+
jobs:
|
6
|
+
build:
|
7
|
+
|
8
|
+
runs-on: ubuntu-latest
|
9
|
+
|
10
|
+
steps:
|
11
|
+
- uses: actions/checkout@v2
|
12
|
+
- name: Set up Ruby 7.6
|
13
|
+
uses: actions/setup-ruby@v1
|
14
|
+
with:
|
15
|
+
ruby-version: 2.7.x
|
16
|
+
- name: Build and test with Rake
|
17
|
+
run: |
|
18
|
+
gem install bundler
|
19
|
+
bundle install --jobs 4 --retry 3
|
20
|
+
bundle exec rake spec
|
data/.gitignore
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,9 +1,8 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
quiq (0.
|
5
|
-
async-
|
6
|
-
async-redis (~> 0.4)
|
4
|
+
quiq (0.2.0)
|
5
|
+
async-redis (~> 0.4.3)
|
7
6
|
|
8
7
|
GEM
|
9
8
|
remote: https://rubygems.org/
|
@@ -67,23 +66,21 @@ GEM
|
|
67
66
|
console (~> 1.0)
|
68
67
|
nio4r (~> 2.3)
|
69
68
|
timers (~> 4.1)
|
70
|
-
async-container (0.16.1)
|
71
|
-
async (~> 1.0)
|
72
|
-
async-io (~> 1.26)
|
73
|
-
process-group
|
74
69
|
async-io (1.27.3)
|
75
70
|
async (~> 1.14)
|
76
|
-
async-
|
71
|
+
async-pool (0.2.0)
|
72
|
+
async (~> 1.8)
|
73
|
+
async-redis (0.4.3)
|
77
74
|
async (~> 1.8)
|
78
75
|
async-io (~> 1.10)
|
79
|
-
|
76
|
+
async-pool (~> 0.2)
|
77
|
+
protocol-redis (~> 0.4.0)
|
80
78
|
builder (3.2.4)
|
81
79
|
concurrent-ruby (1.1.5)
|
82
|
-
console (1.8.
|
80
|
+
console (1.8.1)
|
83
81
|
crass (1.0.6)
|
84
82
|
diff-lcs (1.3)
|
85
83
|
erubi (1.9.0)
|
86
|
-
ffi (1.12.2)
|
87
84
|
globalid (0.4.2)
|
88
85
|
activesupport (>= 4.2.0)
|
89
86
|
i18n (1.8.2)
|
@@ -103,11 +100,7 @@ GEM
|
|
103
100
|
nio4r (2.5.2)
|
104
101
|
nokogiri (1.10.7)
|
105
102
|
mini_portile2 (~> 2.4.0)
|
106
|
-
|
107
|
-
process-terminal (~> 0.2.0)
|
108
|
-
process-terminal (0.2.0)
|
109
|
-
ffi
|
110
|
-
protocol-redis (0.2.0)
|
103
|
+
protocol-redis (0.4.2)
|
111
104
|
rack (2.1.1)
|
112
105
|
rack-test (1.1.0)
|
113
106
|
rack (>= 1.0, < 3)
|
@@ -178,4 +171,4 @@ DEPENDENCIES
|
|
178
171
|
rspec (~> 3.0)
|
179
172
|
|
180
173
|
BUNDLED WITH
|
181
|
-
2.1.
|
174
|
+
2.1.4
|
data/README.md
CHANGED
@@ -1,8 +1,12 @@
|
|
1
1
|
# Quiq
|
2
2
|
|
3
|
-
|
3
|
+
Quiq is a distributed task queue backed by Redis to process jobs in background.
|
4
4
|
|
5
|
-
|
5
|
+
It relies on asynchronous IOs to process multiple jobs simultaneously. The event loop is provided by the [Async](https://github.com/socketry/async) library and many other gems of the [Socketry](https://github.com/socketry) family.
|
6
|
+
|
7
|
+
It can be used without Rails, but will play nicely with [ActiveJob](https://guides.rubyonrails.org/active_job_basics.html) even though it's not supported officialy (more details [here](#activejob-support)).
|
8
|
+
|
9
|
+
The library is in a very early stage, it is **not suitable for production** yet.
|
6
10
|
|
7
11
|
## Installation
|
8
12
|
|
@@ -22,7 +26,102 @@ Or install it yourself as:
|
|
22
26
|
|
23
27
|
## Usage
|
24
28
|
|
25
|
-
|
29
|
+
To launch the workers, you can use the `quiq` command.
|
30
|
+
|
31
|
+
```
|
32
|
+
Usage: quiq [options]
|
33
|
+
-p, --path PATH Location of the workers to load
|
34
|
+
-q, --queues NAMES Comma-separated list of queues to poll
|
35
|
+
-l, --log-level LEVEL The logging level
|
36
|
+
-v, --version Output version and exit
|
37
|
+
-h, --help Show this message
|
38
|
+
```
|
39
|
+
|
40
|
+
This is how to use it with a Rails application using [ActiveJob](https://guides.rubyonrails.org/active_job_basics.html)
|
41
|
+
|
42
|
+
$ bundle exec quiq -p ./config/environment.rb -q critical,medium,low -l WARN
|
43
|
+
|
44
|
+
## Configuration
|
45
|
+
|
46
|
+
Here is an example of a configuration within a Rails application:
|
47
|
+
|
48
|
+
```ruby
|
49
|
+
Quiq.configure do |config|
|
50
|
+
config.redis = 'redis://localhost:6379'
|
51
|
+
config.logger = Rails.logger
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
### ActiveJob support
|
56
|
+
|
57
|
+
As there is no official support for Quiq in ActiveJob, you must monkey patch it to use it as you would do with any other background jobs system. You can find a complete example here: [testapp/config/initializers/quiq.rb](https://github.com/sailor/quiq/blob/master/testapp/config/initializers/quiq.rb)
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
module ActiveJob
|
61
|
+
module QueueAdapters
|
62
|
+
class QuiqAdapter
|
63
|
+
def enqueue(job)
|
64
|
+
Quiq::Client.push(job)
|
65
|
+
end
|
66
|
+
|
67
|
+
def enqueue_at(job, timestamp)
|
68
|
+
Quiq::Client.push(job, scheduled_at: timestamp)
|
69
|
+
end
|
70
|
+
|
71
|
+
class JobWrapper
|
72
|
+
class << self
|
73
|
+
def perform(job_data)
|
74
|
+
Base.execute job_data
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
```
|
82
|
+
|
83
|
+
## Jobs
|
84
|
+
|
85
|
+
As it is using the [Async](https://github.com/socketry/async) gem, we can use the many features provided by this library.
|
86
|
+
|
87
|
+
You can access the underlying `Async::Task` by using `Quiq.current_task`.
|
88
|
+
|
89
|
+
A very dumb example:
|
90
|
+
|
91
|
+
```ruby
|
92
|
+
class TestJob < ApplicationJob
|
93
|
+
def perform(data, wait)
|
94
|
+
puts "Receiving new job: #{data}"
|
95
|
+
Quiq.current_task.sleep wait # Non blocking call
|
96
|
+
puts "Time to wake up after #{wait} seconds"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
```
|
100
|
+
|
101
|
+
More interesting use case. If you combine `quiq` with the [async-http](https://github.com/socketry/async-http) gem, you'll be able to make asynchronous HTTP calls:
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
require 'uri'
|
105
|
+
require 'async/http/internet'
|
106
|
+
|
107
|
+
class HttpJob < ApplicationJob
|
108
|
+
def perform(url)
|
109
|
+
uri = URI(url)
|
110
|
+
|
111
|
+
client = Async::HTTP::Internet.new
|
112
|
+
response = client.get(url)
|
113
|
+
Quiq.logger.info response.read
|
114
|
+
end
|
115
|
+
end
|
116
|
+
```
|
117
|
+
|
118
|
+
### Scheduled jobs
|
119
|
+
|
120
|
+
Since Quiq supports ActiveJob interface you can use the same approach to schedule jobs for the future.
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
TestJob.set(wait: 5.seconds).perform_later(1, 2)
|
124
|
+
```
|
26
125
|
|
27
126
|
## Development
|
28
127
|
|
@@ -30,16 +129,34 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
|
30
129
|
|
31
130
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
32
131
|
|
33
|
-
##
|
132
|
+
## Benchmarks
|
34
133
|
|
35
|
-
|
134
|
+
To benchmark the system you can use the `quiqload` binary. To launch it, execute:
|
135
|
+
|
136
|
+
$ time bin/quiqload -n 10_000 -w 1
|
137
|
+
|
138
|
+
```
|
139
|
+
Usage: quiqload [options]
|
140
|
+
-n, --number JOBS Number of jobs to enqueue
|
141
|
+
-w, --wait DURATION Idle time within each job (in seconds)
|
142
|
+
-h, --help Show this message
|
143
|
+
```
|
36
144
|
|
37
|
-
##
|
145
|
+
## Todo
|
38
146
|
|
39
147
|
- [ ] Graceful shutdown
|
40
|
-
- [
|
41
|
-
- [
|
42
|
-
- [
|
148
|
+
- [x] Customizable logger
|
149
|
+
- [x] Dead-letter queue
|
150
|
+
- [x] Scheduler
|
151
|
+
- [ ] Specs
|
152
|
+
- [ ] Retry system
|
153
|
+
- [ ] Batches support
|
154
|
+
- [x] Load testing script
|
155
|
+
- [ ] Admin user interface
|
156
|
+
|
157
|
+
## Contributing
|
158
|
+
|
159
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/sailor/quiq.
|
43
160
|
|
44
161
|
## License
|
45
162
|
|
data/bin/quiq
CHANGED
@@ -4,19 +4,25 @@
|
|
4
4
|
require_relative '../lib/quiq'
|
5
5
|
require 'optparse'
|
6
6
|
|
7
|
-
options = {}
|
7
|
+
options = { path: Dir.pwd, queues: %w[default], log_level: Logger::DEBUG }
|
8
8
|
OptionParser.new do |opts|
|
9
9
|
opts.banner = 'Usage: quiq [options]'
|
10
10
|
|
11
|
-
opts.on('-p', '--path
|
12
|
-
options[:path] = path
|
11
|
+
opts.on('-p', '--path PATH', 'Location of the workers to load') do |path|
|
12
|
+
options[:path] = File.expand_path(path)
|
13
13
|
end
|
14
14
|
|
15
|
-
opts.on('-q', '--queues
|
16
|
-
|
15
|
+
opts.on('-q', '--queues NAMES', Array,
|
16
|
+
'Comma-separated list of queues to poll') do |queues|
|
17
|
+
options[:queues] = queues
|
17
18
|
end
|
18
19
|
|
19
|
-
opts.on
|
20
|
+
opts.on('-l', '--log-level LEVEL', %i[debug info warn error],
|
21
|
+
'The logging level') do |level|
|
22
|
+
options[:log_level] = level
|
23
|
+
end
|
24
|
+
|
25
|
+
opts.on '-v', '--version', 'Output version and exit' do
|
20
26
|
puts "Quiq #{Quiq::VERSION}"
|
21
27
|
exit
|
22
28
|
end
|
@@ -28,9 +34,9 @@ OptionParser.new do |opts|
|
|
28
34
|
end.parse!
|
29
35
|
|
30
36
|
begin
|
31
|
-
Quiq.
|
32
|
-
rescue
|
33
|
-
warn
|
34
|
-
warn
|
37
|
+
Quiq.boot(options)
|
38
|
+
rescue StandardError => e
|
39
|
+
warn e.message
|
40
|
+
warn e.backtrace.join("\n")
|
35
41
|
exit 1
|
36
42
|
end
|
data/bin/quiqload
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# Usage: time RUBYOPT="-W0" bin/quiqload
|
5
|
+
|
6
|
+
require_relative '../lib/quiq'
|
7
|
+
require_relative '../testapp/config/environment.rb'
|
8
|
+
require 'optparse'
|
9
|
+
|
10
|
+
options = { number: 10_000, wait: 1 }
|
11
|
+
OptionParser.new do |opts|
|
12
|
+
opts.banner = 'Usage: quiqload [options]'
|
13
|
+
|
14
|
+
opts.on('-n', '--number JOBS', Integer, 'Number of jobs to enqueue') do |number|
|
15
|
+
options[:number] = number.to_i
|
16
|
+
end
|
17
|
+
|
18
|
+
opts.on('-w', '--wait DURATION', Integer,
|
19
|
+
'Idle time within each job (in seconds)') do |wait|
|
20
|
+
options[:wait] = wait.to_i
|
21
|
+
end
|
22
|
+
|
23
|
+
opts.on_tail('-h', '--help', 'Show this message') do
|
24
|
+
puts opts
|
25
|
+
exit
|
26
|
+
end
|
27
|
+
end.parse!
|
28
|
+
|
29
|
+
Quiq.logger.info("Enqueuing #{options[:number]} jobs")
|
30
|
+
options[:number].times { |i| TestJob.perform_later(i, options[:wait]) }
|
31
|
+
|
32
|
+
Thread.new do
|
33
|
+
loop do
|
34
|
+
queue_size = Async do
|
35
|
+
queue = Quiq::Queue.processing_name('default')
|
36
|
+
Quiq.redis.llen queue
|
37
|
+
end.wait
|
38
|
+
|
39
|
+
if queue_size.zero?
|
40
|
+
Quiq.logger.info("Done processing #{options[:number]} jobs")
|
41
|
+
break
|
42
|
+
end
|
43
|
+
|
44
|
+
sleep 0.1
|
45
|
+
end
|
46
|
+
end.join
|
data/lib/quiq.rb
CHANGED
@@ -7,39 +7,43 @@ require_relative 'quiq/client'
|
|
7
7
|
require 'async/redis'
|
8
8
|
|
9
9
|
module Quiq
|
10
|
-
|
11
|
-
|
10
|
+
extend self
|
11
|
+
|
12
|
+
def configuration
|
13
|
+
Config.instance
|
12
14
|
end
|
13
15
|
|
14
|
-
def
|
15
|
-
self.configuration ||= Config.instance
|
16
|
+
def configure
|
16
17
|
yield(configuration) if block_given?
|
17
18
|
end
|
18
19
|
|
19
|
-
def
|
20
|
-
configuration.redis
|
20
|
+
def redis
|
21
|
+
configuration.redis.client
|
21
22
|
end
|
22
23
|
|
23
|
-
def
|
24
|
-
|
25
|
-
configuration.queues = options[:queues] || ['default']
|
24
|
+
def boot(options)
|
25
|
+
configuration.parse_options(**options)
|
26
26
|
|
27
|
-
#
|
28
|
-
path =
|
27
|
+
# Load the workers source code
|
28
|
+
path = configuration.path
|
29
29
|
if File.directory?(path)
|
30
30
|
Dir.glob(File.join(path, '*.rb')).each { |file| require file }
|
31
31
|
else
|
32
32
|
require path
|
33
33
|
end
|
34
34
|
|
35
|
-
Server.instance.run
|
35
|
+
Server.instance.run!
|
36
36
|
end
|
37
37
|
|
38
|
-
def
|
38
|
+
def queues
|
39
39
|
configuration.queues
|
40
40
|
end
|
41
41
|
|
42
|
-
def
|
42
|
+
def current_task
|
43
43
|
Async::Task.current
|
44
44
|
end
|
45
|
+
|
46
|
+
def logger
|
47
|
+
configuration.logger
|
48
|
+
end
|
45
49
|
end
|
data/lib/quiq/client.rb
CHANGED
@@ -5,15 +5,18 @@ require 'uri'
|
|
5
5
|
|
6
6
|
module Quiq
|
7
7
|
class Client
|
8
|
-
def push(job)
|
9
|
-
|
10
|
-
|
11
|
-
|
8
|
+
def push(job, scheduled_at)
|
9
|
+
serialized_job = JSON.dump(job.serialize)
|
10
|
+
|
11
|
+
if scheduled_at
|
12
|
+
Async { Scheduler.enqueue_at(serialized_job, scheduled_at) }
|
13
|
+
else
|
14
|
+
Async { Queue.push(job.queue_name, serialized_job) }
|
12
15
|
end
|
13
16
|
end
|
14
17
|
|
15
|
-
def self.push(job)
|
16
|
-
new.push(job)
|
18
|
+
def self.push(job, scheduled_at: nil)
|
19
|
+
new.push(job, scheduled_at)
|
17
20
|
end
|
18
21
|
end
|
19
22
|
end
|
data/lib/quiq/config.rb
CHANGED
@@ -1,29 +1,35 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'logger'
|
3
4
|
require 'singleton'
|
4
|
-
|
5
|
+
require_relative 'redis'
|
5
6
|
|
6
7
|
module Quiq
|
7
8
|
class Config
|
8
9
|
include Singleton
|
9
10
|
|
10
|
-
|
11
|
+
attr_reader :queues, :path
|
12
|
+
attr_writer :logger
|
13
|
+
|
14
|
+
def redis=(server)
|
15
|
+
@redis = Redis.new(server)
|
16
|
+
end
|
11
17
|
|
12
|
-
# Return a connection to the local
|
13
|
-
# Redis instance if not configured
|
14
18
|
def redis
|
15
|
-
@redis ||=
|
16
|
-
|
17
|
-
|
19
|
+
@redis ||= Redis.new
|
20
|
+
end
|
21
|
+
|
22
|
+
def logger
|
23
|
+
@logger ||= begin
|
24
|
+
level = @log_level || Logger::DEBUG
|
25
|
+
::Logger.new(STDOUT, level: level)
|
18
26
|
end
|
19
27
|
end
|
20
28
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
endpoint = Async::IO::Endpoint.tcp(uri.host, uri.port)
|
26
|
-
@redis = Async::Redis::Client.new(endpoint)
|
29
|
+
def parse_options(path:, queues:, log_level:)
|
30
|
+
@path = path
|
31
|
+
@queues = queues
|
32
|
+
@log_level = log_level
|
27
33
|
end
|
28
34
|
end
|
29
35
|
end
|
data/lib/quiq/job.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Quiq
|
6
|
+
class Job
|
7
|
+
def initialize(raw, queue)
|
8
|
+
@raw = raw
|
9
|
+
@queue = queue
|
10
|
+
end
|
11
|
+
|
12
|
+
def run
|
13
|
+
Async do
|
14
|
+
begin
|
15
|
+
# First parse the raw message from redis
|
16
|
+
payload = JSON.parse(@raw)
|
17
|
+
|
18
|
+
# Then load the definition of the job + its arguments
|
19
|
+
klass = Object.const_get(payload['job_class'])
|
20
|
+
args = payload['arguments']
|
21
|
+
|
22
|
+
# Then run the task
|
23
|
+
klass.new.perform(*args)
|
24
|
+
rescue JSON::ParserError => e
|
25
|
+
Quiq.logger.warn("Invalid format: #{e}")
|
26
|
+
send_to_dlq(@raw, e)
|
27
|
+
rescue StandardError => e
|
28
|
+
Quiq.logger.debug("Sending message to DLQ: #{e}")
|
29
|
+
send_to_dlq(payload, e)
|
30
|
+
ensure
|
31
|
+
# Remove the job from the processing list
|
32
|
+
Queue.delete(@queue.processing, @raw)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def send_to_dlq(payload, exception)
|
40
|
+
if payload.is_a?(Hash)
|
41
|
+
payload['error'] = exception.to_s
|
42
|
+
payload['backtrace'] = exception.backtrace
|
43
|
+
message = JSON.dump(payload)
|
44
|
+
else
|
45
|
+
message = @raw
|
46
|
+
end
|
47
|
+
|
48
|
+
Queue.send_to_dlq(message)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/lib/quiq/queue.rb
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Quiq
|
4
|
+
class Queue
|
5
|
+
PREFIX = 'queue'
|
6
|
+
PROCESSING_SUFFIX = 'processing'
|
7
|
+
DEAD_LETTER_QUEUE = 'dead'
|
8
|
+
|
9
|
+
attr_reader :name, :processing
|
10
|
+
|
11
|
+
def initialize(name)
|
12
|
+
@name = self.class.formatted_name(name)
|
13
|
+
@processing = self.class.processing_name(name)
|
14
|
+
end
|
15
|
+
|
16
|
+
def push(job)
|
17
|
+
pushed = Quiq.redis.lpush(@name, job)
|
18
|
+
return unless pushed <= 0
|
19
|
+
|
20
|
+
Quiq.logger.error("Could not push to the queue: #{@name}")
|
21
|
+
false
|
22
|
+
end
|
23
|
+
|
24
|
+
def pop
|
25
|
+
Quiq.redis.brpoplpush(@name, @processing, 0)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Insert elements that weren't fully processed at the tail of the queue to avoid loss
|
29
|
+
# @note that they should be enqueued at the head of the queue, but Redis lacks a LPOPRPUSH command
|
30
|
+
def purge_processing!
|
31
|
+
Async do
|
32
|
+
Quiq.redis.pipeline do |pipe|
|
33
|
+
loop do
|
34
|
+
job = pipe.sync.call('RPOPLPUSH', @processing, @name)
|
35
|
+
Quiq.logger.warn("Requeuing job #{job} in #{@name}") unless job.nil?
|
36
|
+
break if job.nil?
|
37
|
+
end
|
38
|
+
pipe.close
|
39
|
+
end
|
40
|
+
end.wait
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.push(queue, job)
|
44
|
+
@queue = new(queue)
|
45
|
+
@queue.push(job)
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.delete(queue, job)
|
49
|
+
Quiq.redis.lrem(queue, 0, job)
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.formatted_name(name)
|
53
|
+
"#{PREFIX}:#{name}"
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.processing_name(name)
|
57
|
+
"#{PREFIX}:#{name}:#{PROCESSING_SUFFIX}"
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.send_to_dlq(job)
|
61
|
+
@dlq ||= Queue.new(DEAD_LETTER_QUEUE)
|
62
|
+
@dlq.push(job)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
data/lib/quiq/redis.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'uri'
|
4
|
+
|
5
|
+
module Quiq
|
6
|
+
class Redis
|
7
|
+
DEFAULT_REDIS_URL = 'redis://localhost:6379'
|
8
|
+
|
9
|
+
attr_reader :client
|
10
|
+
|
11
|
+
def initialize(server = DEFAULT_REDIS_URL)
|
12
|
+
uri = URI(server)
|
13
|
+
endpoint = Async::IO::Endpoint.tcp(uri.host, uri.port)
|
14
|
+
@client = Async::Redis::Client.new(endpoint)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'singleton'
|
4
|
+
|
5
|
+
module Quiq
|
6
|
+
class Scheduler
|
7
|
+
include Singleton
|
8
|
+
|
9
|
+
SCHEDULER_KEY = 'quiq:schedule'
|
10
|
+
|
11
|
+
def start
|
12
|
+
# Set the process name
|
13
|
+
Process.setproctitle('quiq scheduler')
|
14
|
+
|
15
|
+
Async do
|
16
|
+
loop do
|
17
|
+
sleep 0.2
|
18
|
+
|
19
|
+
# TODO: use ZRANGEBYSCORE instead to batch enqueuing
|
20
|
+
job, scheduled_at = Quiq.redis.zrange(
|
21
|
+
SCHEDULER_KEY, 0, 0, with_scores: true
|
22
|
+
)
|
23
|
+
|
24
|
+
enqueue(job) if job && scheduled_at.to_f <= Time.now.to_f
|
25
|
+
end
|
26
|
+
ensure
|
27
|
+
Quiq.redis.close
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.enqueue_at(job, scheduled_at)
|
32
|
+
Quiq.redis.zadd(SCHEDULER_KEY, scheduled_at, job)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
# Push the job in its queue and remove from scheduler_queue
|
38
|
+
def enqueue(job)
|
39
|
+
begin
|
40
|
+
payload = JSON.parse(job)
|
41
|
+
rescue JSON::ParserError => e
|
42
|
+
Quiq.logger.warn("Invalid format: #{e}")
|
43
|
+
Queue.send_to_dlq(job)
|
44
|
+
end
|
45
|
+
|
46
|
+
# TODO: wrap those 2 calls in a transaction
|
47
|
+
Queue.push(payload['queue_name'], job)
|
48
|
+
Quiq.redis.zrem(SCHEDULER_KEY, job)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/lib/quiq/server.rb
CHANGED
@@ -1,31 +1,28 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'singleton'
|
4
|
-
require 'async/container'
|
5
4
|
require 'async/redis'
|
6
5
|
require_relative 'worker'
|
6
|
+
require_relative 'scheduler'
|
7
7
|
|
8
8
|
module Quiq
|
9
|
-
class Server
|
9
|
+
class Server
|
10
10
|
include Singleton
|
11
11
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
container.async do
|
17
|
-
loop do
|
18
|
-
job = fetch_one
|
19
|
-
Worker.new(job).run
|
20
|
-
end
|
21
|
-
ensure
|
22
|
-
Quiq.redis.close
|
12
|
+
def run!
|
13
|
+
# Launch one worker per queue
|
14
|
+
Quiq.queues.each do |queue|
|
15
|
+
fork { Worker.new(queue).start }
|
23
16
|
end
|
24
|
-
end
|
25
17
|
|
26
|
-
|
27
|
-
|
28
|
-
|
18
|
+
# Launch scheduler for jobs to be performed at certain time
|
19
|
+
fork { Scheduler.instance.start }
|
20
|
+
|
21
|
+
# Set the process name
|
22
|
+
Process.setproctitle("quiq master #{Quiq.configuration.path}")
|
23
|
+
|
24
|
+
# TODO: handle graceful shutdowns
|
25
|
+
Process.waitall
|
29
26
|
end
|
30
27
|
end
|
31
28
|
end
|
data/lib/quiq/version.rb
CHANGED
data/lib/quiq/worker.rb
CHANGED
@@ -1,21 +1,27 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require_relative 'job'
|
4
|
+
require_relative 'queue'
|
4
5
|
|
5
6
|
module Quiq
|
6
7
|
class Worker
|
7
|
-
def initialize(
|
8
|
-
|
9
|
-
@job = JSON.parse(job) rescue nil
|
8
|
+
def initialize(queue)
|
9
|
+
@queue = Queue.new(queue)
|
10
10
|
end
|
11
11
|
|
12
|
-
def
|
13
|
-
|
12
|
+
def start
|
13
|
+
# Set the process name
|
14
|
+
Process.setproctitle("quiq worker #{@queue.name}")
|
14
15
|
|
16
|
+
# Reschedule jobs that get terminated before completion
|
17
|
+
# Beware that the jobs must be idempotent!
|
18
|
+
@queue.purge_processing!
|
19
|
+
|
20
|
+
# Then start processing enqueued jobs
|
15
21
|
Async do
|
16
|
-
|
17
|
-
|
18
|
-
|
22
|
+
loop { Job.new(@queue.pop, @queue).run }
|
23
|
+
ensure
|
24
|
+
Quiq.redis.close
|
19
25
|
end
|
20
26
|
end
|
21
27
|
end
|
data/quiq.gemspec
CHANGED
@@ -7,8 +7,11 @@ Gem::Specification.new do |spec|
|
|
7
7
|
spec.version = Quiq::VERSION
|
8
8
|
spec.authors = ['Salim Semaoune']
|
9
9
|
|
10
|
-
spec.summary = '
|
11
|
-
spec.description =
|
10
|
+
spec.summary = 'Distributed task queue written in Ruby, backed by Redis and using event loops to handle concurrency.'
|
11
|
+
spec.description = <<-EOS
|
12
|
+
Quiq is a distributed task queue backed by Redis to process jobs in background.
|
13
|
+
It relies on asynchronous IOs to process multiple jobs simultaneously. The event loop is provided by the Async library and many other gems of the Socketry family.
|
14
|
+
EOS
|
12
15
|
spec.homepage = 'https://github.com/sailor/quiq'
|
13
16
|
spec.license = 'MIT'
|
14
17
|
spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
|
@@ -22,7 +25,6 @@ Gem::Specification.new do |spec|
|
|
22
25
|
spec.executables = ['quiq']
|
23
26
|
spec.require_paths = ['lib']
|
24
27
|
|
25
|
-
spec.add_dependency 'async-
|
26
|
-
spec.add_dependency 'async-redis', '~> 0.4'
|
28
|
+
spec.add_dependency 'async-redis', '~> 0.4.3'
|
27
29
|
spec.add_development_dependency 'rspec', '~> 3.2'
|
28
30
|
end
|
data/testapp/Gemfile
CHANGED
data/testapp/Gemfile.lock
CHANGED
@@ -2,8 +2,7 @@ PATH
|
|
2
2
|
remote: ..
|
3
3
|
specs:
|
4
4
|
quiq (0.1.0)
|
5
|
-
async-
|
6
|
-
async-redis (~> 0.4)
|
5
|
+
async-redis (~> 0.4.3)
|
7
6
|
|
8
7
|
GEM
|
9
8
|
remote: https://rubygems.org/
|
@@ -67,21 +66,27 @@ GEM
|
|
67
66
|
console (~> 1.0)
|
68
67
|
nio4r (~> 2.3)
|
69
68
|
timers (~> 4.1)
|
70
|
-
async-
|
71
|
-
async (~> 1.
|
72
|
-
async-io (~> 1.
|
73
|
-
|
69
|
+
async-http (0.50.2)
|
70
|
+
async (~> 1.23)
|
71
|
+
async-io (~> 1.27.0)
|
72
|
+
async-pool (~> 0.2)
|
73
|
+
protocol-http (~> 0.13.0)
|
74
|
+
protocol-http1 (~> 0.10.0)
|
75
|
+
protocol-http2 (~> 0.10.0)
|
74
76
|
async-io (1.27.3)
|
75
77
|
async (~> 1.14)
|
76
|
-
async-
|
78
|
+
async-pool (0.2.0)
|
79
|
+
async (~> 1.8)
|
80
|
+
async-redis (0.4.3)
|
77
81
|
async (~> 1.8)
|
78
82
|
async-io (~> 1.10)
|
79
|
-
|
83
|
+
async-pool (~> 0.2)
|
84
|
+
protocol-redis (~> 0.4.0)
|
80
85
|
bootsnap (1.4.5)
|
81
86
|
msgpack (~> 1.0)
|
82
87
|
builder (3.2.4)
|
83
88
|
concurrent-ruby (1.1.5)
|
84
|
-
console (1.8.
|
89
|
+
console (1.8.1)
|
85
90
|
crass (1.0.6)
|
86
91
|
erubi (1.9.0)
|
87
92
|
ffi (1.12.1)
|
@@ -109,11 +114,14 @@ GEM
|
|
109
114
|
nio4r (2.5.2)
|
110
115
|
nokogiri (1.10.7)
|
111
116
|
mini_portile2 (~> 2.4.0)
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
protocol-
|
117
|
+
protocol-hpack (1.4.2)
|
118
|
+
protocol-http (0.13.1)
|
119
|
+
protocol-http1 (0.10.1)
|
120
|
+
protocol-http (~> 0.13)
|
121
|
+
protocol-http2 (0.10.4)
|
122
|
+
protocol-hpack (~> 1.4)
|
123
|
+
protocol-http (~> 0.2)
|
124
|
+
protocol-redis (0.4.2)
|
117
125
|
rack (2.1.1)
|
118
126
|
rack-test (1.1.0)
|
119
127
|
rack (>= 1.0, < 3)
|
@@ -169,6 +177,7 @@ PLATFORMS
|
|
169
177
|
ruby
|
170
178
|
|
171
179
|
DEPENDENCIES
|
180
|
+
async-http
|
172
181
|
bootsnap (>= 1.4.2)
|
173
182
|
listen (>= 3.0.5, < 3.2)
|
174
183
|
quiq!
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'uri'
|
4
|
+
require 'async/http/internet'
|
5
|
+
|
6
|
+
class HttpJob < ApplicationJob
|
7
|
+
def perform(url)
|
8
|
+
uri = URI(url)
|
9
|
+
|
10
|
+
client = Async::HTTP::Internet.new
|
11
|
+
response = client.get(url)
|
12
|
+
Quiq.logger.info response.read
|
13
|
+
end
|
14
|
+
end
|
@@ -2,8 +2,8 @@
|
|
2
2
|
|
3
3
|
class TestJob < ApplicationJob
|
4
4
|
def perform(data, wait)
|
5
|
-
puts "Receiving new
|
5
|
+
puts "[Worker ##{$$}] Receiving new job: #{data}"
|
6
6
|
Quiq.current_task.sleep wait
|
7
|
-
puts "Time to wake up after #{wait} seconds"
|
7
|
+
puts "[Worker ##{$$}] Time to wake up after #{wait} seconds"
|
8
8
|
end
|
9
9
|
end
|
@@ -9,7 +9,7 @@ module ActiveJob
|
|
9
9
|
end
|
10
10
|
|
11
11
|
def enqueue_at(job, timestamp)
|
12
|
-
|
12
|
+
Quiq::Client.push(job, scheduled_at: timestamp)
|
13
13
|
end
|
14
14
|
|
15
15
|
class JobWrapper
|
@@ -25,4 +25,5 @@ end
|
|
25
25
|
|
26
26
|
Quiq.configure do |config|
|
27
27
|
config.redis = 'redis://localhost:6379'
|
28
|
+
# config.logger = Rails.logger
|
28
29
|
end
|
metadata
CHANGED
@@ -1,43 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: quiq
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Salim Semaoune
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-02-
|
11
|
+
date: 2020-02-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
-
- !ruby/object:Gem::Dependency
|
14
|
-
name: async-container
|
15
|
-
requirement: !ruby/object:Gem::Requirement
|
16
|
-
requirements:
|
17
|
-
- - "~>"
|
18
|
-
- !ruby/object:Gem::Version
|
19
|
-
version: '0.16'
|
20
|
-
type: :runtime
|
21
|
-
prerelease: false
|
22
|
-
version_requirements: !ruby/object:Gem::Requirement
|
23
|
-
requirements:
|
24
|
-
- - "~>"
|
25
|
-
- !ruby/object:Gem::Version
|
26
|
-
version: '0.16'
|
27
13
|
- !ruby/object:Gem::Dependency
|
28
14
|
name: async-redis
|
29
15
|
requirement: !ruby/object:Gem::Requirement
|
30
16
|
requirements:
|
31
17
|
- - "~>"
|
32
18
|
- !ruby/object:Gem::Version
|
33
|
-
version:
|
19
|
+
version: 0.4.3
|
34
20
|
type: :runtime
|
35
21
|
prerelease: false
|
36
22
|
version_requirements: !ruby/object:Gem::Requirement
|
37
23
|
requirements:
|
38
24
|
- - "~>"
|
39
25
|
- !ruby/object:Gem::Version
|
40
|
-
version:
|
26
|
+
version: 0.4.3
|
41
27
|
- !ruby/object:Gem::Dependency
|
42
28
|
name: rspec
|
43
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -52,13 +38,16 @@ dependencies:
|
|
52
38
|
- - "~>"
|
53
39
|
- !ruby/object:Gem::Version
|
54
40
|
version: '3.2'
|
55
|
-
description:
|
41
|
+
description: |2
|
42
|
+
Quiq is a distributed task queue backed by Redis to process jobs in background.
|
43
|
+
It relies on asynchronous IOs to process multiple jobs simultaneously. The event loop is provided by the Async library and many other gems of the Socketry family.
|
56
44
|
email:
|
57
45
|
executables:
|
58
46
|
- quiq
|
59
47
|
extensions: []
|
60
48
|
extra_rdoc_files: []
|
61
49
|
files:
|
50
|
+
- ".github/workflows/ruby.yml"
|
62
51
|
- ".gitignore"
|
63
52
|
- ".rspec"
|
64
53
|
- ".rubocop.yml"
|
@@ -70,10 +59,15 @@ files:
|
|
70
59
|
- Rakefile
|
71
60
|
- bin/console
|
72
61
|
- bin/quiq
|
62
|
+
- bin/quiqload
|
73
63
|
- bin/setup
|
74
64
|
- lib/quiq.rb
|
75
65
|
- lib/quiq/client.rb
|
76
66
|
- lib/quiq/config.rb
|
67
|
+
- lib/quiq/job.rb
|
68
|
+
- lib/quiq/queue.rb
|
69
|
+
- lib/quiq/redis.rb
|
70
|
+
- lib/quiq/scheduler.rb
|
77
71
|
- lib/quiq/server.rb
|
78
72
|
- lib/quiq/version.rb
|
79
73
|
- lib/quiq/worker.rb
|
@@ -86,6 +80,7 @@ files:
|
|
86
80
|
- testapp/app/controllers/application_controller.rb
|
87
81
|
- testapp/app/controllers/concerns/.keep
|
88
82
|
- testapp/app/jobs/application_job.rb
|
83
|
+
- testapp/app/jobs/http_job.rb
|
89
84
|
- testapp/app/jobs/test_job.rb
|
90
85
|
- testapp/app/models/concerns/.keep
|
91
86
|
- testapp/bin/bundle
|
@@ -140,5 +135,6 @@ requirements: []
|
|
140
135
|
rubygems_version: 3.1.2
|
141
136
|
signing_key:
|
142
137
|
specification_version: 4
|
143
|
-
summary:
|
138
|
+
summary: Distributed task queue written in Ruby, backed by Redis and using event loops
|
139
|
+
to handle concurrency.
|
144
140
|
test_files: []
|