quiq 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 +7 -0
- data/.github/workflows/ruby.yml +20 -0
- data/.gitignore +15 -0
- data/.rspec +3 -0
- data/.rubocop.yml +2 -0
- data/.travis.yml +6 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +174 -0
- data/LICENSE.txt +21 -0
- data/README.md +163 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/quiq +42 -0
- data/bin/quiqload +46 -0
- data/bin/setup +8 -0
- data/lib/quiq.rb +49 -0
- data/lib/quiq/client.rb +22 -0
- data/lib/quiq/config.rb +35 -0
- 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 +28 -0
- data/lib/quiq/version.rb +5 -0
- data/lib/quiq/worker.rb +28 -0
- data/quiq.gemspec +30 -0
- data/testapp/Gemfile +15 -0
- data/testapp/Gemfile.lock +190 -0
- data/testapp/README.md +24 -0
- data/testapp/Rakefile +6 -0
- data/testapp/app/controllers/application_controller.rb +2 -0
- data/testapp/app/controllers/concerns/.keep +0 -0
- data/testapp/app/jobs/application_job.rb +7 -0
- data/testapp/app/jobs/http_job.rb +14 -0
- data/testapp/app/jobs/test_job.rb +9 -0
- data/testapp/app/models/concerns/.keep +0 -0
- data/testapp/bin/bundle +114 -0
- data/testapp/bin/rails +4 -0
- data/testapp/bin/rake +4 -0
- data/testapp/bin/setup +25 -0
- data/testapp/config.ru +5 -0
- data/testapp/config/application.rb +40 -0
- data/testapp/config/boot.rb +4 -0
- data/testapp/config/credentials.yml.enc +1 -0
- data/testapp/config/environment.rb +5 -0
- data/testapp/config/environments/development.rb +38 -0
- data/testapp/config/environments/production.rb +88 -0
- data/testapp/config/environments/test.rb +38 -0
- data/testapp/config/initializers/application_controller_renderer.rb +8 -0
- data/testapp/config/initializers/backtrace_silencers.rb +7 -0
- data/testapp/config/initializers/cors.rb +16 -0
- data/testapp/config/initializers/filter_parameter_logging.rb +4 -0
- data/testapp/config/initializers/inflections.rb +16 -0
- data/testapp/config/initializers/mime_types.rb +4 -0
- data/testapp/config/initializers/quiq.rb +29 -0
- data/testapp/config/initializers/wrap_parameters.rb +9 -0
- data/testapp/config/locales/en.yml +33 -0
- data/testapp/config/master.key +1 -0
- data/testapp/config/routes.rb +3 -0
- data/testapp/lib/tasks/.keep +0 -0
- data/testapp/public/robots.txt +1 -0
- data/testapp/tmp/.keep +0 -0
- data/testapp/tmp/development_secret.txt +1 -0
- data/testapp/tmp/pids/.keep +0 -0
- data/testapp/vendor/.keep +0 -0
- metadata +140 -0
data/bin/quiq
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative '../lib/quiq'
|
5
|
+
require 'optparse'
|
6
|
+
|
7
|
+
options = { path: Dir.pwd, queues: %w[default], log_level: Logger::DEBUG }
|
8
|
+
OptionParser.new do |opts|
|
9
|
+
opts.banner = 'Usage: quiq [options]'
|
10
|
+
|
11
|
+
opts.on('-p', '--path PATH', 'Location of the workers to load') do |path|
|
12
|
+
options[:path] = File.expand_path(path)
|
13
|
+
end
|
14
|
+
|
15
|
+
opts.on('-q', '--queues NAMES', Array,
|
16
|
+
'Comma-separated list of queues to poll') do |queues|
|
17
|
+
options[:queues] = queues
|
18
|
+
end
|
19
|
+
|
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
|
26
|
+
puts "Quiq #{Quiq::VERSION}"
|
27
|
+
exit
|
28
|
+
end
|
29
|
+
|
30
|
+
opts.on_tail('-h', '--help', 'Show this message') do
|
31
|
+
puts opts
|
32
|
+
exit
|
33
|
+
end
|
34
|
+
end.parse!
|
35
|
+
|
36
|
+
begin
|
37
|
+
Quiq.boot(options)
|
38
|
+
rescue StandardError => e
|
39
|
+
warn e.message
|
40
|
+
warn e.backtrace.join("\n")
|
41
|
+
exit 1
|
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/bin/setup
ADDED
data/lib/quiq.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'quiq/version'
|
4
|
+
require_relative 'quiq/config'
|
5
|
+
require_relative 'quiq/server'
|
6
|
+
require_relative 'quiq/client'
|
7
|
+
require 'async/redis'
|
8
|
+
|
9
|
+
module Quiq
|
10
|
+
extend self
|
11
|
+
|
12
|
+
def configuration
|
13
|
+
Config.instance
|
14
|
+
end
|
15
|
+
|
16
|
+
def configure
|
17
|
+
yield(configuration) if block_given?
|
18
|
+
end
|
19
|
+
|
20
|
+
def redis
|
21
|
+
configuration.redis.client
|
22
|
+
end
|
23
|
+
|
24
|
+
def boot(options)
|
25
|
+
configuration.parse_options(**options)
|
26
|
+
|
27
|
+
# Load the workers source code
|
28
|
+
path = configuration.path
|
29
|
+
if File.directory?(path)
|
30
|
+
Dir.glob(File.join(path, '*.rb')).each { |file| require file }
|
31
|
+
else
|
32
|
+
require path
|
33
|
+
end
|
34
|
+
|
35
|
+
Server.instance.run!
|
36
|
+
end
|
37
|
+
|
38
|
+
def queues
|
39
|
+
configuration.queues
|
40
|
+
end
|
41
|
+
|
42
|
+
def current_task
|
43
|
+
Async::Task.current
|
44
|
+
end
|
45
|
+
|
46
|
+
def logger
|
47
|
+
configuration.logger
|
48
|
+
end
|
49
|
+
end
|
data/lib/quiq/client.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'uri'
|
5
|
+
|
6
|
+
module Quiq
|
7
|
+
class Client
|
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) }
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.push(job, scheduled_at: nil)
|
19
|
+
new.push(job, scheduled_at)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
data/lib/quiq/config.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
require 'singleton'
|
5
|
+
require_relative 'redis'
|
6
|
+
|
7
|
+
module Quiq
|
8
|
+
class Config
|
9
|
+
include Singleton
|
10
|
+
|
11
|
+
attr_reader :queues, :path
|
12
|
+
attr_writer :logger
|
13
|
+
|
14
|
+
def redis=(server)
|
15
|
+
@redis = Redis.new(server)
|
16
|
+
end
|
17
|
+
|
18
|
+
def redis
|
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)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def parse_options(path:, queues:, log_level:)
|
30
|
+
@path = path
|
31
|
+
@queues = queues
|
32
|
+
@log_level = log_level
|
33
|
+
end
|
34
|
+
end
|
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
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'singleton'
|
4
|
+
require 'async/redis'
|
5
|
+
require_relative 'worker'
|
6
|
+
require_relative 'scheduler'
|
7
|
+
|
8
|
+
module Quiq
|
9
|
+
class Server
|
10
|
+
include Singleton
|
11
|
+
|
12
|
+
def run!
|
13
|
+
# Launch one worker per queue
|
14
|
+
Quiq.queues.each do |queue|
|
15
|
+
fork { Worker.new(queue).start }
|
16
|
+
end
|
17
|
+
|
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
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/quiq/version.rb
ADDED
data/lib/quiq/worker.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'job'
|
4
|
+
require_relative 'queue'
|
5
|
+
|
6
|
+
module Quiq
|
7
|
+
class Worker
|
8
|
+
def initialize(queue)
|
9
|
+
@queue = Queue.new(queue)
|
10
|
+
end
|
11
|
+
|
12
|
+
def start
|
13
|
+
# Set the process name
|
14
|
+
Process.setproctitle("quiq worker #{@queue.name}")
|
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
|
21
|
+
Async do
|
22
|
+
loop { Job.new(@queue.pop, @queue).run }
|
23
|
+
ensure
|
24
|
+
Quiq.redis.close
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|