loading
Generated 2024-02-28T18:37:11+00:00

All Files ( 96.73% covered at 15.8 hits/line )

15 files in total.
642 relevant lines, 621 lines covered and 21 lines missed. ( 96.73% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/tobox.rb 100.00 % 41 22 22 0 6.18
lib/tobox/application.rb 100.00 % 40 22 22 0 8.95
lib/tobox/configuration.rb 100.00 % 175 94 94 0 31.99
lib/tobox/fetcher.rb 100.00 % 188 92 92 0 46.53
lib/tobox/plugins/datadog.rb 98.25 % 106 57 56 1 2.09
lib/tobox/plugins/datadog/configuration.rb 66.67 % 70 42 28 14 0.67
lib/tobox/plugins/datadog/integration.rb 100.00 % 39 19 19 0 3.21
lib/tobox/plugins/datadog/patcher.rb 100.00 % 26 11 11 0 1.09
lib/tobox/plugins/sentry.rb 100.00 % 168 82 82 0 4.12
lib/tobox/plugins/stats.rb 100.00 % 123 63 63 0 3.90
lib/tobox/plugins/zeitwerk.rb 86.21 % 52 29 25 4 1.07
lib/tobox/pool.rb 100.00 % 43 23 23 0 14.83
lib/tobox/pool/fiber_pool.rb 89.47 % 39 19 17 2 1.68
lib/tobox/pool/threaded_pool.rb 100.00 % 70 37 37 0 13.51
lib/tobox/worker.rb 100.00 % 56 30 30 0 27.20

lib/tobox.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 4 require "sequel"
  3. 4 require_relative "tobox/version"
  4. 4 require "mutex_m"
  5. 4 module Tobox
  6. 4 class Error < StandardError; end
  7. 4 module Plugins
  8. 4 @plugins = {}
  9. 4 @plugins.extend(Mutex_m)
  10. # Loads a plugin based on a name. If the plugin hasn't been loaded, tries to load
  11. # it from the load path under "httpx/plugins/" directory.
  12. #
  13. 4 def self.load_plugin(name)
  14. 13 h = @plugins
  15. 26 unless (plugin = h.synchronize { h[name] })
  16. 4 require "tobox/plugins/#{name}"
  17. 8 raise "Plugin #{name} hasn't been registered" unless (plugin = h.synchronize { h[name] })
  18. end
  19. 13 plugin
  20. end
  21. # Registers a plugin (+mod+) in the central store indexed by +name+.
  22. #
  23. 4 def self.register_plugin(name, mod)
  24. 4 h = @plugins
  25. 8 h.synchronize { h[name] = mod }
  26. end
  27. end
  28. end
  29. 4 require_relative "tobox/configuration"
  30. 4 require_relative "tobox/fetcher"
  31. 4 require_relative "tobox/worker"
  32. 4 require_relative "tobox/pool"
  33. 4 require_relative "tobox/application"

lib/tobox/application.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 4 module Tobox
  3. 4 class Application
  4. 4 def initialize(configuration)
  5. 21 @configuration = configuration
  6. 21 @running = false
  7. 21 @on_start_handlers = Array(configuration.lifecycle_events[:on_start])
  8. 21 @on_stop_handlers = Array(configuration.lifecycle_events[:on_stop])
  9. 21 worker = configuration[:worker]
  10. 21 @pool = case worker
  11. 15 when :thread then ThreadedPool
  12. 3 when :fiber then FiberPool
  13. 3 else worker
  14. end.new(configuration)
  15. end
  16. 4 def start
  17. 6 return if @running
  18. 3 @on_start_handlers.each(&:call)
  19. 3 @pool.start
  20. 3 @running = true
  21. end
  22. 4 def stop
  23. 6 return unless @running
  24. 3 @on_stop_handlers.each(&:call)
  25. 3 @pool.stop
  26. 3 @running = false
  27. end
  28. end
  29. end

lib/tobox/configuration.rb

100.0% lines covered

94 relevant lines. 94 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 4 require "logger"
  3. 4 require "forwardable"
  4. 4 module Tobox
  5. 4 class Configuration
  6. 4 extend Forwardable
  7. 4 attr_reader :handlers, :lifecycle_events, :arguments_handler, :default_logger, :database
  8. 4 def_delegator :@config, :[]
  9. 1 DEFAULT_CONFIGURATION = {
  10. 3 environment: ENV.fetch("APP_ENV", "development"),
  11. logger: nil,
  12. log_level: nil,
  13. database_uri: nil,
  14. database_options: nil,
  15. table: :outbox,
  16. group_column: nil,
  17. inbox_table: nil,
  18. inbox_column: nil,
  19. max_attempts: 10,
  20. exponential_retry_factor: 4,
  21. wait_for_events_delay: 5,
  22. shutdown_timeout: 10,
  23. concurrency: 4, # TODO: CPU count
  24. worker: :thread
  25. }.freeze
  26. 4 LOG_FORMAT_PATTERN = "%s, [%s #%d (th: %s)] %5s -- %s: %s\n"
  27. 4 DEFAULT_LOG_FORMATTER = Class.new(Logger::Formatter) do
  28. 4 def call(severity, time, progname, msg)
  29. 137 format(LOG_FORMAT_PATTERN, severity[0, 1], format_datetime(time), Process.pid,
  30. Thread.current.name || Thread.current.object_id, severity, progname, msg2str(msg))
  31. end
  32. end.new
  33. 4 def initialize(name = nil, &block)
  34. 89 @name = name
  35. 89 @config = DEFAULT_CONFIGURATION.dup
  36. 89 @lifecycle_events = {}
  37. 89 @handlers = {}
  38. 89 @message_to_arguments = nil
  39. 89 @plugins = []
  40. 89 if block
  41. 89 case block.arity
  42. when 0
  43. 16 instance_exec(&block)
  44. when 1
  45. 70 yield(self)
  46. else
  47. 3 raise Error, "configuration does not support blocks with more than one variable"
  48. end
  49. end
  50. 83 env = @config[:environment]
  51. 83 @default_logger = @config[:logger] || Logger.new(STDERR, formatter: DEFAULT_LOG_FORMATTER) # rubocop:disable Style/GlobalStdStream
  52. 83 @default_logger.level = @config[:log_level] || (env == "production" ? Logger::INFO : Logger::DEBUG)
  53. 83 @database = if @config[:database_uri]
  54. 4 database_opts = @config[:database_options] || {}
  55. 4 database_opts[:max_connections] = @config[:concurrency] if @config[:worker] == :thread
  56. 4 db = Sequel.connect(@config[:database_uri].to_s, database_opts)
  57. 6 Array(@lifecycle_events[:database_connect]).each { |cb| cb.call(db) }
  58. 4 db
  59. else
  60. 79 Sequel::DATABASES.first
  61. end
  62. 83 raise Error, "no database found" unless @database
  63. 83 if @database.frozen?
  64. 75 raise "#{@database} must have the :date_arithmetic extension loaded" unless Sequel.respond_to?(:date_add)
  65. else
  66. 8 @database.extension :date_arithmetic
  67. 8 @database.loggers << @default_logger unless @config[:environment] == "production"
  68. end
  69. 83 freeze
  70. end
  71. 4 def on(*events, &callback)
  72. 24 events.each do |event|
  73. 24 (@handlers[event.to_sym] ||= []) << callback
  74. end
  75. 24 self
  76. end
  77. 4 def on_start(&callback)
  78. 9 (@lifecycle_events[:on_start] ||= []) << callback
  79. 9 self
  80. end
  81. 4 def on_stop(&callback)
  82. 4 (@lifecycle_events[:on_stop] ||= []) << callback
  83. 4 self
  84. end
  85. 4 def on_before_event(&callback)
  86. 11 (@lifecycle_events[:before_event] ||= []) << callback
  87. 11 self
  88. end
  89. 4 def on_after_event(&callback)
  90. 11 (@lifecycle_events[:after_event] ||= []) << callback
  91. 11 self
  92. end
  93. 4 def on_error_event(&callback)
  94. 17 (@lifecycle_events[:error_event] ||= []) << callback
  95. 17 self
  96. end
  97. 4 def on_error_worker(&callback)
  98. 6 (@lifecycle_events[:error_worker] ||= []) << callback
  99. 6 self
  100. end
  101. 4 def on_database_connect(&callback)
  102. 2 (@lifecycle_events[:database_connect] ||= []) << callback
  103. 2 self
  104. end
  105. 4 def message_to_arguments(&callback)
  106. 3 @arguments_handler = callback
  107. 3 self
  108. end
  109. 4 def plugin(plugin, **options, &block)
  110. 13 raise Error, "Cannot add a plugin to a frozen config" if frozen?
  111. 13 plugin = Plugins.load_plugin(plugin) if plugin.is_a?(Symbol)
  112. 13 return if @plugins.include?(plugin)
  113. 13 @plugins << plugin
  114. 13 plugin.load_dependencies(self, **options, &block) if plugin.respond_to?(:load_dependencies)
  115. 13 extend(plugin::ConfigurationMethods) if defined?(plugin::ConfigurationMethods)
  116. 13 plugin.configure(self, **options, &block) if plugin.respond_to?(:configure)
  117. end
  118. 4 def freeze
  119. 83 @name.freeze
  120. 83 @config.each_value(&:freeze).freeze
  121. 83 @handlers.each_value(&:freeze).freeze
  122. 83 @lifecycle_events.each_value(&:freeze).freeze
  123. 83 @plugins.freeze
  124. 83 @database.freeze
  125. 83 super
  126. end
  127. 4 private
  128. 4 def method_missing(meth, *args, &block)
  129. 155 if DEFAULT_CONFIGURATION.key?(meth) && args.size == 1
  130. 146 @config[meth] = args.first
  131. 8 elsif /\Aon_(.*)\z/.match(meth) && args.empty?
  132. 6 on(Regexp.last_match(1).to_sym, &block)
  133. else
  134. 3 super
  135. end
  136. end
  137. 4 def respond_to_missing?(meth, *args)
  138. 4 super(meth, *args) ||
  139. DEFAULT_CONFIGURATION.key?(meth) ||
  140. /\Aon_(.*)\z/.match(meth)
  141. end
  142. end
  143. end

lib/tobox/fetcher.rb

100.0% lines covered

92 relevant lines. 92 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 4 require "json"
  3. 4 module Tobox
  4. 4 class Fetcher
  5. 4 def initialize(label, configuration)
  6. 105 @label = label
  7. 105 @configuration = configuration
  8. 105 @logger = @configuration.default_logger
  9. 105 @db = configuration.database
  10. 105 @table = configuration[:table]
  11. 105 @group_column = configuration[:group_column]
  12. 105 @exponential_retry_factor = configuration[:exponential_retry_factor]
  13. 105 max_attempts = configuration[:max_attempts]
  14. 105 @inbox_table = configuration[:inbox_table]
  15. 105 @inbox_column = configuration[:inbox_column]
  16. 105 @ds = @db[@table]
  17. 30 run_at_conds = [
  18. 75 { Sequel[@table][:run_at] => nil },
  19. 105 (Sequel.expr(Sequel[@table][:run_at]) < Sequel::CURRENT_TIMESTAMP)
  20. 105 ].reduce { |agg, cond| Sequel.expr(agg) | Sequel.expr(cond) }
  21. 105 @pick_next_sql = @ds.where(Sequel[@table][:attempts] < max_attempts) # filter out exhausted attempts
  22. .where(run_at_conds)
  23. .order(Sequel.desc(:run_at, nulls: :first), :id)
  24. 105 @before_event_handlers = Array(@configuration.lifecycle_events[:before_event])
  25. 105 @after_event_handlers = Array(@configuration.lifecycle_events[:after_event])
  26. 105 @error_event_handlers = Array(@configuration.lifecycle_events[:error_event])
  27. end
  28. 4 def fetch_events(&blk)
  29. 90 num_events = 0
  30. 90 @db.transaction(savepoint: false) do
  31. 90 if @group_column
  32. 12 group = @pick_next_sql.for_update
  33. .skip_locked
  34. .limit(1)
  35. .select(@group_column)
  36. # get total from a group, to compare to the number of future locked rows.
  37. 12 total_from_group = @ds.where(@group_column => group).count
  38. 12 event_ids = @ds.where(@group_column => group)
  39. .order(Sequel.desc(:run_at, nulls: :first), :id)
  40. .for_update.skip_locked.select_map(:id)
  41. 12 if event_ids.size != total_from_group
  42. # this happens if concurrent workers locked different rows from the same group,
  43. # or when new rows from a given group have been inserted after the lock has been
  44. # acquired
  45. 3 event_ids = []
  46. end
  47. # lock all, process 1
  48. 12 event_ids = event_ids[0, 1]
  49. else
  50. 78 event_ids = @pick_next_sql.for_update
  51. .skip_locked
  52. .limit(1).select_map(:id) # lock starts here
  53. end
  54. 90 events = nil
  55. 90 error = nil
  56. 90 unless event_ids.empty?
  57. 54 @db.transaction(savepoint: true) do
  58. 54 events = @ds.where(id: event_ids).returning.delete
  59. 54 if blk
  60. 51 num_events = events.size
  61. 51 events.map! do |ev|
  62. 51 try_insert_inbox(ev) do
  63. 48 ev[:metadata] = try_json_parse(ev[:metadata])
  64. 48 handle_before_event(ev)
  65. 48 yield(to_message(ev))
  66. 36 ev
  67. end
  68. rescue StandardError => e
  69. 12 error = e
  70. 12 raise Sequel::Rollback
  71. 10 end.compact!
  72. else
  73. 3 events.map!(&method(:to_message))
  74. end
  75. end
  76. end
  77. 90 return blk ? 0 : [] if events.nil?
  78. 54 return events unless blk
  79. 51 if events
  80. 51 events.each do |event|
  81. 48 if error
  82. 12 event.merge!(mark_as_error(event, error))
  83. 12 handle_error_event(event, error)
  84. else
  85. 36 handle_after_event(event)
  86. end
  87. end
  88. end
  89. end
  90. 51 num_events
  91. end
  92. 4 private
  93. 4 def log_message(msg)
  94. 48 "(worker: #{@label}) -> #{msg}"
  95. end
  96. 4 def mark_as_error(event, error)
  97. 10 @ds.where(id: event[:id]).returning.update(
  98. attempts: Sequel[@table][:attempts] + 1,
  99. run_at: Sequel.date_add(Sequel::CURRENT_TIMESTAMP,
  100. 10 seconds: event[:attempts] + (1**@exponential_retry_factor)),
  101. # run_at: Sequel.date_add(Sequel::CURRENT_TIMESTAMP,
  102. # seconds: Sequel.function(:POWER, Sequel[@table][:attempts] + 1, 4)),
  103. last_error: "#{error.message}\n#{error.backtrace.join("\n")}"
  104. 3 ).first
  105. end
  106. 4 def to_message(event)
  107. {
  108. 50 id: event[:id],
  109. type: event[:type],
  110. before: try_json_parse(event[:data_before]),
  111. after: try_json_parse(event[:data_after]),
  112. at: event[:created_at]
  113. }
  114. end
  115. 4 def try_json_parse(data)
  116. 150 return unless data
  117. 56 data = JSON.parse(data.to_s) unless data.respond_to?(:to_hash)
  118. 56 data
  119. end
  120. 4 def try_insert_inbox(event)
  121. 51 return yield unless @inbox_table && @inbox_column
  122. 9 ret = @db[@inbox_table].insert_conflict.insert(@inbox_column => event[@inbox_column])
  123. 9 return unless ret
  124. 6 yield
  125. end
  126. 4 def handle_before_event(event)
  127. 48 @logger.debug do
  128. 24 log_message("outbox event (type: \"#{event[:type]}\", attempts: #{event[:attempts]}) starting...")
  129. end
  130. 48 @before_event_handlers.each do |hd|
  131. 8 hd.call(event)
  132. end
  133. end
  134. 4 def handle_after_event(event)
  135. 52 @logger.debug { log_message("outbox event (type: \"#{event[:type]}\", attempts: #{event[:attempts]}) completed") }
  136. 36 @after_event_handlers.each do |hd|
  137. 4 hd.call(event)
  138. end
  139. end
  140. 4 def handle_error_event(event, error)
  141. 12 @logger.error do
  142. 8 log_message("outbox event (type: \"#{event[:type]}\", attempts: #{event[:attempts]}) failed with error\n" \
  143. "#{error.class}: #{error.message}\n" \
  144. "#{error.backtrace.join("\n")}")
  145. end
  146. 12 @error_event_handlers.each do |hd|
  147. 7 hd.call(event, error)
  148. end
  149. end
  150. end
  151. end

lib/tobox/plugins/datadog.rb

98.25% lines covered

57 relevant lines. 56 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "datadog/configuration"
  3. 1 require_relative "datadog/integration"
  4. 1 require_relative "datadog/patcher"
  5. 1 module Tobox
  6. 1 module Plugins
  7. 1 module Datadog
  8. 1 class EventHandler
  9. 1 def initialize(config)
  10. 3 @config = config
  11. 3 @db_table = @config[:table]
  12. end
  13. 1 def on_start(event)
  14. 3 datadog_config = ::Datadog.configuration.tracing[:tobox]
  15. 3 service = datadog_config[:service_name]
  16. 3 error_handler = datadog_config[:error_handler]
  17. 3 analytics_enabled = datadog_config[:analytics_enabled]
  18. 3 analytics_sample_rate = datadog_config[:analytics_sample_rate]
  19. 3 distributed_tracing = datadog_config[:distributed_tracing]
  20. 3 resource = event[:type]
  21. 3 if (metadata = event[:metadata])
  22. 1 previous_span = metadata["datadog-parent-id"]
  23. 1 if distributed_tracing && previous_span
  24. 1 trace_digest = ::Datadog::Tracing::TraceDigest.new(
  25. span_id: previous_span,
  26. trace_id: event[:metadata]["datadog-trace-id"],
  27. trace_sampling_priority: event[:metadata]["datadog-sampling-priority"],
  28. trace_origin: event[:metadata]["datadog-origin"]
  29. )
  30. 1 ::Datadog::Tracing.continue_trace!(trace_digest)
  31. end
  32. end
  33. 3 span = ::Datadog::Tracing.trace(
  34. "tobox.event",
  35. service: service,
  36. span_type: ::Datadog::Tracing::Metadata::Ext::AppTypes::TYPE_WORKER,
  37. on_error: error_handler
  38. )
  39. 3 span.resource = resource
  40. 3 span.set_tag(::Datadog::Tracing::Metadata::Ext::TAG_COMPONENT, "tobox")
  41. 3 span.set_tag(::Datadog::Tracing::Metadata::Ext::TAG_OPERATION, "event")
  42. 3 if ::Datadog::Tracing::Contrib::Analytics.enabled?(analytics_enabled)
  43. ::Datadog::Tracing::Contrib::Analytics.set_sample_rate(span, analytics_sample_rate)
  44. end
  45. # Measure service stats
  46. 3 ::Datadog::Tracing::Contrib::Analytics.set_measured(span)
  47. 3 span.set_tag("tobox.event.id", event[:id])
  48. 3 span.set_tag("tobox.event.type", event[:type])
  49. 3 span.set_tag("tobox.event.retry", event[:attempts])
  50. 3 span.set_tag("tobox.event.table", @db_table)
  51. 3 span.set_tag("tobox.event.delay", (Time.now.utc - event[:created_at]).to_f)
  52. 3 event[:__tobox_event_span] = span
  53. end
  54. 1 def on_finish(event)
  55. 2 span = event[:__tobox_event_span]
  56. 2 return unless span
  57. 2 span.finish
  58. end
  59. 1 def on_error(event, error)
  60. 1 span = event[:__tobox_event_span]
  61. 1 return unless span
  62. 1 span.set_error(error)
  63. 1 span.finish
  64. end
  65. end
  66. 1 class << self
  67. 1 def load_dependencies(*)
  68. 3 require "uri"
  69. end
  70. 1 def configure(config, **datadog_options, &blk)
  71. 3 event_handler = EventHandler.new(config)
  72. 3 config.on_before_event(&event_handler.method(:on_start))
  73. 3 config.on_after_event(&event_handler.method(:on_finish))
  74. 3 config.on_error_event(&event_handler.method(:on_error))
  75. 3 ::Datadog.configure do |c|
  76. 3 c.tracing.instrument :tobox, datadog_options
  77. 3 yield(c) if blk
  78. end
  79. end
  80. end
  81. end
  82. 1 register_plugin :datadog, Datadog
  83. end
  84. end

lib/tobox/plugins/datadog/configuration.rb

66.67% lines covered

42 relevant lines. 28 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "datadog/tracing/contrib"
  3. 1 require "datadog/tracing/contrib/configuration/settings"
  4. 1 require "datadog/tracing/span_operation"
  5. 1 module Datadog
  6. 1 module Tracing
  7. 1 module Contrib
  8. 1 module Tobox
  9. 1 module Configuration
  10. 1 class Settings < Contrib::Configuration::Settings
  11. 1 if Gem::Version.new(DDTrace::VERSION::STRING) >= Gem::Version.new("1.13.0")
  12. 1 option :enabled do |o|
  13. 1 o.type :bool
  14. 1 o.env "DD_TOBOX_SIDEKIQ_ENABLED"
  15. 1 o.default true
  16. end
  17. 1 option :analytics_enabled do |o|
  18. 1 o.type :bool
  19. 1 o.env "DD_TOBOX_ANALYTICS_ENABLED"
  20. 1 o.default false
  21. end
  22. 1 option :analytics_sample_rate do |o|
  23. 1 o.type :float
  24. 1 o.env "DD_TRACE_TOBOX_ANALYTICS_SAMPLE_RATE"
  25. 1 o.default 1.0
  26. end
  27. else
  28. option :enabled do |o|
  29. o.default { env_to_bool("DD_TOBOX_SIDEKIQ_ENABLED", true) }
  30. o.lazy
  31. end
  32. option :analytics_enabled do |o|
  33. o.default { env_to_bool("DD_TOBOX_ANALYTICS_ENABLED", false) }
  34. o.lazy
  35. end
  36. option :analytics_sample_rate do |o|
  37. o.default { env_to_float("DD_TRACE_TOBOX_ANALYTICS_SAMPLE_RATE", 1.0) }
  38. o.lazy
  39. end
  40. end
  41. 1 option :service_name
  42. 1 if DDTrace::VERSION::STRING >= "1.15.0"
  43. 1 option :error_handler do |o|
  44. 1 o.type :proc
  45. 1 o.default_proc(&Tracing::SpanOperation::Events::DEFAULT_ON_ERROR)
  46. end
  47. elsif DDTrace::VERSION::STRING >= "1.13.0"
  48. option :error_handler do |o|
  49. o.type :proc
  50. o.experimental_default_proc(&Tracing::SpanOperation::Events::DEFAULT_ON_ERROR)
  51. end
  52. else
  53. option :error_handler, default: Tracing::SpanOperation::Events::DEFAULT_ON_ERROR
  54. end
  55. 1 option :distributed_tracing, default: false
  56. end
  57. end
  58. end
  59. end
  60. end
  61. end

lib/tobox/plugins/datadog/integration.rb

100.0% lines covered

19 relevant lines. 19 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "datadog/tracing/contrib/integration"
  3. 1 module Datadog
  4. 1 module Tracing
  5. 1 module Contrib
  6. 1 module Tobox
  7. 1 class Integration
  8. 1 include Contrib::Integration
  9. 1 MINIMUM_VERSION = Gem::Version.new("0.1.0")
  10. 1 register_as :tobox
  11. 1 def self.version
  12. 20 Gem.loaded_specs["tobox"] && Gem.loaded_specs["tobox"].version
  13. end
  14. 1 def self.loaded?
  15. 6 !defined?(::Tobox).nil?
  16. end
  17. 1 def self.compatible?
  18. 6 super && version >= MINIMUM_VERSION
  19. end
  20. 1 def new_configuration
  21. 3 Configuration::Settings.new
  22. end
  23. 1 def patcher
  24. 12 Patcher
  25. end
  26. end
  27. end
  28. end
  29. end
  30. end

lib/tobox/plugins/datadog/patcher.rb

100.0% lines covered

11 relevant lines. 11 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "datadog/tracing/contrib/patcher"
  3. 1 module Datadog
  4. 1 module Tracing
  5. 1 module Contrib
  6. 1 module Tobox
  7. 1 module Patcher
  8. 1 include Contrib::Patcher
  9. 1 module_function
  10. 1 def target_version
  11. 2 Integration.version
  12. end
  13. 1 def patch
  14. # server-patches provided by plugin(:sidekiq)
  15. # TODO: use this once we have a producer side
  16. end
  17. end
  18. end
  19. end
  20. end
  21. end

lib/tobox/plugins/sentry.rb

100.0% lines covered

82 relevant lines. 82 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Tobox
  3. 1 module Plugins
  4. 1 module Sentry
  5. 1 module ConfigurationMethods
  6. 1 def on_sentry_init(&callback)
  7. 5 (@lifecycle_events[:sentry_init] ||= []) << callback
  8. 5 self
  9. end
  10. end
  11. 1 class Configuration
  12. # Set this option to true if you want Sentry to only capture the last job
  13. # retry if it fails.
  14. 1 attr_accessor :report_after_retries
  15. 1 def initialize
  16. 30 @report_after_retries = false
  17. end
  18. end
  19. 1 class EventHandler
  20. 1 TOBOX_NAME = "tobox"
  21. 1 def initialize(config)
  22. 5 @config = config
  23. 5 @db_table = @config[:table]
  24. 5 @db_scheme = URI(@config[:database_uri]).scheme if @config[:database_uri]
  25. 5 @max_attempts = @config[:max_attempts]
  26. end
  27. 1 def on_start(event)
  28. 5 return unless ::Sentry.initialized?
  29. 5 ::Sentry.clone_hub_to_current_thread
  30. 5 scope = ::Sentry.get_current_scope
  31. 5 scope.set_contexts(tobox: {
  32. id: event[:id],
  33. type: event[:type],
  34. attempts: event[:attempts],
  35. created_at: event[:created_at],
  36. run_at: event[:run_at],
  37. last_error: event[:last_error]&.byteslice(0..1000),
  38. version: Tobox::VERSION,
  39. db_adapter: @db_scheme
  40. })
  41. 5 scope.set_tags(
  42. outbox: @db_table,
  43. event_id: event[:id],
  44. event_type: event[:type]
  45. )
  46. 5 scope.set_transaction_name("#{TOBOX_NAME}/#{event[:type]}") unless scope.transaction_name
  47. 5 transaction = start_transaction(scope.transaction_name, event[:metadata].to_h["sentry_trace"])
  48. 5 return unless transaction
  49. 5 scope.set_span(transaction)
  50. # good for thread pool, good for fiber pool
  51. 5 store_transaction(event, transaction)
  52. end
  53. 1 def on_finish(event)
  54. 2 return unless ::Sentry.initialized?
  55. 2 transaction = retrieve_transaction(event)
  56. 2 return unless transaction
  57. 2 finish_transaction(transaction, 200)
  58. 2 scope = ::Sentry.get_current_scope
  59. 2 scope.clear
  60. end
  61. 1 def on_error(event, error)
  62. 3 return unless ::Sentry.initialized?
  63. 3 capture_exception(event, error)
  64. 3 transaction = retrieve_transaction(event)
  65. 3 return unless transaction
  66. 3 finish_transaction(transaction, 500)
  67. end
  68. 1 private
  69. 1 def start_transaction(transaction_name, sentry_trace)
  70. 5 options = { name: transaction_name, op: "tobox" }
  71. 5 transaction = ::Sentry::Transaction.from_sentry_trace(sentry_trace, **options) if sentry_trace
  72. 5 ::Sentry.start_transaction(transaction: transaction, **options)
  73. end
  74. 1 def finish_transaction(transaction, status)
  75. 5 transaction.set_http_status(status)
  76. 5 transaction.finish
  77. end
  78. 1 def store_transaction(event, transaction)
  79. 5 store = (Thread.current[:tobox_sentry_transactions] ||= {})
  80. 5 store[event[:id]] = transaction
  81. end
  82. 1 def retrieve_transaction(event)
  83. 5 return unless (store = Thread.current[:tobox_sentry_transactions])
  84. 5 store.delete(event[:id])
  85. end
  86. 1 def capture_exception(event, error)
  87. 3 if ::Sentry.configuration.tobox.report_after_retries && event[:attempts] && event[:attempts] < @max_attempts
  88. 1 return
  89. end
  90. 2 ::Sentry.capture_exception(
  91. error,
  92. hint: { background: false }
  93. )
  94. end
  95. end
  96. 1 class << self
  97. 1 def load_dependencies(*)
  98. 5 require "uri"
  99. 5 require "sentry-ruby"
  100. 5 require "sentry/integrable"
  101. 5 extend ::Sentry::Integrable
  102. end
  103. 1 def configure(config)
  104. 5 event_handler = EventHandler.new(config)
  105. 5 config.on_before_event(&event_handler.method(:on_start))
  106. 5 config.on_after_event(&event_handler.method(:on_finish))
  107. 5 config.on_error_event(&event_handler.method(:on_error))
  108. 5 config.on_error_worker do |error|
  109. 1 ::Sentry.capture_exception(error, hint: { background: false })
  110. end
  111. 5 ::Sentry::Configuration.attr_reader(:tobox)
  112. 5 ::Sentry::Configuration.add_post_initialization_callback do
  113. 30 @tobox = Plugins::Sentry::Configuration.new
  114. end
  115. 5 register_integration name: "tobox", version: Tobox::VERSION
  116. 5 config.on_start do
  117. 10 ::Sentry.init do |sentry_cfg|
  118. 10 Array(config.lifecycle_events[:sentry_init]).each do |cb|
  119. 10 cb[sentry_cfg]
  120. end
  121. end
  122. end
  123. end
  124. end
  125. end
  126. 1 register_plugin :sentry, Sentry
  127. end
  128. end

lib/tobox/plugins/stats.rb

100.0% lines covered

63 relevant lines. 63 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Tobox
  3. 1 module Plugins
  4. 1 module Stats
  5. 1 module ConfigurationMethods
  6. 1 attr_reader :stats_interval_seconds
  7. 1 def on_stats(stats_interval_seconds, &callback)
  8. 4 @stats_interval_seconds = stats_interval_seconds
  9. 4 (@lifecycle_events[:stats] ||= []) << callback
  10. 4 self
  11. end
  12. end
  13. 1 class StatsEmitter
  14. 1 def initialize(config)
  15. 4 @config = config
  16. 4 @running = false
  17. end
  18. 1 def start
  19. 4 return if @running
  20. 4 config = @config
  21. 4 interval = config.stats_interval_seconds
  22. 4 @stats_handlers = Array(config.lifecycle_events[:stats])
  23. 4 return if @stats_handlers.empty?
  24. 4 @error_handlers = Array(config.lifecycle_events[:error_worker])
  25. 4 @max_attempts = config[:max_attempts]
  26. 4 @db = Sequel.connect(config.database.opts.merge(max_connections: 1))
  27. 4 Array(config.lifecycle_events[:database_connect]).each { |cb| cb.call(@db) }
  28. 4 @outbox_table = config[:table]
  29. 4 @outbox_ds = @db[@outbox_table]
  30. 4 inbox_table = config[:inbox_table]
  31. 4 @inbox_ds = @db[inbox_table] if inbox_table
  32. 4 logger = config.default_logger
  33. 4 stats = method(:collect_event_stats)
  34. 4 stats.instance_eval do
  35. 4 alias collect call
  36. end
  37. 4 @th = Thread.start do
  38. 4 Thread.current.name = "outbox-stats"
  39. 4 loop do
  40. 22 logger.debug { "stats worker: sleep for #{interval}s..." }
  41. 11 sleep interval
  42. begin
  43. 7 emit_event_stats(stats)
  44. rescue RuntimeError => e
  45. 2 @error_handlers.each { |hd| hd.call(e) }
  46. end
  47. 7 break unless @running
  48. end
  49. end
  50. 4 @running = true
  51. end
  52. 1 def stop
  53. 4 return unless @running
  54. 4 @th.terminate
  55. 4 @db.disconnect
  56. 4 @running = false
  57. end
  58. 1 private
  59. 1 def emit_event_stats(stats)
  60. 7 @stats_handlers.each do |hd|
  61. 7 hd.call(stats, @db)
  62. end
  63. end
  64. 1 def collect_event_stats
  65. 5 stats = @outbox_ds.group_and_count(
  66. Sequel.case([
  67. [{ last_error: nil }, "pending_count"],
  68. [Sequel.expr([:attempts]) < @max_attempts, "failing_count"]
  69. ],
  70. "failed_count").as(:status)
  71. )
  72. 5 stats = stats.as_hash(:status, :count).transform_keys(&:to_sym)
  73. # fill it in
  74. 5 stats[:pending_count] ||= 0
  75. 5 stats[:failing_count] ||= 0
  76. 5 stats[:failed_count] ||= 0
  77. 5 stats[:inbox_count] = @inbox_ds.count if @inbox_ds
  78. 5 stats
  79. end
  80. end
  81. 1 class << self
  82. 1 def configure(config)
  83. 4 emitter = StatsEmitter.new(config)
  84. 4 config.on_start(&emitter.method(:start))
  85. 4 config.on_stop(&emitter.method(:stop))
  86. end
  87. end
  88. end
  89. 1 register_plugin :stats, Stats
  90. end
  91. end

lib/tobox/plugins/zeitwerk.rb

86.21% lines covered

29 relevant lines. 25 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Tobox
  3. 1 module Plugins
  4. 1 module Zeitwerk
  5. 1 module ConfigurationMethods
  6. 1 def zeitwerk_loader(loader = nil, &blk)
  7. 3 if loader
  8. @zeitwerk_loader = loader
  9. 3 elsif blk
  10. 1 @zeitwerk_loader ||= ::Zeitwerk::Loader.new
  11. 1 yield(@zeitwerk_loader)
  12. 2 elsif !(loader || blk)
  13. 2 @zeitwerk_loader
  14. end
  15. end
  16. 1 def freeze
  17. 1 loader = @zeitwerk_loader
  18. 1 return super unless loader
  19. 1 if @config[:environment] == "production"
  20. loader.setup
  21. ::Zeitwerk::Loader.eager_load_all
  22. else
  23. 1 loader.enable_reloading
  24. 1 loader.setup
  25. end
  26. 1 super
  27. end
  28. end
  29. 1 class << self
  30. 1 def load_dependencies(*)
  31. 1 require "zeitwerk"
  32. end
  33. 1 def configure(config)
  34. 1 loader = config.zeitwerk_loader
  35. 1 return unless loader
  36. config.on_before_event { |*| loader.reload }
  37. end
  38. end
  39. end
  40. 1 register_plugin :zeitwerk, Zeitwerk
  41. end
  42. end

lib/tobox/pool.rb

100.0% lines covered

23 relevant lines. 23 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 4 module Tobox
  3. 4 class Pool
  4. 4 class KillError < Interrupt; end
  5. 4 def initialize(configuration)
  6. 25 @configuration = configuration
  7. 25 @logger = @configuration.default_logger
  8. 25 @num_workers = configuration[:concurrency]
  9. 25 @workers = Array.new(@num_workers) do |idx|
  10. 62 Worker.new("tobox-worker-#{idx}", configuration)
  11. end
  12. 25 @worker_error_handlers = Array(@configuration.lifecycle_events[:error_worker])
  13. 25 @running = true
  14. end
  15. 4 def stop
  16. 13 return unless @running
  17. 13 @workers.each(&:finish!)
  18. 13 @running = false
  19. end
  20. 4 def do_work(wrk)
  21. 33 wrk.work
  22. rescue KillError
  23. # noop
  24. rescue Exception => e # rubocop:disable Lint/RescueException
  25. 7 wrk.finish!
  26. 7 @logger.error do
  27. 3 "(worker: #{wrk.label}) -> " \
  28. "crashed with error\n" \
  29. "#{e.class}: #{e.message}\n" \
  30. "#{e.backtrace.join("\n")}"
  31. end
  32. 8 @worker_error_handlers.each { |hd| hd.call(e) }
  33. end
  34. end
  35. 4 autoload :ThreadedPool, File.join(__dir__, "pool", "threaded_pool")
  36. 4 autoload :FiberPool, File.join(__dir__, "pool", "fiber_pool")
  37. end

lib/tobox/pool/fiber_pool.rb

89.47% lines covered

19 relevant lines. 17 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 2 require "timeout"
  3. 2 require "fiber_scheduler"
  4. 2 module Tobox
  5. 2 class FiberPool < Pool
  6. 2 def initialize(_configuration)
  7. 3 Sequel.extension(:fiber_concurrency)
  8. 3 super
  9. end
  10. 2 def start
  11. 1 @fiber_thread = Thread.start do
  12. 1 Thread.current.name = "tobox-fibers-thread"
  13. 1 FiberScheduler do
  14. 1 @workers.each_with_index do |wk, _idx|
  15. 4 Fiber.schedule { do_work(wk) }
  16. end
  17. end
  18. end
  19. end
  20. 2 def stop
  21. 1 shutdown_timeout = @configuration[:shutdown_timeout]
  22. 1 super
  23. begin
  24. 2 Timeout.timeout(shutdown_timeout) { @fiber_thread.value }
  25. rescue Timeout::Error
  26. # hard exit
  27. @fiber_thread.raise(KillError)
  28. @fiber_thread.value
  29. end
  30. end
  31. end
  32. end

lib/tobox/pool/threaded_pool.rb

100.0% lines covered

37 relevant lines. 37 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "monitor"
  3. 3 module Tobox
  4. 3 class ThreadedPool < Pool
  5. 3 def initialize(_configuration)
  6. 15 @parent_thread = Thread.main
  7. 15 @threads = []
  8. 15 @threads.extend(MonitorMixin)
  9. 15 super
  10. end
  11. 3 def start
  12. 12 @workers.each do |wk|
  13. 24 th = start_thread_worker(wk)
  14. 24 @threads.synchronize do
  15. 24 @threads << th
  16. end
  17. end
  18. end
  19. 3 def stop
  20. 9 shutdown_timeout = @configuration[:shutdown_timeout]
  21. 9 deadline = Process.clock_gettime(::Process::CLOCK_MONOTONIC)
  22. 9 super
  23. 9 Thread.pass # let workers finish
  24. # soft exit
  25. 9 while Process.clock_gettime(::Process::CLOCK_MONOTONIC) - deadline < shutdown_timeout
  26. 40 return if @threads.empty?
  27. 34 sleep 0.5
  28. end
  29. # hard exit
  30. 9 @threads.each { |th| th.raise(KillError) }
  31. 8 while (th = @threads.pop)
  32. 4 th.value # waits
  33. end
  34. end
  35. 3 private
  36. 3 def start_thread_worker(wrk)
  37. 30 Thread.start(wrk) do |worker|
  38. 30 Thread.current.name = worker.label
  39. 30 do_work(worker)
  40. 24 @threads.synchronize do
  41. 24 @threads.delete(Thread.current)
  42. 24 if worker.finished? && @running
  43. 6 idx = @workers.index(worker)
  44. 6 subst_worker = Worker.new(worker.label, @configuration)
  45. 6 @workers[idx] = subst_worker
  46. 6 subst_thread = start_thread_worker(subst_worker)
  47. 6 @threads << subst_thread
  48. end
  49. # all workers went down abruply, we need to kill the process.
  50. # @parent_thread.raise(Interrupt) if wk.finished? && @threads.empty? && @running
  51. end
  52. end
  53. end
  54. end
  55. end

lib/tobox/worker.rb

100.0% lines covered

30 relevant lines. 30 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 4 module Tobox
  3. 4 class Worker
  4. 4 attr_reader :label
  5. 4 def initialize(label, configuration)
  6. 81 @label = label
  7. 81 @wait_for_events_delay = configuration[:wait_for_events_delay]
  8. 81 @handlers = configuration.handlers || {}
  9. 81 @fetcher = Fetcher.new(label, configuration)
  10. 81 @finished = false
  11. 81 return unless (message_to_arguments = configuration.arguments_handler)
  12. 3 define_singleton_method(:message_to_arguments, &message_to_arguments)
  13. end
  14. 4 def finished?
  15. 24 @finished
  16. end
  17. 4 def finish!
  18. 36 @finished = true
  19. end
  20. 4 def work
  21. 23 do_work until @finished
  22. end
  23. 4 private
  24. 4 def do_work
  25. 33 return if @finished
  26. 33 sum_fetched_events = @fetcher.fetch_events do |event|
  27. 15 event_type = event[:type].to_sym
  28. 15 args = message_to_arguments(event)
  29. 15 if @handlers.key?(event_type)
  30. 9 @handlers[event_type].each do |handler|
  31. 9 handler.call(args)
  32. end
  33. end
  34. end
  35. 33 return if @finished
  36. 30 sleep(@wait_for_events_delay) if sum_fetched_events.zero?
  37. end
  38. 4 def message_to_arguments(event)
  39. 12 event
  40. end
  41. end
  42. end