tobox 0.1.0

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