rails-transactional-outbox 0.1.0

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