cuniculus 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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: []