cuniculus 0.0.1

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: a706ee37ab059f8897e2aba8cd1ab3a6dc15cdde2eabc78d98ddecd5113afb2a
4
+ data.tar.gz: 02bc1b2265e346485e99f583ed3f3c3a619335238d7f1552d809490dea3d5a0d
5
+ SHA512:
6
+ metadata.gz: bc3a8d8a26b299ffab344a642fc539e762fd03f3922bd165ff0611d44a0ecd62a39cd4a242fe529bc283e063764a15e399c2f4ad1dbbb4112c97863953f032ab
7
+ data.tar.gz: efc582168bc82aa2b5a66ecb42f26dc1d4fc36a73f95a9f46fb9028114a0781d0ad0f4cc10dc4fb41ff0f32f93550c8f745b80fb1e6a93c16a502e7f19e4a73e
data/CHANGELOG.md ADDED
File without changes
data/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2020, Marcelo
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # Cuniculus
2
+
3
+ Ruby job queue backed by RabbitMQ. The word _cuniculus_ comes from the scientific name of the European rabbit (Oryctolagus cuniculus).
4
+
5
+ ## Getting started
6
+
7
+ ```sh
8
+ gem install cuniculus
9
+ ```
10
+
11
+ _The following minimal example assumes RabbitMQ is running on `localhost:5672`; see the [configuration section](#configuration) for how to change this._
12
+
13
+ Create a worker class:
14
+ ```ruby
15
+ # -- my_worker.rb
16
+ require 'cuniculus/worker'
17
+
18
+ class MyWorker
19
+ include Cuniculus::Worker
20
+
21
+ def perform(arg1, arg2)
22
+ puts "Processing:"
23
+ puts "arg1: #{arg1.inspect}"
24
+ puts "arg2: #{arg2.inspect}"
25
+ end
26
+ end
27
+ ```
28
+
29
+ Add jobs to queue:
30
+ ```ruby
31
+ MyWorker.perform_async('x', [1, 2, 3])
32
+ ```
33
+
34
+ Start the job consumer:
35
+ ```sh
36
+ cuniculus -r my_worker.rb
37
+ ```
38
+
39
+ ### Example
40
+
41
+ There is also a more complete example in the Cuniculus repository itself. To run it, clone the repository, then
42
+ - start the Ruby and RabbitMQ containers using [Docker Compose](https://docs.docker.com/compose/):
43
+ ```
44
+ docker-compose up -d
45
+ ```
46
+ - from within the _cuniculus_ container, produce a job:
47
+ ```
48
+ ruby -Ilib examples/produce.rb
49
+ ```
50
+ - also from within the container, start the consumer:
51
+ ```
52
+ bin/cuniculus -I examples/ -r example/init_cuniculus.rb
53
+ ```
54
+
55
+ ## Configuration
56
+
57
+ Configuration is done through code, using `Cuniculus.configure`.
58
+
59
+ Example:
60
+ ```ruby
61
+ require "cuniculus"
62
+
63
+ # The following Hash is passed as is to Bunny, the library that integrates with RabbitMQ.
64
+ rabbitmq_conn = {
65
+ host: 'rabbitmq', # default is 127.0.0.1
66
+ port: 5672,
67
+ ssl: false,
68
+ vhost: '/',
69
+ user: 'guest',
70
+ pass: 'guest',
71
+ auth_mechanism: 'PLAIN',
72
+ }
73
+
74
+ Cuniculus.configure do |cfg|
75
+ cfg.rabbitmq_opts = rabbitmq_conn
76
+ cfg.pub_thr_pool_size = 5 # Only affects job producers
77
+ cfg.dead_queue_ttl = 1000 * 60 * 60 * 24 * 30 # keep failed jobs for 30 days
78
+ end
79
+ ```
80
+
81
+ ## Error handling
82
+
83
+ By default, exceptions raised when consuming a job are logged to STDOUT. This can be overriden with the `Cuniculus.error_handler` method:
84
+
85
+ ```ruby
86
+ Cuniculus.error_handler do |e|
87
+ puts "Oh nein! #{e}"
88
+ end
89
+ ```
90
+
91
+ The method expects a block that will receive an exception, and run in the scope of the Worker instance.
92
+
93
+ ## Retry mechanism
94
+
95
+ Cuniculus declares a `cun_default` queue, together with some `cun_default_{n}` queues used for job retries.
96
+ When a job raises an exception, it is placed into the `cun_default_1` queue for the first retry. It stays there for some pre-defined time, and then gets moved back into the `cun_default` queue for execution.
97
+
98
+ If it fails again, it gets moved to `cun_default_2`, where it stays for a longer period until it's moved back directly into the `cun_default` queue again.
99
+
100
+ This goes on until there are no more retry attempts, in which case the job gets moved into the `cun_dead` queue. It can be then only be moved back into the `cun_default` queue manually; otherwise it is discarded after some time, defined as the `dead_queue_ttl`, in milliseconds (by default, 180 days).
101
+
102
+ Note that if a job cannot even be parsed, it is moved straight to the dead queue, as there's no point in retrying.
103
+
104
+ ## How it works
105
+
106
+ Cuniculus code and conventions are very much inspired by another Ruby job queue library: [Sidekiq](https://github.com/mperham/sidekiq).
107
+
108
+ To communicate with RabbitMQ, Cuniculus uses [Bunny](https://github.com/ruby-amqp/bunny).
109
+
110
+ The first time an async job is produced, a thread pool is created, each thread with its own communication channel to RabbitMQ. These threads push jobs to RabbitMQ.
111
+
112
+ For consuming, each queue will have a corresponding thread pool (handled by Bunny) for concurrency.
113
+
114
+ ## License
115
+
116
+ Cuniculus is licensed under the "BSD 2-Clause License". See [LICENSE](./LICENSE) for details.
117
+
data/bin/cuniculus ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ $LOAD_PATH.unshift(File.expand_path(File.join(File.dirname(__FILE__), "..", "lib")))
6
+
7
+ require "cuniculus/cli"
8
+
9
+ cli = Cuniculus::CLI.instance
10
+ cli.parse
11
+ cli.run
data/lib/cuniculus.rb ADDED
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cuniculus/version"
4
+
5
+ raise "Cuniculus #{Cuniculus.version} does not support Ruby versions below 2.6." if RUBY_PLATFORM != "java" && Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.6")
6
+
7
+ require "cuniculus/logger"
8
+ require "cuniculus/config"
9
+ require "cuniculus/plugins"
10
+ require "cuniculus/rmq_pool"
11
+ require "cuniculus/supervisor"
12
+
13
+ # Base definition of the Cuniculus Module
14
+ module Cuniculus
15
+
16
+ # Configure Cuniculus.
17
+ #
18
+ # @yield [Cuniculus::Config]
19
+ #
20
+ # @example Change RabbitMQ connection details.
21
+ # Cuniculus.configure do |cfg|
22
+ # cfg.rabbitmq_opts = { host: 'rmq.mycompany.com', user: 'guest', pass: 'guest' }
23
+ # end
24
+ def self.configure
25
+ cfg = Cuniculus::Config.new
26
+ yield cfg
27
+ cfg.declare!
28
+ @config = cfg
29
+ Cuniculus::RMQPool.configure(cfg)
30
+ end
31
+
32
+ # Current config of Cuniculus
33
+ #
34
+ # Returns config for read-only purpose. Use {Cuniculus.configure Cuniculus.configure} to change the configured values.
35
+ #
36
+ # @return [Cuniculus::Config]
37
+ def self.config
38
+ @config ||= Cuniculus::Config.new
39
+ end
40
+
41
+ # Current Cuniculus logger
42
+ #
43
+ # @return [Cuniculus::Logger]
44
+ def self.logger
45
+ @logger ||= Cuniculus::Logger.new($stdout, level: Logger::INFO)
46
+ end
47
+
48
+ # Receives a block that is called when the job consumer encounters an error.
49
+ # The block receives the exception object and runs in the context of the consumer instance.
50
+ #
51
+ # Note that overriding the default error handler does not affect the retry mechanism. This error handler
52
+ # is designed to be used for logging.
53
+ #
54
+ # The default error handler is defined in {Cuniculus::Consumer#handle_error}.
55
+ #
56
+ # @example Send error info to an external service.
57
+ # Cuniculus.error_handler do |e|
58
+ # err = "#{e.class.name}: #{e.message}"
59
+ # bt = e.backtrace.join("\n") unless e.backtrace.nil?
60
+ # MyLogginService.error(err, bt)
61
+ # end
62
+ def self.error_handler(&block)
63
+ Cuniculus::Consumer.define_method(:handle_error, &block)
64
+ Cuniculus::Consumer.instance_eval { private :handle_error }
65
+ end
66
+
67
+ # Load a plugin. If plugin is a Module, it is loaded directly.
68
+ # If it is a symbol, then it needs to satisfy the following:
69
+ # - The call `require "cuniculus/plugins/#{plugin}"` should succeed
70
+ # - The required plugin must register itself by calling {Cuniculus::Plugins.register_plugin}
71
+ #
72
+ # The additional arguments and block are passed to the plugin's `configure` method, if it exists.
73
+ #
74
+ # @param plugin [Symbol, Module]
75
+ # @param args [Array<Object>] *args passed to the plugin's `configure` method
76
+ # @param [Block] block passed to the plugin's `configure` method
77
+ #
78
+ # @example Enable `:health_check` plugin
79
+ # Cuniculus.plugin(:health_check)
80
+ def self.plugin(plugin, *args, &block)
81
+ plugin = Cuniculus::Plugins.load_plugin(plugin) if plugin.is_a?(Symbol)
82
+ raise Cuniculus::Error, "Invalid plugin type: #{plugin.class.inspect}. It must be a module" unless plugin.is_a?(Module)
83
+
84
+ self::Supervisor.send(:include, plugin::SupervisorMethods) if defined?(plugin::SupervisorMethods)
85
+ self::Supervisor.send(:extend, plugin::SupervisorClassMethods) if defined?(plugin::SupervisorClassMethods)
86
+ plugin.configure(config.opts, *args, &block) if plugin.respond_to?(:configure)
87
+ end
88
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ $stdout.sync = true
4
+
5
+ require "optparse"
6
+ require "singleton"
7
+
8
+ require "cuniculus"
9
+ require "cuniculus/supervisor"
10
+
11
+ module Cuniculus
12
+ class CLI
13
+ include Singleton
14
+
15
+ attr_reader :options
16
+
17
+ def parse(args = ARGV)
18
+ @options = parse_options(args)
19
+
20
+ return unless options[:require]
21
+
22
+ raise ArgumentError, "Invalid '--require' argument: #{options[:require]}. File does not exist" unless File.exist?(options[:require])
23
+ raise ArgumentError, "Invalid '--require' argument: #{options[:require]}. Cannot be a directory" if File.directory?(options[:require])
24
+ require File.join(Dir.pwd, options[:require])
25
+ end
26
+
27
+ def run
28
+ pipe_reader, pipe_writer = IO.pipe
29
+ sigs = %w[INT TERM]
30
+
31
+ sigs.each do |sig|
32
+ trap sig do
33
+ pipe_writer.puts(sig)
34
+ end
35
+ rescue ArgumentError
36
+ puts "Signal #{sig} not supported"
37
+ end
38
+
39
+
40
+ launch(pipe_reader)
41
+ end
42
+
43
+ def launch(pipe_reader)
44
+ config = Cuniculus.config
45
+ supervisor = Cuniculus::Supervisor.new(config)
46
+
47
+ begin
48
+ Cuniculus.logger.info("Starting process")
49
+ supervisor.start
50
+
51
+ while (readable_io = IO.select([pipe_reader]))
52
+ signal = readable_io.first[0].gets.strip
53
+ handle_signal(signal)
54
+ end
55
+ rescue Interrupt
56
+ Cuniculus.logger.info("Interrupt received; shutting down")
57
+ supervisor.stop
58
+ Cuniculus.logger.info("Shutdown complete")
59
+ end
60
+
61
+ exit(0)
62
+ end
63
+
64
+ def handle_signal(sig)
65
+ case sig
66
+ when "INT", "TERM"
67
+ raise Interrupt
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def parse_options(argv)
74
+ opts = {}
75
+ @parser = option_parser(opts)
76
+ @parser.parse!(argv)
77
+ opts
78
+ end
79
+
80
+ def option_parser(opts)
81
+ OptionParser.new do |o|
82
+ o.on("-r", "--require [PATH]", "location of file required before starting consumer") do |arg|
83
+ opts[:require] = arg
84
+ end
85
+
86
+ o.on("-I", "--include [DIR]", "add directory to LOAD_PATH") do |arg|
87
+ $LOAD_PATH << arg
88
+ end
89
+
90
+ o.on "-V", "--version", "print version and exit" do |arg|
91
+ puts "Cuniculus #{Cuniculus.version}"
92
+ exit(0)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cuniculus/core"
4
+ require "cuniculus/queue_config"
5
+
6
+ module Cuniculus
7
+ class Config
8
+ ENFORCED_CONN_OPTS = {
9
+ threaded: false # No need for a reader thread, since this connection is only used for publishing
10
+ }.freeze
11
+
12
+ attr_accessor :dead_queue_ttl, :exchange_name, :pub_thr_pool_size, :rabbitmq_opts
13
+ attr_reader :queues, :opts
14
+
15
+ def initialize
16
+ @opts = {}
17
+ @queues = { "cun_default" => QueueConfig.new({ "name" => "cun_default" }) }
18
+ @rabbitmq_opts = {
19
+ host: "127.0.0.1",
20
+ port: 5672,
21
+ user: "guest",
22
+ pass: "guest",
23
+ vhost: "/"
24
+ }
25
+ @exchange_name = "cuniculus"
26
+ @dead_queue_ttl = 1000 * 60 * 60 * 24 * 180 # 180 days
27
+ end
28
+
29
+ def declare!
30
+ conn = ::Bunny.new(rabbitmq_opts.merge(ENFORCED_CONN_OPTS))
31
+ conn.start
32
+ ch = conn.create_channel
33
+ declare_exchanges!(ch)
34
+ declare_dead_queue!(ch)
35
+ @queues.each_value { |q| q.declare!(ch) }
36
+ end
37
+
38
+ def default_queue=(bool)
39
+ @queues.delete("cun_default") unless bool
40
+ end
41
+
42
+ private
43
+
44
+ def declare_exchanges!(ch)
45
+ ch.direct(Cuniculus::CUNICULUS_EXCHANGE, { durable: true })
46
+ ch.fanout(Cuniculus::CUNICULUS_DLX_EXCHANGE, { durable: true })
47
+ end
48
+
49
+ def declare_dead_queue!(ch)
50
+ ch.queue(
51
+ "cun_dead",
52
+ durable: true,
53
+ exclusive: false,
54
+ arguments: {
55
+ "x-message-ttl" => dead_queue_ttl
56
+ }
57
+ ).
58
+ bind(Cuniculus::CUNICULUS_DLX_EXCHANGE)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cuniculus/core"
4
+ require "cuniculus/logger"
5
+
6
+ module Cuniculus
7
+ class Consumer
8
+ POLL_TIME = 5
9
+ JOB_REQUIRED_KEYS = %w[class args].freeze
10
+
11
+ attr_reader :channel, :exchange, :job_queue, :queue_config
12
+
13
+ def initialize(queue_config, channel)
14
+ @channel = channel
15
+ @queue_config = queue_config
16
+ end
17
+
18
+ def start
19
+ @exchange = channel.direct(Cuniculus::CUNICULUS_EXCHANGE, { durable: true })
20
+ # channel.direct(Cuniculus::CUNICULUS_DLX_EXCHANGE, { durable: true })
21
+ @job_queue = queue_config.declare!(channel)
22
+ @_consumer = job_queue.subscribe(manual_ack: true, block: false) do |delivery_info, properties, payload|
23
+ run_job(delivery_info, properties, payload)
24
+ end
25
+ end
26
+
27
+ def stop
28
+ @_consumer&.cancel
29
+ channel.close unless channel.closed?
30
+ end
31
+
32
+ def run_job(delivery_info, _properties, payload)
33
+ item = parse_job(payload)
34
+ klass = Object.const_get(item["class"])
35
+ worker = klass.new
36
+ worker.perform(*item["args"])
37
+ channel.ack(delivery_info.delivery_tag, false)
38
+ rescue Cuniculus::BadlyFormattedPayload => ex
39
+ handle_error(ex)
40
+ # If parse failed, send message straight to DLX
41
+ channel.nack(delivery_info.delivery_tag, false, false)
42
+ rescue StandardError => ex
43
+ handle_error(Cuniculus.convert_exception_class(ex, Cuniculus::Error))
44
+ maybe_retry(delivery_info, item)
45
+ end
46
+
47
+ def parse_job(payload)
48
+ msg = Cuniculus.load_job(payload)
49
+ raise Cuniculus::BadlyFormattedPayload, "Consumed message with missing information: #{payload}\nIt should have keys [#{JOB_REQUIRED_KEYS.join(', ')}]" unless (JOB_REQUIRED_KEYS - msg.keys).empty?
50
+
51
+ msg
52
+ rescue Cuniculus::BadlyFormattedPayload
53
+ raise
54
+ rescue StandardError => ex
55
+ raise Cuniculus.convert_exception_class(ex, Cuniculus::BadlyFormattedPayload), "Badly formatted consumed message: #{payload}"
56
+ end
57
+
58
+ def maybe_retry(delivery_info, item)
59
+ retry_count = item["_cun_retries"].to_i
60
+ retry_queue_name = job_queue.retry_queue(retry_count)
61
+ unless retry_queue_name
62
+ channel.nack(delivery_info.delivery_tag, false, false)
63
+ return
64
+ end
65
+ payload = Cuniculus.dump_job(item.merge("_cun_retries" => retry_count + 1))
66
+ exchange.publish(
67
+ payload,
68
+ {
69
+ routing_key: retry_queue_name,
70
+ persistent: true
71
+ }
72
+ )
73
+ channel.ack(delivery_info.delivery_tag, false)
74
+ end
75
+
76
+ def handle_error(e)
77
+ Cuniculus.logger.error("#{e.class.name}: #{e.message}")
78
+ Cuniculus.logger.error(e.backtrace.join("\n")) unless e.backtrace.nil?
79
+ end
80
+
81
+ def constantize(str)
82
+ return Object.const_get(str) unless str.include?("::")
83
+
84
+ names = str.split("::")
85
+ names.shift if names.empty? || names.first.empty?
86
+
87
+ names.inject(Object) do |constant, name|
88
+ constant.const_get(name, false)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Cuniculus
6
+ CUNICULUS_EXCHANGE = "cuniculus"
7
+ CUNICULUS_DLX_EXCHANGE = "cuniculus_dlx" # Dead Letter Exchange
8
+
9
+ # Core Cuniculus methods
10
+ module CuniculusMethods
11
+ # Convert a RabbitMQ message into Ruby object for processing.
12
+ def load_job(rmq_msg)
13
+ ::JSON.parse(rmq_msg)
14
+ end
15
+
16
+ # Serializes a Ruby object for publishing to RabbitMQ.
17
+ def dump_job(job)
18
+ ::JSON.dump(job)
19
+ end
20
+
21
+ # Convert the input `exception` to the given class. The given class should be
22
+ # {Cuniculus::Error} or a subclass. Returns an instance of `klass` with
23
+ # the message and backtrace of `exception`.
24
+ #
25
+ # @param exception [Exception] The exception being wrapped
26
+ # @param [Cuniculus::Error] The subclass of `Cuniculus::Error`
27
+ #
28
+ # @return [Cuniculus::Error] An instance of the input `Cuniculus::Error`
29
+ def convert_exception_class(exception, klass)
30
+ return exception if exception.is_a?(klass)
31
+
32
+ e = klass.new("#{exception.class}: #{exception.message}")
33
+ e.wrapped_exception = exception
34
+ e.set_backtrace(exception.backtrace)
35
+ e
36
+ end
37
+ end
38
+
39
+ extend CuniculusMethods
40
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cuniculus
4
+ # Cuniculus-specific exceptions
5
+ #
6
+ # * `Cuniculus::Error`: Default exception raised by Cuniculus.
7
+ # All exceptions classes defined by Cuniculus descend from this class.
8
+ # * `Cuniculus::RMQConnectionError`: Raised when unable to connect to RabbitMQ.
9
+ # * `Cuniculus::RMQQueueConfigurationConflict`: Raised when the queue configuration
10
+ # given to Cuniculus conflicts with the current configuration of the same
11
+ # existing queue in RabbitMQ.
12
+ # * `Cuniculus::BadlyFormattedPayload`: Raised when Cuniculus consumer receives an
13
+ # improperly formatted job message.
14
+
15
+ class Error < ::StandardError
16
+ # If the Cuniculus exception wraps an underlying exception, the latter
17
+ # is held here.
18
+ attr_accessor :wrapped_exception
19
+
20
+ # Underlying exception `cause`
21
+ #
22
+ # @return [Exception#cause]
23
+ def cause
24
+ wrapped_exception || super
25
+ end
26
+ end
27
+
28
+ (
29
+ RMQConnectionError = Class.new(Error)
30
+ ).name
31
+
32
+ (
33
+ RMQQueueConfigurationConflict = Class.new(Error)
34
+ ).name
35
+
36
+ (
37
+ BadlyFormattedPayload = Class.new(Error)
38
+ ).name
39
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ module Cuniculus
5
+ class JobQueue
6
+ extend Forwardable
7
+
8
+ def_delegators :@base_queue, :subscribe
9
+
10
+ def initialize(base_queue, retry_queue_names)
11
+ @base_queue = base_queue
12
+ @retry_queue_names = retry_queue_names
13
+ end
14
+
15
+ def retry_queue(retry_count)
16
+ @retry_queue_names[retry_count]
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "time"
5
+
6
+ module Cuniculus
7
+ class Logger < ::Logger
8
+ def initialize(*args, **kwargs)
9
+ super
10
+ self.formatter = Formatters::Standard.new
11
+ end
12
+
13
+ module Formatters
14
+ class Base
15
+ def tid
16
+ Thread.current["cuniculus_tid"] ||= (Thread.current.object_id ^ ::Process.pid).to_s(36)
17
+ end
18
+ end
19
+
20
+ class Standard < Base
21
+ def call(severity, time, _program_name, message)
22
+ "#{time.utc.iso8601(3)} pid=#{::Process.pid} tid=#{tid} #{severity}: #{message}\n"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cuniculus
4
+ # Base plugin load and registration module.
5
+ module Plugins
6
+
7
+ # Store for registered plugins
8
+ @plugins = {}
9
+
10
+ # Method that loads a plugin file. It should not be called directly; instead
11
+ # use {Cuniculus.plugin} method to add and configure a plugin.
12
+ #
13
+ # @param name [Symbol] name of plugin, also matching its file name.
14
+ #
15
+ # @return [Module]
16
+ def self.load_plugin(name)
17
+ h = @plugins
18
+ unless plugin = h[name]
19
+ require "cuniculus/plugins/#{name}"
20
+ raise Cuniculus::Error, "Plugin was not registered with 'register_plugin'" unless plugin = h[name]
21
+ end
22
+ plugin
23
+ end
24
+
25
+ # Include plugin module into a Hash so it can be referenced by its name.
26
+ # This method should be called by the plugin itself, so that when it is required
27
+ # (by {Cuniculus::Plugins.load_plugin}), it can be found.
28
+ #
29
+ # @param name [Symbol] Name of the plugin, matching its file name.
30
+ # @param mod [Module] The plugin module.
31
+ #
32
+ # @example Register a plugin named `my_plugin`
33
+ # # file: my_plugin.rb
34
+ # module Cuniculus
35
+ # module Plugins
36
+ # module MyPlugin
37
+ # end
38
+ # register_plugin(:my_plugin, MyPlugin)
39
+ # end
40
+ # end
41
+ def self.register_plugin(name, mod)
42
+ @plugins[name] = mod
43
+ end
44
+ end
45
+ end
46
+
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "thread"
5
+
6
+ module Cuniculus
7
+ module Plugins
8
+ # The HealthCheck plugin starts a TCP server together with consumers for health probing.
9
+ # It currently does not perform any additional checks returns '200 OK' regardless of whether
10
+ # - the node can connect to RabbitMQ;
11
+ # - consumers are stuck.
12
+ #
13
+ # The healthcheck stays up as long as the supervisor module is also running.
14
+ #
15
+ # Enable the plugin with:
16
+ # ```ruby
17
+ # Cuniculus.plugin(:health_check)
18
+ # ```
19
+ #
20
+ # Options may be passed as well (use `String` keys):
21
+ # ```ruby
22
+ # opts = {
23
+ # "bind_to" => "127.0.0.1", # Default: "0.0.0.0"
24
+ # "port" => 8080 # Default: 3000
25
+ # }
26
+ # Cuniculus.plugin(:health_check, opts)
27
+ # ```
28
+ # This starts the server bound to 127.0.0.1 and port 8080.
29
+ #
30
+ # Note that the request path is not considered. The server responds with 200 to any path.
31
+ module HealthCheck
32
+ HEALTH_CHECK_RESPONSE = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK"
33
+
34
+ DEFAULTS = {
35
+ "bind_to" => "0.0.0.0",
36
+ "port" => 3000,
37
+ "server" => "webrick",
38
+ "block" => nil
39
+ }.freeze
40
+
41
+ OPTS_KEY = "__health_check_opts" # Key in the global plugin options where `:health_check` plugin options are stored.
42
+
43
+ # Configure `health_check` plugin
44
+ #
45
+ # @param plugins_cfg [Hash] Global plugin config hash, passed by Cuniculus. This should not be used by plugin users.
46
+ # @param opts [Hash] Plugin specific options.
47
+ # @option opts [String] "bind_to" IP address to bind to (default: "0.0.0.0")
48
+ # @option opts [Numeric] "port" Port number to bind to (default: 3000)
49
+ def self.configure(plugins_cfg, opts = {}, &block)
50
+ invalid_opts = opts.keys - DEFAULTS.keys
51
+ raise Cuniculus::Error, "Invalid option keys for :health_check plugin: #{invalid_opts}" unless invalid_opts.empty?
52
+
53
+ plugins_cfg[OPTS_KEY] = h = opts.slice("bind_to", "port", "server")
54
+ h["block"] = block if block
55
+ DEFAULTS.each do |k, v|
56
+ h[k] = v if v && !h.key?(k)
57
+ end
58
+ end
59
+
60
+ module SupervisorMethods
61
+ def start
62
+ hc_rd, @hc_wr = IO.pipe
63
+ start_health_check_server(hc_rd)
64
+ super
65
+ end
66
+
67
+ def stop
68
+ @hc_wr << "a"
69
+ super
70
+ end
71
+
72
+
73
+ private
74
+
75
+ def start_health_check_server(pipe_reader)
76
+ opts = config.opts[OPTS_KEY]
77
+ server = ::TCPServer.new(opts["bind_to"], opts["port"])
78
+
79
+ # If port was assigned by OS (when 'port' option was given as 0),
80
+ # now override input value with it.
81
+ opts["port"] = server.addr[1]
82
+ @hc_thread = Thread.new do
83
+ sock = nil
84
+ done = false
85
+ loop do
86
+ begin
87
+ break if done
88
+ sock = server.accept_nonblock
89
+ rescue IO::WaitReadable, Errno::EINTR
90
+ io = IO.select([server, pipe_reader])
91
+ done = true if io.first.include?(pipe_reader)
92
+ retry
93
+ end
94
+
95
+ sock.print HEALTH_CHECK_RESPONSE
96
+ sock.shutdown
97
+ end
98
+
99
+ sock&.close if sock && !sock.closed?
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ register_plugin(:health_check, HealthCheck)
106
+ end
107
+ end
108
+
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cuniculus/core"
4
+ require "cuniculus/exceptions"
5
+ require "cuniculus/job_queue"
6
+
7
+ module Cuniculus
8
+ class QueueConfig
9
+ OPTS = {}.freeze
10
+
11
+ DEFAULT_MAX_RETRY = 4
12
+
13
+ attr_reader :max_retry, :name, :thread_pool_size
14
+
15
+ def initialize(opts = OPTS)
16
+ @name = read_opt(opts, "name") || "cun_default"
17
+ @max_retry = read_opt(opts, "max_retry") || DEFAULT_MAX_RETRY
18
+ @thread_pool_size = read_opt(opts, "thread_pool_size")
19
+ end
20
+
21
+ def read_opt(opts, key)
22
+ opts[key.to_s] || opts[key.to_sym]
23
+ end
24
+
25
+ def declare!(channel)
26
+ queue_name = name
27
+ base_q = channel.queue(
28
+ queue_name,
29
+ durable: true,
30
+ exclusive: false,
31
+ arguments: { "x-dead-letter-exchange" => Cuniculus::CUNICULUS_DLX_EXCHANGE }
32
+ )
33
+ base_q.bind(Cuniculus::CUNICULUS_EXCHANGE, { routing_key: name })
34
+
35
+ retry_queue_names = (1..max_retry).map { |i| "#{name}_#{i}" }
36
+ max_retry.times do |i|
37
+ queue_name = retry_queue_names[i]
38
+
39
+ q = channel.queue(
40
+ queue_name,
41
+ durable: true,
42
+ exclusive: false,
43
+ arguments: {
44
+ "x-dead-letter-exchange" => Cuniculus::CUNICULUS_EXCHANGE,
45
+ "x-dead-letter-routing-key" => name,
46
+ "x-message-ttl" => ((i**4) + (15 * (i + 1))) * 1000
47
+ }
48
+ )
49
+ q.bind(Cuniculus::CUNICULUS_EXCHANGE, { routing_key: queue_name })
50
+ end
51
+
52
+ Cuniculus::JobQueue.new(base_q, retry_queue_names)
53
+ rescue Bunny::PreconditionFailed => e
54
+ raise Cuniculus.convert_exception_class(e, Cuniculus::RMQQueueConfigurationConflict), "Declaration failed for queue '#{queue_name}'"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bunny"
4
+ require "connection_pool"
5
+ require "cuniculus/core"
6
+ require "cuniculus/config"
7
+
8
+ module Cuniculus
9
+ class RMQPool
10
+ ENFORCED_CONN_OPTS = {
11
+ threaded: false # No need for a reader thread, since this connection is only used for publishing
12
+ }.freeze
13
+
14
+ class << self
15
+ def configure(config)
16
+ @config = config
17
+ end
18
+
19
+ def config
20
+ @config ||= Cuniculus::Config.new
21
+ end
22
+
23
+ def init!
24
+ @conn = ::Bunny.new(@config.rabbitmq_opts.merge(ENFORCED_CONN_OPTS))
25
+ @conn.start
26
+ @channel_pool = ConnectionPool.new(timeout: 1, size: @config.pub_thr_pool_size) do
27
+ ch = @conn.create_channel
28
+ ch.direct(Cuniculus::CUNICULUS_EXCHANGE, { durable: true })
29
+ ch.fanout(Cuniculus::CUNICULUS_DLX_EXCHANGE, { durable: true })
30
+ ch
31
+ end
32
+ end
33
+
34
+ def with_exchange(&block)
35
+ init! unless @channel_pool
36
+ @channel_pool.with do |ch|
37
+ block.call(ch.exchanges["cuniculus"])
38
+ ensure
39
+ ch.open if ch.closed?
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bunny"
4
+
5
+ require "cuniculus/core"
6
+ require "cuniculus/exceptions"
7
+ require "cuniculus/consumer"
8
+
9
+ module Cuniculus
10
+ module SupervisorMethods
11
+ attr_reader :config
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ conn = connect(config.rabbitmq_opts)
16
+ @consumers = create_consumers(conn, config.queues)
17
+ @consumer_lock = Mutex.new
18
+ @done = false
19
+ end
20
+
21
+ def start
22
+ @consumers.each(&:start)
23
+ end
24
+
25
+ def stop
26
+ @done = true
27
+ @consumers.each(&:stop)
28
+ end
29
+
30
+ def connect(conn_opts)
31
+ conn = ::Bunny.new(conn_opts)
32
+ conn.start
33
+ conn
34
+ rescue StandardError => e
35
+ raise Cuniculus.convert_exception_class(e, Cuniculus::RMQConnectionError)
36
+ end
37
+
38
+ def create_consumers(conn, queues)
39
+ consumers = []
40
+ consumer_pool_size = 5
41
+ queues.each do |_name, q_cfg|
42
+ ch = conn.create_channel(nil, consumer_pool_size)
43
+ consumers << Cuniculus::Consumer.new(q_cfg, ch)
44
+ end
45
+ consumers
46
+ end
47
+
48
+ def consumer_exception(consumer, _ex)
49
+ @consumer_lock.synchronize do
50
+ @consumers.delete(consumer)
51
+ unless @done
52
+ # Reuse channel
53
+ ch = consumer.channel
54
+ name = consumer.queue.name
55
+ c = Cuniculus::Consumer.new(self, name, ch)
56
+ @consumers << c
57
+ c.start
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ class Supervisor
64
+ include SupervisorMethods
65
+ end
66
+ end
67
+
@@ -0,0 +1,25 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Cuniculus
4
+ # The major version of Cuniculus. Only bumped for major changes.
5
+ MAJOR = 0
6
+
7
+ # The minor version of Cuniculus. Bumped for every non-patch level
8
+ # release.
9
+ MINOR = 0
10
+
11
+ # The tiny version of Cuniculus. Usually 0, only bumped for bugfix
12
+ # releases that fix regressions from previous versions.
13
+ TINY = 1
14
+
15
+ # The version of Cuniculus you are using, as a string (e.g. "2.11.0")
16
+ VERSION = [MAJOR, MINOR, TINY].join(".").freeze
17
+
18
+ # The version of Cuniculus you are using, as a number (2.11.0 -> 20110)
19
+ VERSION_NUMBER = MAJOR * 10_000 + MINOR * 10 + TINY
20
+
21
+ # The version of Cuniculus you are using, as a string (e.g. "2.11.0")
22
+ def self.version
23
+ VERSION
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cuniculus/core"
4
+ require "cuniculus/rmq_pool"
5
+
6
+ module Cuniculus
7
+ module Worker
8
+ def self.included(base)
9
+ base.extend(ClassMethods)
10
+ end
11
+
12
+ module ClassMethods
13
+ def perform_async(*args)
14
+ publish({ "class" => self, "args" => args })
15
+ end
16
+
17
+ def publish(item)
18
+ routing_key = "cun_default"
19
+ payload = normalize_item(item)
20
+ Cuniculus::RMQPool.with_exchange do |x|
21
+ x.publish(payload, { routing_key: routing_key, persistent: true })
22
+ end
23
+ end
24
+
25
+ def normalize_item(item)
26
+ Cuniculus.dump_job(item)
27
+ end
28
+ end
29
+ end
30
+ end
metadata ADDED
@@ -0,0 +1,186 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cuniculus
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Marcelo Pereira
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-02-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bunny
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 2.15.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 2.15.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: connection_pool
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 2.2.2
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 2.2.2
41
+ - !ruby/object:Gem::Dependency
42
+ name: pry
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: redcarpet
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: warning
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: yard
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description: Job queue processing backed by RabbitMQ
126
+ email:
127
+ executables:
128
+ - cuniculus
129
+ extensions: []
130
+ extra_rdoc_files:
131
+ - README.md
132
+ - CHANGELOG.md
133
+ files:
134
+ - CHANGELOG.md
135
+ - LICENSE
136
+ - README.md
137
+ - bin/cuniculus
138
+ - lib/cuniculus.rb
139
+ - lib/cuniculus/cli.rb
140
+ - lib/cuniculus/config.rb
141
+ - lib/cuniculus/consumer.rb
142
+ - lib/cuniculus/core.rb
143
+ - lib/cuniculus/exceptions.rb
144
+ - lib/cuniculus/job_queue.rb
145
+ - lib/cuniculus/logger.rb
146
+ - lib/cuniculus/plugins.rb
147
+ - lib/cuniculus/plugins/health_check.rb
148
+ - lib/cuniculus/queue_config.rb
149
+ - lib/cuniculus/rmq_pool.rb
150
+ - lib/cuniculus/supervisor.rb
151
+ - lib/cuniculus/version.rb
152
+ - lib/cuniculus/worker.rb
153
+ homepage: https://github.com/MarcPer/cuniculus
154
+ licenses:
155
+ - BSD-2-Clause
156
+ metadata:
157
+ source_code_uri: https://github.com/MarcPer/cuniculus
158
+ bug_tracker_uri: https://github.com/MarcPer/cuniculus/issues
159
+ changelog_uri: https://github.com/MarcPer/cuniculus/CHANGELOG.md
160
+ post_install_message:
161
+ rdoc_options:
162
+ - "--quiet"
163
+ - "--line-numbers"
164
+ - "--inline-source"
165
+ - "--title"
166
+ - 'Cuniculus: Background job processing with RabbitMQ'
167
+ - "--main"
168
+ - README.rdoc
169
+ require_paths:
170
+ - lib
171
+ required_ruby_version: !ruby/object:Gem::Requirement
172
+ requirements:
173
+ - - ">="
174
+ - !ruby/object:Gem::Version
175
+ version: '2.6'
176
+ required_rubygems_version: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ requirements: []
182
+ rubygems_version: 3.1.4
183
+ signing_key:
184
+ specification_version: 4
185
+ summary: Job queue processing backed by RabbitMQ
186
+ test_files: []