rails-transactional-outbox 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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +13 -0
  3. data/.github/workflows/ci.yml +49 -0
  4. data/.gitignore +13 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +150 -0
  7. data/CHANGELOG.md +5 -0
  8. data/Gemfile +19 -0
  9. data/Gemfile.lock +142 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +285 -0
  12. data/Rakefile +10 -0
  13. data/bin/console +15 -0
  14. data/bin/rails_transactional_outbox_health_check +13 -0
  15. data/bin/setup +8 -0
  16. data/lib/rails-transactional-outbox.rb +3 -0
  17. data/lib/rails_transactional_outbox/configuration.rb +33 -0
  18. data/lib/rails_transactional_outbox/error_handlers/null_error_handler.rb +9 -0
  19. data/lib/rails_transactional_outbox/error_handlers.rb +6 -0
  20. data/lib/rails_transactional_outbox/event_type.rb +37 -0
  21. data/lib/rails_transactional_outbox/exponential_backoff.rb +9 -0
  22. data/lib/rails_transactional_outbox/health_check.rb +48 -0
  23. data/lib/rails_transactional_outbox/monitor.rb +47 -0
  24. data/lib/rails_transactional_outbox/outbox_entries_processor.rb +56 -0
  25. data/lib/rails_transactional_outbox/outbox_entry_factory.rb +32 -0
  26. data/lib/rails_transactional_outbox/outbox_model.rb +78 -0
  27. data/lib/rails_transactional_outbox/railtie.rb +11 -0
  28. data/lib/rails_transactional_outbox/record_processor.rb +35 -0
  29. data/lib/rails_transactional_outbox/record_processors/active_record_processor.rb +39 -0
  30. data/lib/rails_transactional_outbox/record_processors/base_processor.rb +15 -0
  31. data/lib/rails_transactional_outbox/record_processors.rb +6 -0
  32. data/lib/rails_transactional_outbox/reliable_model/reliable_callback.rb +41 -0
  33. data/lib/rails_transactional_outbox/reliable_model/reliable_callbacks_registry.rb +26 -0
  34. data/lib/rails_transactional_outbox/reliable_model.rb +81 -0
  35. data/lib/rails_transactional_outbox/runner.rb +106 -0
  36. data/lib/rails_transactional_outbox/runner_sleep_interval.rb +14 -0
  37. data/lib/rails_transactional_outbox/tracers/datadog_tracer.rb +35 -0
  38. data/lib/rails_transactional_outbox/tracers/null_tracer.rb +9 -0
  39. data/lib/rails_transactional_outbox/tracers.rb +7 -0
  40. data/lib/rails_transactional_outbox/version.rb +7 -0
  41. data/lib/rails_transactional_outbox.rb +54 -0
  42. data/lib/tasks/rails_transactional_outbox.rake +11 -0
  43. data/rails-transactional-outbox.gemspec +44 -0
  44. metadata +188 -0
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ module OutboxModel
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ scope :fetch_processable, lambda { |batch_size|
9
+ where(processed_at: nil)
10
+ .lock("FOR UPDATE SKIP LOCKED")
11
+ .where("retry_at IS NULL OR retry_at <= ?", Time.current)
12
+ .order(created_at: :asc)
13
+ .limit(batch_size)
14
+ }
15
+
16
+ def self.any_records_to_process?
17
+ where(processed_at: nil)
18
+ .where("retry_at IS NULL OR retry_at <= ?", Time.current)
19
+ .exists?
20
+ end
21
+
22
+ def self.outbox_encrypt_json_for(*encryptable_json_attributes)
23
+ encryptable_json_attributes.each do |attribute|
24
+ define_method "#{attribute}=" do |payload|
25
+ super(payload.to_json)
26
+ end
27
+
28
+ define_method "transformed_#{attribute}" do
29
+ JSON.parse(public_send(attribute)).symbolize_keys
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ def transformed_arguments
36
+ arguments.to_h.symbolize_keys
37
+ end
38
+
39
+ def transformed_changeset
40
+ changeset.to_h.symbolize_keys
41
+ end
42
+
43
+ def processed?
44
+ processed_at.present?
45
+ end
46
+
47
+ def failed?
48
+ failed_at.present?
49
+ end
50
+
51
+ def handle_error(raised_error, clock: Time, backoff_multiplier: 5)
52
+ @error = raised_error
53
+ self.error_class = raised_error.class
54
+ self.error_message = raised_error.message
55
+ self.failed_at = clock.current
56
+ self.attempts ||= 0
57
+ self.attempts += 1
58
+ self.retry_at = clock.current.advance(
59
+ seconds: RailsTransactionalOutbox::ExponentialBackoff.backoff_for(backoff_multiplier, attempts)
60
+ )
61
+ end
62
+
63
+ def error
64
+ @error || error_class.constantize.new(error_message)
65
+ end
66
+
67
+ def event_type
68
+ RailsTransactionalOutbox::EventType.resolve_from_event_name(event_name).to_sym
69
+ end
70
+
71
+ def infer_model
72
+ model_klass = resource_class.constantize
73
+ model_klass.find(resource_id)
74
+ rescue ActiveRecord::RecordNotFound
75
+ model_klass.new(id: resource_id) if RailsTransactionalOutbox::EventType.new(event_type).destroy?
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class Railtie < Rails::Railtie
5
+ railtie_name :rails_transactional_outbox
6
+
7
+ rake_tasks do
8
+ load "tasks/rails_transactional_outbox.rake"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class RecordProcessor
5
+ attr_reader :config
6
+ private :config
7
+
8
+ def initialize(config: RailsTransactionalOutbox.configuration)
9
+ @config = config
10
+ end
11
+
12
+ def call(record)
13
+ applicable_record_processors = record_processors.select { |processor| processor.applies?(record) }
14
+ applicable_record_processors.any? or raise ProcessorNotFoundError.new(record)
15
+
16
+ applicable_record_processors.each { |processor| processor.call(record) }
17
+ end
18
+
19
+ delegate :record_processors, to: :config
20
+
21
+ class ProcessorNotFoundError < StandardError
22
+ attr_reader :record
23
+ private :record
24
+
25
+ def initialize(record)
26
+ super()
27
+ @record = record
28
+ end
29
+
30
+ def to_s
31
+ "no processor was found for record with ID: #{record.id}, context: #{record.context}"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class RecordProcessors
5
+ class ActiveRecordProcessor < RailsTransactionalOutbox::RecordProcessors::BaseProcessor
6
+ ACTIVE_RECORD_CONTEXT = "active_record"
7
+ private_constant :ACTIVE_RECORD_CONTEXT
8
+
9
+ def self.context
10
+ ACTIVE_RECORD_CONTEXT
11
+ end
12
+
13
+ def applies?(record)
14
+ record.context == ACTIVE_RECORD_CONTEXT
15
+ end
16
+
17
+ def call(record)
18
+ model = record.infer_model or raise CouldNotFindModelError.new(record)
19
+ model.previous_changes = record.transformed_changeset.with_indifferent_access
20
+ model.reliable_after_commit_callbacks.for_event_type(record.event_type).each do |callback|
21
+ callback.call(model)
22
+ end
23
+ end
24
+
25
+ class CouldNotFindModelError < StandardError
26
+ attr_reader :record
27
+
28
+ def initialize(record)
29
+ super()
30
+ @record = record
31
+ end
32
+
33
+ def to_s
34
+ "could not find model for outbox record: #{record.id}"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class RecordProcessors
5
+ class BaseProcessor
6
+ def applies?(_record)
7
+ raise "implement me"
8
+ end
9
+
10
+ def call(_record)
11
+ raise "implement me"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class RecordProcessors
5
+ end
6
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ module ReliableModel
5
+ class ReliableCallback
6
+ attr_reader :callback, :options
7
+ private :callback, :options
8
+
9
+ def initialize(callback, options)
10
+ @callback = callback
11
+ @options = options
12
+ end
13
+
14
+ def for_event?(event_type)
15
+ on.include?(event_type.to_sym)
16
+ end
17
+
18
+ def call(model)
19
+ return unless execute?(model)
20
+
21
+ model.instance_exec(&callback)
22
+ end
23
+
24
+ private
25
+
26
+ def on
27
+ Array(options.fetch(:on, [])).map(&:to_sym)
28
+ end
29
+
30
+ def execute?(model)
31
+ if options.key?(:if)
32
+ model.instance_exec(&options[:if])
33
+ elsif options.key?(:unless)
34
+ !model.instance_exec(&options[:unless])
35
+ else
36
+ true
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ module ReliableModel
5
+ class ReliableCallbacksRegistry
6
+ include Enumerable
7
+
8
+ delegate :each, to: :registry
9
+
10
+ attr_reader :registry
11
+ private :registry
12
+
13
+ def initialize
14
+ @registry = []
15
+ end
16
+
17
+ def <<(item)
18
+ registry << item
19
+ end
20
+
21
+ def for_event_type(event_type)
22
+ registry.select { |cb| cb.for_event?(event_type) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ module ReliableModel
5
+ extend ActiveSupport::Concern
6
+
7
+ NOT_PROVIDED = Object.new.freeze
8
+ private_constant :NOT_PROVIDED
9
+
10
+ included do
11
+ after_create :transactional_outbox_insert_model_created
12
+ after_update :transactional_outbox_insert_model_updated
13
+ after_destroy :transactional_outbox_insert_model_destroyed
14
+
15
+ def self.reliable_after_commit_callbacks
16
+ @reliable_after_commit_callbacks ||= RailsTransactionalOutbox::ReliableModel::ReliableCallbacksRegistry.new
17
+ end
18
+
19
+ def self.reliable_after_commit(method_name = NOT_PROVIDED, options = {}, &block)
20
+ if block
21
+ callback_proc = block
22
+ else
23
+ raise ArgumentError.new("You must provide a block or a method name") unless method_name.is_a?(Symbol)
24
+
25
+ callback_proc = -> { send(method_name) }
26
+ end
27
+
28
+ final_options = options.reverse_merge(on: %i[create update destroy])
29
+ reliable_after_commit_callbacks << ReliableCallback.new(callback_proc, final_options)
30
+ end
31
+
32
+ def self.reliable_after_create_commit(method_name = NOT_PROVIDED, options = {}, &block)
33
+ reliable_after_commit(method_name, options.merge(on: :create), &block)
34
+ end
35
+
36
+ def self.reliable_after_update_commit(method_name = NOT_PROVIDED, options = {}, &block)
37
+ reliable_after_commit(method_name, options.merge(on: :update), &block)
38
+ end
39
+
40
+ def self.reliable_after_destroy_commit(method_name = NOT_PROVIDED, options = {}, &block)
41
+ reliable_after_commit(method_name, options.merge(on: :destroy), &block)
42
+ end
43
+
44
+ def self.reliable_after_save_commit(method_name = NOT_PROVIDED, options = {}, &block)
45
+ reliable_after_commit(method_name, options.merge(on: %i[create update]), &block)
46
+ end
47
+
48
+ alias_method :original_previous_changes, :previous_changes
49
+
50
+ def previous_changes
51
+ @previous_changes || original_previous_changes
52
+ end
53
+
54
+ def previous_changes=(changeset)
55
+ @previous_changes = changeset
56
+ end
57
+
58
+ private
59
+
60
+ def transactional_outbox_insert_model_created
61
+ transactional_outbox_entry_factory.build(self, :create).save
62
+ end
63
+
64
+ def transactional_outbox_insert_model_updated
65
+ transactional_outbox_entry_factory.build(self, :update).save!
66
+ end
67
+
68
+ def transactional_outbox_insert_model_destroyed
69
+ transactional_outbox_entry_factory.build(self, :destroy).save!
70
+ end
71
+
72
+ def transactional_outbox_entry_factory
73
+ @transactional_outbox_entry_factory ||= RailsTransactionalOutbox::OutboxEntryFactory.new
74
+ end
75
+ end
76
+
77
+ def reliable_after_commit_callbacks
78
+ self.class.reliable_after_commit_callbacks
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class Runner
5
+ attr_reader :config, :id
6
+ private :config
7
+
8
+ def initialize(config: RailsTransactionalOutbox.configuration)
9
+ @id = SecureRandom.uuid
10
+ @config = config
11
+ logger.push_tags("RailsTransactionalOutbox::Runner #{id}") if logger.respond_to?(:push_tags)
12
+ end
13
+
14
+ def start
15
+ log("started")
16
+ instrument("rails_transactional_outbox.started")
17
+ @should_stop = false
18
+ ensure_database_connection!
19
+ loop do
20
+ if @should_stop
21
+ instrument("rails_transactional_outbox.shutting_down")
22
+ log("shutting down")
23
+ break
24
+ end
25
+ entries = process_entries
26
+ instrument("rails_transactional_outbox.heartbeat")
27
+ sleep sleep_interval_for(entries)
28
+ end
29
+ rescue => e
30
+ error_handler.capture_exception(e)
31
+ log("error: #{e} #{e.message}")
32
+ instrument("rails_transactional_outbox.error", error: e, error_message: e.message)
33
+ raise e
34
+ end
35
+
36
+ def stop
37
+ log("Rails Transactional Outbox Worker stopping")
38
+ instrument("rails_transactional_outbox.stopped")
39
+ @should_stop = true
40
+ end
41
+
42
+ private
43
+
44
+ delegate :error_handler, :transactional_outbox_worker_sleep_seconds,
45
+ :transactional_outbox_worker_idle_delay_multiplier, :database_connection_provider, :logger, to: :config
46
+ delegate :monitor, to: RailsTransactionalOutbox
47
+
48
+ def process_entries
49
+ tracer.trace("rails_transactional_outbox_entries_processor") do
50
+ outbox_entries_processor.call do |record|
51
+ if record.failed?
52
+ instrument("rails_transactional_outbox.record_processing_failed", outbox_record: record)
53
+ error("failed to process #{record.inspect}")
54
+ error_handler.capture_exception(record.error)
55
+ else
56
+ debug("processed #{record.inspect}")
57
+ instrument("rails_transactional_outbox.record_processed", outbox_record: record)
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ def ensure_database_connection!
64
+ database_connection_provider.connection.reconnect!
65
+ end
66
+
67
+ def outbox_entries_processor
68
+ @outbox_entries_processor ||= RailsTransactionalOutbox::OutboxEntriesProcessor.new
69
+ end
70
+
71
+ def log(message)
72
+ logger.info("#{log_prefix} #{message}")
73
+ end
74
+
75
+ def debug(message)
76
+ logger.debug("#{log_prefix} #{message}")
77
+ end
78
+
79
+ def error(message)
80
+ logger.error("#{log_prefix} #{message}")
81
+ end
82
+
83
+ def log_prefix
84
+ "[Rails Transactional Outbox Worker] "
85
+ end
86
+
87
+ def instrument(*args, **kwargs)
88
+ monitor.instrument(*args, **kwargs) do
89
+ yield if block_given?
90
+ end
91
+ end
92
+
93
+ def tracer
94
+ @tracer ||= if Object.const_defined?(:Datadog)
95
+ RailsTransactionalOutbox::Tracers::DatadogTracer.new
96
+ else
97
+ RailsTransactionalOutbox::Tracers::NullTracer
98
+ end
99
+ end
100
+
101
+ def sleep_interval_for(entries)
102
+ RailsTransactionalOutbox::RunnerSleepInterval.interval_for(entries, transactional_outbox_worker_sleep_seconds,
103
+ transactional_outbox_worker_idle_delay_multiplier)
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class RunnerSleepInterval
5
+ # TODO: maybe apply some backoff or longer pause if there were no entries to be processed?
6
+ def self.interval_for(processed_entries, sleep_seconds, idle_delay_multiplier)
7
+ if processed_entries.any?
8
+ sleep_seconds
9
+ else
10
+ sleep_seconds * idle_delay_multiplier
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ module Tracers
5
+ class DatadogTracer
6
+ SERVICE_NAME = "rails_transactional_outbox_worker"
7
+ private_constant :SERVICE_NAME
8
+
9
+ def self.service_name
10
+ SERVICE_NAME
11
+ end
12
+
13
+ def trace(event_name)
14
+ tracer.trace(event_name, span_type: "worker", service: self.class.service_name,
15
+ on_error: error_handler) do |_span|
16
+ yield
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def tracer
23
+ if Datadog.respond_to?(:tracer)
24
+ Datadog.tracer
25
+ else
26
+ Datadog::Tracing
27
+ end
28
+ end
29
+
30
+ def error_handler
31
+ ->(span, error) { span.set_error(error) }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ module Tracers
5
+ class NullTracer
6
+ def self.trace(_event_name); end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ module Tracers
5
+ autoload :DatadogTracer, "rails_transactional_outbox/tracers/datadog_tracer"
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ module Version
5
+ end
6
+ VERSION = "0.1.0"
7
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "logger"
5
+ require "dry-monitor"
6
+ require "sigurd"
7
+ require "concurrent-ruby"
8
+
9
+ class RailsTransactionalOutbox
10
+ def self.loader
11
+ @loader ||= Zeitwerk::Loader.for_gem.tap do |loader|
12
+ loader.ignore(
13
+ "#{__dir__}/rails-transactional-outbox.rb",
14
+ "#{__dir__}/tracers/datadog_tracer.rb"
15
+ )
16
+ end
17
+ end
18
+
19
+ def self.configuration
20
+ @configuration ||= RailsTransactionalOutbox::Configuration.new
21
+ end
22
+
23
+ def self.configure
24
+ yield configuration
25
+ end
26
+
27
+ def self.monitor
28
+ @monitor ||= RailsTransactionalOutbox::Monitor.new
29
+ end
30
+
31
+ def self.reset
32
+ @configuration = nil
33
+ end
34
+
35
+ def self.outbox_worker_health_check
36
+ @outbox_worker_health_check ||= RailsTransactionalOutbox::HealthCheck.new
37
+ end
38
+
39
+ def self.enable_outbox_worker_healthcheck
40
+ monitor.subscribe("rails_transactional_outbox.started") { outbox_worker_health_check.register_heartbeat }
41
+ monitor.subscribe("rails_transactional_outbox.stopped") { outbox_worker_health_check.worker_stopped }
42
+ monitor.subscribe("rails_transactional_outbox.heartbeat") { outbox_worker_health_check.register_heartbeat }
43
+ end
44
+
45
+ def self.start_outbox_worker(threads_number: 1)
46
+ runners = (1..threads_number).map { RailsTransactionalOutbox::Runner.new(config: configuration) }
47
+ executor = Sigurd::Executor.new(runners, sleep_seconds: 5, logger: configuration.logger)
48
+ signal_handler = Sigurd::SignalHandler.new(executor)
49
+ signal_handler.run!
50
+ end
51
+ end
52
+
53
+ RailsTransactionalOutbox.loader.setup
54
+ require "rails_transactional_outbox/railtie" if defined?(Rails)
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :rails_transactional_outbox do
4
+ desc "Starts the RailsTransactionalOutbox worker"
5
+ task worker: :environment do
6
+ $stdout.sync = true
7
+ Rails.logger.info("Running rails_transactional_outbox:worker rake task.")
8
+ threads_number = ENV.fetch("RAILS_TRANSACTIONAL_OUTBOX_THREADS_NUMBER", 1).to_i
9
+ RailsTransactionalOutbox.start_outbox_worker(threads_number: threads_number)
10
+ end
11
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rails_transactional_outbox/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rails-transactional-outbox"
7
+ spec.version = RailsTransactionalOutbox::VERSION
8
+ spec.authors = ["Karol Galanciak"]
9
+ spec.email = ["karol.galanciak@gmail.com"]
10
+
11
+ spec.summary = "An implementation of transactional outbox pattern to be used with Rails."
12
+ spec.description = "An implementation of transactional outbox pattern to be used with Rails."
13
+ spec.homepage = "https://github.com/BookingSync/rails-transactional-outbox"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
16
+
17
+ # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/BookingSync/rails-transactional-outbox"
21
+ spec.metadata["changelog_uri"] = "https://github.com/BookingSync/rails-transactional-outbox/blob/master/CHANGELOG.md"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ %x(git ls-files -z).split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
27
+ end
28
+ spec.bindir = "bin"
29
+ spec.executables = %w[rails_transactional_outbox_health_check]
30
+ spec.require_paths = ["lib"]
31
+
32
+ # Uncomment to register a new dependency of your gem
33
+ spec.add_dependency "activerecord", ">= 5"
34
+ spec.add_dependency "activesupport", ">= 3.2"
35
+ spec.add_dependency "concurrent-ruby"
36
+ spec.add_dependency "dry-monitor"
37
+ spec.add_dependency "redis"
38
+ spec.add_dependency "sigurd"
39
+ spec.add_dependency "zeitwerk"
40
+
41
+ # For more information and examples about making a new gem, checkout our
42
+ # guide at: https://bundler.io/guides/creating_gem.html
43
+ spec.metadata["rubygems_mfa_required"] = "true"
44
+ end