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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ruby.yml +20 -0
  3. data/.gitignore +15 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +2 -0
  6. data/.travis.yml +6 -0
  7. data/Gemfile +11 -0
  8. data/Gemfile.lock +174 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +163 -0
  11. data/Rakefile +6 -0
  12. data/bin/console +14 -0
  13. data/bin/quiq +42 -0
  14. data/bin/quiqload +46 -0
  15. data/bin/setup +8 -0
  16. data/lib/quiq.rb +49 -0
  17. data/lib/quiq/client.rb +22 -0
  18. data/lib/quiq/config.rb +35 -0
  19. data/lib/quiq/job.rb +51 -0
  20. data/lib/quiq/queue.rb +65 -0
  21. data/lib/quiq/redis.rb +17 -0
  22. data/lib/quiq/scheduler.rb +51 -0
  23. data/lib/quiq/server.rb +28 -0
  24. data/lib/quiq/version.rb +5 -0
  25. data/lib/quiq/worker.rb +28 -0
  26. data/quiq.gemspec +30 -0
  27. data/testapp/Gemfile +15 -0
  28. data/testapp/Gemfile.lock +190 -0
  29. data/testapp/README.md +24 -0
  30. data/testapp/Rakefile +6 -0
  31. data/testapp/app/controllers/application_controller.rb +2 -0
  32. data/testapp/app/controllers/concerns/.keep +0 -0
  33. data/testapp/app/jobs/application_job.rb +7 -0
  34. data/testapp/app/jobs/http_job.rb +14 -0
  35. data/testapp/app/jobs/test_job.rb +9 -0
  36. data/testapp/app/models/concerns/.keep +0 -0
  37. data/testapp/bin/bundle +114 -0
  38. data/testapp/bin/rails +4 -0
  39. data/testapp/bin/rake +4 -0
  40. data/testapp/bin/setup +25 -0
  41. data/testapp/config.ru +5 -0
  42. data/testapp/config/application.rb +40 -0
  43. data/testapp/config/boot.rb +4 -0
  44. data/testapp/config/credentials.yml.enc +1 -0
  45. data/testapp/config/environment.rb +5 -0
  46. data/testapp/config/environments/development.rb +38 -0
  47. data/testapp/config/environments/production.rb +88 -0
  48. data/testapp/config/environments/test.rb +38 -0
  49. data/testapp/config/initializers/application_controller_renderer.rb +8 -0
  50. data/testapp/config/initializers/backtrace_silencers.rb +7 -0
  51. data/testapp/config/initializers/cors.rb +16 -0
  52. data/testapp/config/initializers/filter_parameter_logging.rb +4 -0
  53. data/testapp/config/initializers/inflections.rb +16 -0
  54. data/testapp/config/initializers/mime_types.rb +4 -0
  55. data/testapp/config/initializers/quiq.rb +29 -0
  56. data/testapp/config/initializers/wrap_parameters.rb +9 -0
  57. data/testapp/config/locales/en.yml +33 -0
  58. data/testapp/config/master.key +1 -0
  59. data/testapp/config/routes.rb +3 -0
  60. data/testapp/lib/tasks/.keep +0 -0
  61. data/testapp/public/robots.txt +1 -0
  62. data/testapp/tmp/.keep +0 -0
  63. data/testapp/tmp/development_secret.txt +1 -0
  64. data/testapp/tmp/pids/.keep +0 -0
  65. data/testapp/vendor/.keep +0 -0
  66. metadata +140 -0
@@ -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
@@ -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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quiq
4
+ VERSION = '0.2.0'
5
+ end
@@ -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