tobox 0.1.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.
data/lib/tobox/cli.rb ADDED
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "optparse"
5
+ require "uri"
6
+ require_relative "../tobox"
7
+
8
+ module Tobox
9
+ class CLI
10
+ def self.run(args = ARGV)
11
+ new(args).run
12
+ end
13
+
14
+ def initialize(args)
15
+ @options = parse(args)
16
+ end
17
+
18
+ def run
19
+ options = @options
20
+ logger = Logger.new($stderr)
21
+
22
+ config = Configuration.new do |c|
23
+ c.logger(logger)
24
+ c.instance_eval(File.read(options.fetch(:config_file)), options.fetch(:config_file), 1)
25
+ end
26
+
27
+ # boot
28
+ options.fetch(:require).each(&method(:require))
29
+
30
+ # signals
31
+ pipe_read, pipe_write = IO.pipe
32
+ %w[INT TERM].each do |sig|
33
+ old_handler = Signal.trap(sig) do
34
+ if old_handler.respond_to?(:call)
35
+ begin
36
+ old_handler.call
37
+ rescue Exception => e # rubocop:disable Lint/RescueException
38
+ puts ["Error in #{sig} handler", e].inspect
39
+ end
40
+ end
41
+ pipe_write.puts(sig)
42
+ end
43
+ rescue ArgumentError
44
+ puts "Signal #{sig} not supported"
45
+ end
46
+
47
+ app = Tobox::Application.new(config)
48
+
49
+ begin
50
+ app.start
51
+
52
+ logger.info "Running tobox-#{Tobox::VERSION} (#{RUBY_DESCRIPTION})"
53
+ logger.info "workers=#{config[:concurrency]}"
54
+ logger.info "Press Ctrl-C to stop"
55
+
56
+ while pipe_read.wait_readable
57
+ signal = pipe_read.gets.strip
58
+ handle_signal(signal)
59
+ end
60
+ rescue Interrupt
61
+ logger.info "Shutting down..."
62
+ app.stop
63
+ logger.info "Down!"
64
+ exit(0)
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def parse(args)
71
+ opts = {
72
+ require: []
73
+ }
74
+ parser = OptionParser.new do |o|
75
+ o.on "-C", "--config PATH", "path to tobox .rb config file" do |arg|
76
+ arg = File.join(arg, "tobox.rb") if File.directory?(arg)
77
+ raise ArgumentError, "no such file #{arg}" unless File.exist?(arg)
78
+
79
+ opts[:config_file] = arg
80
+ end
81
+
82
+ o.on "-r", "--require [PATH|DIR]", "Location of application with files to require" do |arg|
83
+ requires = (opts[:require] ||= [])
84
+ if File.directory?(arg)
85
+ requires.concat(Dir.glob(File.join("**", "*.rb")))
86
+ else
87
+ raise ArgumentError, "no such file #{arg}" unless File.exist?(arg)
88
+
89
+ requires << arg
90
+ end
91
+ end
92
+
93
+ o.on "-d", "--database-uri DATABASE_URI", String, "location of the database with the outbox table" do |arg|
94
+ opts[:database_uri] = URI(arg)
95
+ end
96
+
97
+ o.on "-t", "--table TABLENAME", "(optional) name of the outbox database table" do |arg|
98
+ opts[:table] = arg
99
+ end
100
+
101
+ o.on "-c", "--concurrency INT", Integer, "processor threads to use" do |arg|
102
+ raise ArgumentError, "must be positive" unless arg.positive?
103
+
104
+ opts[:concurrency] = arg
105
+ end
106
+
107
+ o.on "-g", "--tag TAG", "Process tag for procline" do |arg|
108
+ opts[:tag] = arg
109
+ end
110
+
111
+ o.on "-t", "--shutdown-timeout NUM", Integer, "Shutdown timeout (in seconds)" do |arg|
112
+ raise ArgumentError, "must be positive" unless arg.positive?
113
+
114
+ opts[:shutdown_timeout] = arg
115
+ end
116
+
117
+ o.on "--verbose", "Print more verbose output" do |arg|
118
+ opts[:verbose] = arg
119
+ end
120
+
121
+ o.on "-v", "--version", "Print version and exit" do |_arg|
122
+ puts "Tobox #{Tobox::VERSION}"
123
+ exit(0)
124
+ end
125
+ end
126
+
127
+ parser.banner = "tobox [options]"
128
+ parser.on_tail "-h", "--help", "Show help" do
129
+ $stdout.puts parser
130
+ exit(0)
131
+ end
132
+ parser.parse(args)
133
+ opts
134
+ end
135
+
136
+ def handle_signal(sig)
137
+ case sig
138
+ when "INT", "TERM"
139
+ raise Interrupt
140
+ else
141
+ warn "#{sig} is unsupported"
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "forwardable"
5
+
6
+ module Tobox
7
+ class Configuration
8
+ extend Forwardable
9
+
10
+ attr_reader :handlers, :lifecycle_events, :arguments_handler, :default_logger
11
+
12
+ def_delegator :@config, :[]
13
+
14
+ DEFAULT_CONFIGURATION = {
15
+ environment: ENV.fetch("APP_ENV", "development"),
16
+ logger: nil,
17
+ log_level: nil,
18
+ database_uri: nil,
19
+ table: :outbox,
20
+ max_attempts: 10,
21
+ exponential_retry_factor: 4,
22
+ wait_for_events_delay: 5,
23
+ shutdown_timeout: 10,
24
+ concurrency: 4, # TODO: CPU count
25
+ worker: :thread
26
+ }.freeze
27
+
28
+ def initialize(name = nil, &block)
29
+ @name = name
30
+ @config = DEFAULT_CONFIGURATION.dup
31
+
32
+ @lifecycle_events = {}
33
+ @handlers = {}
34
+ @message_to_arguments = nil
35
+ @plugins = []
36
+
37
+ if block
38
+ case block.arity
39
+ when 0
40
+ instance_exec(&block)
41
+ when 1
42
+ yield(self)
43
+ else
44
+ raise Error, "configuration does not support blocks with more than one variable"
45
+ end
46
+ end
47
+
48
+ env = @config[:environment]
49
+ @default_logger = @config[:logger] || Logger.new(STDERR) # rubocop:disable Style/GlobalStdStream
50
+ @default_logger.level = @config[:log_level] || (env == "production" ? Logger::INFO : Logger::DEBUG)
51
+
52
+ freeze
53
+ end
54
+
55
+ def on(event, &callback)
56
+ (@handlers[event.to_sym] ||= []) << callback
57
+ self
58
+ end
59
+
60
+ def on_before_event(&callback)
61
+ (@lifecycle_events[:before_event] ||= []) << callback
62
+ self
63
+ end
64
+
65
+ def on_after_event(&callback)
66
+ (@lifecycle_events[:after_event] ||= []) << callback
67
+ self
68
+ end
69
+
70
+ def on_error_event(&callback)
71
+ (@lifecycle_events[:error_event] ||= []) << callback
72
+ self
73
+ end
74
+
75
+ def message_to_arguments(&callback)
76
+ @arguments_handler = callback
77
+ self
78
+ end
79
+
80
+ def plugin(plugin, _options = nil, &block)
81
+ raise Error, "Cannot add a plugin to a frozen config" if frozen?
82
+
83
+ plugin = Plugins.load_plugin(plugin) if plugin.is_a?(Symbol)
84
+
85
+ return if @plugins.include?(plugin)
86
+
87
+ @plugins << plugin
88
+ plugin.load_dependencies(self, &block) if plugin.respond_to?(:load_dependencies)
89
+
90
+ extend(plugin::ConfigurationMethods) if defined?(plugin::ConfigurationMethods)
91
+
92
+ plugin.configure(self, &block) if plugin.respond_to?(:configure)
93
+ end
94
+
95
+ def freeze
96
+ @name.freeze
97
+ @config.each_value(&:freeze).freeze
98
+ @handlers.each_value(&:freeze).freeze
99
+ @lifecycle_events.each_value(&:freeze).freeze
100
+ @plugins.freeze
101
+ super
102
+ end
103
+
104
+ private
105
+
106
+ def method_missing(meth, *args, &block)
107
+ if DEFAULT_CONFIGURATION.key?(meth) && args.size == 1
108
+ @config[meth] = args.first
109
+ elsif /\Aon_(.*)\z/.match(meth) && args.size.zero?
110
+ on(Regexp.last_match(1).to_sym, &block)
111
+ else
112
+ super
113
+ end
114
+ end
115
+
116
+ def respond_to_missing?(meth, *args)
117
+ super(meth, *args) ||
118
+ DEFAULT_CONFIGURATION.key?(meth) ||
119
+ /\Aon_(.*)\z/.match(meth)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Tobox
6
+ class Fetcher
7
+ def initialize(configuration)
8
+ @configuration = configuration
9
+
10
+ @logger = @configuration.default_logger
11
+
12
+ database_uri = @configuration[:database_uri]
13
+ @db = database_uri ? Sequel.connect(database_uri.to_s) : Sequel::DATABASES.first
14
+ raise Error, "no database found" unless @db
15
+
16
+ @db.extension :date_arithmetic
17
+
18
+ @db.loggers << @logger unless @configuration[:environment] == "production"
19
+
20
+ @table = configuration[:table]
21
+ @exponential_retry_factor = configuration[:exponential_retry_factor]
22
+
23
+ max_attempts = configuration[:max_attempts]
24
+
25
+ @ds = @db[@table]
26
+
27
+ run_at_conds = [
28
+ { Sequel[@table][:run_at] => nil },
29
+ (Sequel.expr(Sequel[@table][:run_at]) < Sequel::CURRENT_TIMESTAMP)
30
+ ].reduce { |agg, cond| Sequel.expr(agg) | Sequel.expr(cond) }
31
+
32
+ @pick_next_sql = @ds.where(Sequel[@table][:attempts] < max_attempts) # filter out exhausted attempts
33
+ .where(run_at_conds)
34
+ .order(Sequel.desc(:run_at, nulls: :first), :id)
35
+ .for_update
36
+ .skip_locked
37
+ .limit(1)
38
+
39
+ @before_event_handlers = Array(@configuration.lifecycle_events[:before_event])
40
+ @after_event_handlers = Array(@configuration.lifecycle_events[:after_event])
41
+ @error_event_handlers = Array(@configuration.lifecycle_events[:error_event])
42
+ end
43
+
44
+ def fetch_events(&blk)
45
+ num_events = 0
46
+ @db.transaction do
47
+ event_ids = @pick_next_sql.select_map(:id) # lock starts here
48
+
49
+ events = nil
50
+ error = nil
51
+ unless event_ids.empty?
52
+ @db.transaction(savepoint: true) do
53
+ events = @ds.where(id: event_ids).returning.delete
54
+
55
+ if blk
56
+ num_events = events.size
57
+
58
+ events.each do |ev|
59
+ ev[:metadata] = JSON.parse(ev[:metadata].to_s) if ev[:metadata]
60
+ handle_before_event(ev)
61
+ yield(to_message(ev))
62
+ rescue StandardError => e
63
+ error = e
64
+ raise Sequel::Rollback
65
+ end
66
+ else
67
+ events.map!(&method(:to_message))
68
+ end
69
+ end
70
+ end
71
+
72
+ return blk ? 0 : [] if events.nil?
73
+
74
+ return events unless blk
75
+
76
+ if events
77
+ events.each do |event|
78
+ if error
79
+ event.merge!(mark_as_error(event, error))
80
+ handle_error_event(event, error)
81
+ else
82
+ handle_after_event(event)
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ num_events
89
+ end
90
+
91
+ private
92
+
93
+ def mark_as_error(event, error)
94
+ @ds.where(id: event[:id]).returning.update(
95
+ attempts: Sequel[@table][:attempts] + 1,
96
+ run_at: Sequel.date_add(Sequel::CURRENT_TIMESTAMP,
97
+ seconds: event[:attempts] + (1**@exponential_retry_factor)),
98
+ # run_at: Sequel.date_add(Sequel::CURRENT_TIMESTAMP,
99
+ # seconds: Sequel.function(:POWER, Sequel[@table][:attempts] + 1, 4)),
100
+ last_error: "#{error.message}\n#{error.backtrace.join("\n")}"
101
+ ).first
102
+ end
103
+
104
+ def to_message(event)
105
+ {
106
+ id: event[:id],
107
+ type: event[:type],
108
+ before: (JSON.parse(event[:data_before].to_s) if event[:data_before]),
109
+ after: (JSON.parse(event[:data_after].to_s) if event[:data_after]),
110
+ at: event[:created_at]
111
+ }
112
+ end
113
+
114
+ def handle_before_event(event)
115
+ @logger.debug { "outbox event (type: \"#{event[:type]}\", attempts: #{event[:attempts]}) starting..." }
116
+ @before_event_handlers.each do |hd|
117
+ hd.call(event)
118
+ end
119
+ end
120
+
121
+ def handle_after_event(event)
122
+ @logger.debug { "outbox event (type: \"#{event[:type]}\", attempts: #{event[:attempts]}) completed" }
123
+ @after_event_handlers.each do |hd|
124
+ hd.call(event)
125
+ end
126
+ end
127
+
128
+ def handle_error_event(event, error)
129
+ @logger.error do
130
+ "outbox event (type: \"#{event[:type]}\", attempts: #{event[:attempts]}) failed with error\n" \
131
+ "#{error.class}: #{error.message}\n" \
132
+ "#{error.backtrace.join("\n")}"
133
+ end
134
+ @error_event_handlers.each do |hd|
135
+ hd.call(event, error)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "datadog/tracing/contrib/configuration/settings"
4
+
5
+ module Datadog
6
+ module Tracing
7
+ module Contrib
8
+ module Tobox
9
+ module Configuration
10
+ class Settings < Contrib::Configuration::Settings
11
+ option :enabled do |o|
12
+ o.default { env_to_bool("DD_TRACE_SIDEKIQ_ENABLED", true) }
13
+ o.lazy
14
+ end
15
+
16
+ option :analytics_enabled do |o|
17
+ o.default { env_to_bool("DD_TRACE_SIDEKIQ_ANALYTICS_ENABLED", false) }
18
+ o.lazy
19
+ end
20
+
21
+ option :analytics_sample_rate do |o|
22
+ o.default { env_to_float("DD_TRACE_SIDEKIQ_ANALYTICS_SAMPLE_RATE", 1.0) }
23
+ o.lazy
24
+ end
25
+
26
+ option :service_name
27
+ option :error_handler, default: SpanOperation::Events::DEFAULT_ON_ERROR
28
+ option :distributed_tracing, default: false
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "datadog/tracing/contrib/integration"
4
+
5
+ module Datadog
6
+ module Tracing
7
+ module Contrib
8
+ module Tobox
9
+ class Integration
10
+ include Contrib::Integration
11
+
12
+ MINIMUM_VERSION = Gem::Version.new("0.1.0")
13
+
14
+ register_as :tobox
15
+
16
+ def self.version
17
+ Gem.loaded_specs["tobox"] && Gem.loaded_specs["tobox"].version
18
+ end
19
+
20
+ def self.loaded?
21
+ !defined?(::Tobox).nil?
22
+ end
23
+
24
+ def self.compatible?
25
+ super && version >= MINIMUM_VERSION
26
+ end
27
+
28
+ def new_configuration
29
+ Configuration::Settings.new
30
+ end
31
+
32
+ def patcher
33
+ Patcher
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "datadog/tracing/contrib/patcher"
4
+
5
+ module Datadog
6
+ module Tracing
7
+ module Contrib
8
+ module Tobox
9
+ module Patcher
10
+ include Contrib::Patcher
11
+
12
+ module_function
13
+
14
+ def target_version
15
+ Integration.version
16
+ end
17
+
18
+ def patch
19
+ # server-patches provided by plugin(:sidekiq)
20
+ # TODO: use this once we have a producer side
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "datadog/configuration"
4
+ require_relative "datadog/integration"
5
+ require_relative "datadog/patcher"
6
+
7
+ module Tobox
8
+ module Plugins
9
+ module Datadog
10
+ class EventHandler
11
+ def initialize(config)
12
+ @config = config
13
+ @db_table = @config[:table]
14
+ end
15
+
16
+ def on_start(event)
17
+ datadog_config = ::Datadog.configuration.tracing[:tobox]
18
+ service = datadog_config[:service_name]
19
+ error_handler = datadog_config[:error_handler]
20
+
21
+ analytics_enabled = datadog_config[:analytics_enabled]
22
+ analytics_sample_rate = datadog_config[:analytics_sample_rate]
23
+ distributed_tracing = datadog_config[:distributed_tracing]
24
+
25
+ resource = event[:type]
26
+
27
+ if (metadata = event[:metadata])
28
+ previous_span = metadata["datadog-parent-id"]
29
+
30
+ if distributed_tracing && previous_span
31
+ trace_digest = ::Datadog::Tracing::TraceDigest.new(
32
+ span_id: previous_span,
33
+ trace_id: event[:metadata]["datadog-trace-id"],
34
+ trace_sampling_priority: event[:metadata]["datadog-sampling-priority"],
35
+ trace_origin: event[:metadata]["datadog-origin"]
36
+ )
37
+ ::Datadog::Tracing.continue_trace!(trace_digest)
38
+ end
39
+ end
40
+
41
+ span = ::Datadog::Tracing.trace(
42
+ "tobox.event",
43
+ service: service,
44
+ span_type: ::Datadog::Tracing::Metadata::Ext::AppTypes::TYPE_WORKER,
45
+ on_error: error_handler
46
+ )
47
+ span.resource = resource
48
+
49
+ span.set_tag(::Datadog::Tracing::Metadata::Ext::TAG_COMPONENT, "tobox")
50
+ span.set_tag(::Datadog::Tracing::Metadata::Ext::TAG_OPERATION, "event")
51
+
52
+ if ::Datadog::Tracing::Contrib::Analytics.enabled?(analytics_enabled)
53
+ ::Datadog::Tracing::Contrib::Analytics.set_sample_rate(span, analytics_sample_rate)
54
+ end
55
+
56
+ # Measure service stats
57
+ ::Datadog::Tracing::Contrib::Analytics.set_measured(span)
58
+
59
+ span.set_tag("tobox.event.id", event[:id])
60
+ span.set_tag("tobox.event.type", event[:type])
61
+ span.set_tag("tobox.event.retry", event[:attempts])
62
+ span.set_tag("tobox.event.table", @db_table)
63
+ span.set_tag("tobox.event.delay", (Time.now.utc - event[:created_at]).to_f)
64
+
65
+ event[:__tobox_event_span] = span
66
+ end
67
+
68
+ def on_finish(event)
69
+ span = event[:__tobox_event_span]
70
+
71
+ return unless span
72
+
73
+ span.finish
74
+ end
75
+
76
+ def on_error(event, error)
77
+ span = event[:__tobox_event_span]
78
+
79
+ return unless span
80
+
81
+ span.set_error(error)
82
+ span.finish
83
+ end
84
+ end
85
+
86
+ class << self
87
+ def load_dependencies(*)
88
+ require "uri"
89
+ end
90
+
91
+ def configure(config)
92
+ event_handler = EventHandler.new(config)
93
+ config.on_before_event(&event_handler.method(:on_start))
94
+ config.on_after_event(&event_handler.method(:on_finish))
95
+ config.on_error_event(&event_handler.method(:on_error))
96
+ end
97
+ end
98
+ end
99
+
100
+ register_plugin :datadog, Datadog
101
+ end
102
+ end