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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '087c9f77d2ec6838e562f95595c17620f00089367bcacd513dded5b35f4348da'
4
- data.tar.gz: b8939bc5606d8677b78aa56b98a2c530a07f22d1c5a91d5433e5750041f56cf6
3
+ metadata.gz: f90fff6709ecb676daed0452eb08a59829a574b6a0d7b5819c9efe210a022453
4
+ data.tar.gz: 8391e3cc14ff6dc6ee97370f114bc314655714c983f04176cae17b1c251bbfb6
5
5
  SHA512:
6
- metadata.gz: 8502ee18454f56f2a7ff26d5c528b32a4cf5c417752a5d68e241d9662c7d7b94196067ffdcdd0591cbea6557c50263e4a4209f441e49aa971b8a2d68a9936aac
7
- data.tar.gz: dc3e2e731796bc8834b453b1ab957a1a2307b59cd2613c5175ca3a6a001bb43d5c59d85f6aa020e36a8df26dbe2d493c23a14bc86719a747f5d2022b1abbb57a
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
@@ -11,3 +11,5 @@
11
11
 
12
12
  # rspec failure tracking
13
13
  .rspec_status
14
+ .ruby-version
15
+ .ruby-gemset
@@ -1,9 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- quiq (0.1.0)
5
- async-container (~> 0.16)
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-redis (0.4.1)
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
- protocol-redis (~> 0.2.0)
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.0)
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
- process-group (1.2.1)
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.2
174
+ 2.1.4
data/README.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # Quiq
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/quiq`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Quiq is a distributed task queue backed by Redis to process jobs in background.
4
4
 
5
- TODO: Delete this and the text above, and describe your gem
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
- TODO: Write usage instructions here
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
- ## Contributing
132
+ ## Benchmarks
34
133
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/sailor/quiq.
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
- ## TODO
145
+ ## Todo
38
146
 
39
147
  - [ ] Graceful shutdown
40
- - [ ] Add a customizable logger
41
- - [ ] Implement a DLQ
42
- - [ ] Support job scheduled in the future
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 [PATH|DIR]", "Location of the workers to require') do |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 [NAMES]", "Comma-separated list of queues to fetch jobs from') do |queues|
16
- options[:queues] = queues.split(',')
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 '-v', '--version', 'Output version and exit' do |arg|
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.run(options)
32
- rescue
33
- warn $!.message
34
- warn $!.backtrace.join("\n")
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
@@ -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
@@ -7,39 +7,43 @@ require_relative 'quiq/client'
7
7
  require 'async/redis'
8
8
 
9
9
  module Quiq
10
- class << self
11
- attr_accessor :configuration
10
+ extend self
11
+
12
+ def configuration
13
+ Config.instance
12
14
  end
13
15
 
14
- def self.configure
15
- self.configuration ||= Config.instance
16
+ def configure
16
17
  yield(configuration) if block_given?
17
18
  end
18
19
 
19
- def self.redis
20
- configuration.redis
20
+ def redis
21
+ configuration.redis.client
21
22
  end
22
23
 
23
- def self.run(options)
24
- configure if configuration.nil?
25
- configuration.queues = options[:queues] || ['default']
24
+ def boot(options)
25
+ configuration.parse_options(**options)
26
26
 
27
- # Lookup for workers in the given path or the current directory
28
- path = options[:path] || Dir.pwd
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 self.queues
38
+ def queues
39
39
  configuration.queues
40
40
  end
41
41
 
42
- def self.current_task
42
+ def current_task
43
43
  Async::Task.current
44
44
  end
45
+
46
+ def logger
47
+ configuration.logger
48
+ end
45
49
  end
@@ -5,15 +5,18 @@ require 'uri'
5
5
 
6
6
  module Quiq
7
7
  class Client
8
- def push(job)
9
- serialized = JSON.dump(job.serialize)
10
- Async do
11
- Quiq.redis.lpush("queue:#{job.queue_name}", serialized)
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
@@ -1,29 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
3
4
  require 'singleton'
4
- require 'uri'
5
+ require_relative 'redis'
5
6
 
6
7
  module Quiq
7
8
  class Config
8
9
  include Singleton
9
10
 
10
- attr_accessor :queues
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 ||= begin
16
- endpoint = Async::Redis.local_endpoint
17
- Async::Redis::Client.new(endpoint)
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
- # Only accepts a redis connection uri for now
22
- # Note the client used is far from being production ready
23
- def redis=(server)
24
- uri = URI(server)
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
@@ -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
@@ -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
@@ -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
@@ -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 < Async::Container::Controller
9
+ class Server
10
10
  include Singleton
11
11
 
12
- # Called by Server.instance.run
13
- def setup(container)
14
- @queues = Quiq.queues.map { |q| "queue:#{q}" }
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
- def fetch_one
27
- # BRPOP returns a tuple made of the queue name then the args
28
- Quiq.redis.brpop(*@queues).last
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quiq
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -1,21 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
3
+ require_relative 'job'
4
+ require_relative 'queue'
4
5
 
5
6
  module Quiq
6
7
  class Worker
7
- def initialize(job)
8
- # TODO: handle deserialization errors
9
- @job = JSON.parse(job) rescue nil
8
+ def initialize(queue)
9
+ @queue = Queue.new(queue)
10
10
  end
11
11
 
12
- def run
13
- return if @job.nil?
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
- klass = Object.const_get(@job['job_class'])
17
- args = @job['arguments']
18
- klass.new.perform(*args)
22
+ loop { Job.new(@queue.pop, @queue).run }
23
+ ensure
24
+ Quiq.redis.close
19
25
  end
20
26
  end
21
27
  end
@@ -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 = 'amazing summary'
11
- spec.description = 'gorgeous 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-container', '~> 0.16'
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
@@ -7,6 +7,7 @@ ruby '2.7.0'
7
7
 
8
8
  gem 'bootsnap', '>= 1.4.2', require: false
9
9
  gem 'quiq', path: '..'
10
+ gem 'async-http'
10
11
  gem 'rails', '~> 6.0.2', '>= 6.0.2.1'
11
12
 
12
13
  group :development do
@@ -2,8 +2,7 @@ PATH
2
2
  remote: ..
3
3
  specs:
4
4
  quiq (0.1.0)
5
- async-container (~> 0.16)
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-container (0.16.1)
71
- async (~> 1.0)
72
- async-io (~> 1.26)
73
- process-group
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-redis (0.4.1)
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
- protocol-redis (~> 0.2.0)
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.0)
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
- process-group (1.2.1)
113
- process-terminal (~> 0.2.0)
114
- process-terminal (0.2.0)
115
- ffi
116
- protocol-redis (0.2.0)
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 data: #{data}"
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
- raise NotImplementedError, 'Support for schedule jobs is coming soon.'
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.1.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-02 00:00:00.000000000 Z
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: '0.4'
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: '0.4'
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: gorgeous 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: amazing summary
138
+ summary: Distributed task queue written in Ruby, backed by Redis and using event loops
139
+ to handle concurrency.
144
140
  test_files: []