nulogy_message_bus_producer 1.0.4 → 3.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +183 -15
  3. data/Rakefile +6 -2
  4. data/db/migrate/20201005150212_rename_tenant_id_and_public.rb +6 -0
  5. data/lib/nulogy_message_bus_producer.rb +61 -25
  6. data/lib/nulogy_message_bus_producer/{base_public_subscription.rb → base_subscription.rb} +1 -1
  7. data/lib/nulogy_message_bus_producer/config.rb +72 -0
  8. data/lib/nulogy_message_bus_producer/repopulate_replication_slots.rb +23 -0
  9. data/lib/nulogy_message_bus_producer/subscriber_graphql_schema_validator.rb +1 -1
  10. data/lib/nulogy_message_bus_producer/{public_subscription.rb → subscription.rb} +4 -3
  11. data/lib/nulogy_message_bus_producer/{public_subscription_event.rb → subscription_event.rb} +1 -1
  12. data/lib/nulogy_message_bus_producer/subscriptions/postgres_transport.rb +85 -0
  13. data/lib/nulogy_message_bus_producer/subscriptions/risky_subscription_blocker.rb +58 -0
  14. data/lib/nulogy_message_bus_producer/version.rb +1 -1
  15. data/lib/tasks/engine/message_bus_producer.rake +11 -0
  16. data/spec/dummy/config/database.yml +1 -1
  17. data/spec/dummy/db/migrate/20201005164116_create_active_storage_tables.active_storage.rb +5 -0
  18. data/spec/dummy/db/schema.rb +3 -5
  19. data/spec/dummy/log/development.log +2217 -31
  20. data/spec/dummy/log/test.log +27556 -16
  21. data/spec/integration/lib/nulogy_message_bus_producer/repopulate_replication_slots_spec.rb +133 -0
  22. data/spec/integration/lib/nulogy_message_bus_producer/subscriber_graphql_schema_validator_spec.rb +49 -0
  23. data/spec/integration/lib/nulogy_message_bus_producer/subscription_spec.rb +63 -0
  24. data/spec/integration/lib/nulogy_message_bus_producer/subscriptions/postgres_transport_spec.rb +137 -0
  25. data/spec/integration/lib/nulogy_message_bus_producer/subscriptions/risky_subscription_blocker_spec.rb +51 -0
  26. data/spec/integration_spec_helper.rb +6 -0
  27. data/spec/spec_helper.rb +0 -25
  28. data/spec/support/kafka.rb +98 -0
  29. data/spec/support/kafka_connect.rb +31 -0
  30. data/spec/support/spec_utils.rb +15 -0
  31. data/spec/support/sql_helpers.rb +47 -0
  32. data/spec/support/subscription_helpers.rb +52 -0
  33. data/spec/support/test_graphql_schema.rb +47 -0
  34. metadata +88 -39
  35. data/lib/nulogy_message_bus_producer/postgres_public_subscriptions.rb +0 -102
  36. data/spec/integration/lib/graphql_api/postgres_public_subscriptions_spec.rb +0 -16
  37. data/spec/integration/lib/graphql_api/validators/subscriber_graphql_schema_validator_spec.rb +0 -76
  38. data/spec/unit/lib/graphql_api/models/public_subscription_spec.rb +0 -56
  39. data/spec/unit_spec_helper.rb +0 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1584c82f65b0be1d4b508359a88720ac4b2b1ec6a7fc82982043de1dfdfb476c
4
- data.tar.gz: 85adbd1344cebbbc4a2c160c54b94cd2227479c51432422c399d2ad99ea6107a
3
+ metadata.gz: 54bcc4198672edd1d3366678b06118df437c95267fd274f1df2969d5e029abff
4
+ data.tar.gz: 5c4a2d3dd70682348e383a0398e48944845512fcb6a68478278e76b5f5ac3153
5
5
  SHA512:
6
- metadata.gz: bdaba4ee6796bdb31e937d5e9d13504a22e57e9f9ef32d03a8bcb62a68e94c089a9fcfba65284d93312409e32cfb63b3127d23129e897b7c3b43e5c6b32900bb
7
- data.tar.gz: ccdb3e8363346ace7041cf1604a61ea15726247520de4d650eff21ee45bd8b8b2d369b239d5ab449a9d7938825204b13fbd4b304a0175b351915fc3ecf7486f4
6
+ metadata.gz: 04707ec3c4da493d52a4d817da040a88fb32dc3017cb549098e7a1d38d47a8ba24a34d707382c7391822fe640c0bd2792f540acb826da8eb4300a5e3482caaca
7
+ data.tar.gz: a27eabba49d13fabae89628f1210232b7f46ce28c41514e1fc86d3ac344af8e8c650517dd76c7141a290f625474adcf34bb1ff11209a542d49d34194c5b08bcc
data/README.md CHANGED
@@ -11,7 +11,7 @@ Nulogy's code for producing to to the Message Bus
11
11
 
12
12
  This engine contains the classes and validations for producing messages to the Message Bus.
13
13
 
14
- In general, as long as a subscription inherits from `NulogyMessageBusProducer::BasePublicSubscription`, everything
14
+ In general, as long as a subscription inherits from `NulogyMessageBusProducer::BaseSubscription`, everything
15
15
  should be set up.
16
16
 
17
17
  Subscriptions will be written to the `NulogyMessageBusProducer.subscriptions_table_name` while events generated from the subscriptions will be written to
@@ -19,34 +19,202 @@ Subscriptions will be written to the `NulogyMessageBusProducer.subscriptions_tab
19
19
 
20
20
  ## Configuration
21
21
 
22
- | Configuration | Default | Description |
23
- |--------------------------------|---------------------------------|--------------------------------------------------------------|
24
- | subscriptions_table_name | message_bus_subscriptions | The name of the table for the PublicSubscription model |
25
- | subscription_events_table_name | message_bus_subscription_events | The name of the table for the PublicSubscriptionEvents model |
22
+ | Configuration | Default | Description |
23
+ |--------------------------------|---------------------------------|---------------------------------------------------------------------------------------------|
24
+ | context_for_subscription | `->(_subscription) { {} }` | A lambda used to inject any GraphQL Context to be used when producing subscription events |
25
+ | producing_events_fails_with | :raise | How the producer should handle errors when producing subscription events |
26
+ | subscriptions_table_name | "message_bus_subscriptions" | The name of the table for the Subscription model |
27
+ | subscription_events_table_name | "message_bus_subscription_events" | The name of the table for the SubscriptionEvents model |
26
28
 
27
29
  ## Setup
28
30
 
29
31
  You should configure the gem with an initializer. A recommended setup:
30
32
 
31
- ```
33
+ ```ruby
32
34
  # Table names are configurable (optional)
33
- NulogyMessageBusProducer.subscriptions_table_name = :public_subscriptions
34
- NulogyMessageBusProducer.subscription_events_table_name = :public_subscription_events
35
+ NulogyMessageBusProducer.subscriptions_table_name = :message_bus_subscriptions
36
+ NulogyMessageBusProducer.subscription_events_table_name = :message_bus_subscription_events
35
37
 
36
38
  # Register known schemas
37
- NulogyMessageBusProducer.register_schema("example_domain", "ExampleDomain::Schema")
39
+ NulogyMessageBusProducer.configure do |config|
40
+ config.register_schema(schema: "ExampleDomain::Schema", key: "exampleDomain")
41
+
42
+ config.context_for_subscription = lambda do |_subscription|
43
+ {
44
+ current_user: Current.user,
45
+ current_company: Current.company
46
+ }
47
+ end
48
+
49
+ # Can fail with either :soft_fail or :raise
50
+ # :raise will cause the application to halt when event generation fails.
51
+ # :soft_fail will allow the application to continue, even if generating events fails.
52
+ # Be very careful when deciding on the behaviour.
53
+ # Using :raise can result in the application being unusable if a subscription cannot be fulfilled.
54
+ # Using :soft_fail can result in missing messages, and a lack of consistency between applications.
55
+ config.producing_events_fails_with(:soft_fail) do |subscription_id:, context:, variables:, result:|
56
+ # Log in Airbrake / Honeybadger
57
+ ExceptionNotifier.tagged_message(
58
+ message: "Generating an event failed!",
59
+ context: {
60
+ subscription_id: subscription_id,
61
+ context: context,
62
+ variables: variables,
63
+ result: result
64
+ }
65
+ )
66
+ end
67
+ end
38
68
 
39
69
  # It is *strongly* recommended to run this validation
40
- # It ensures the query in a subscription is valid agains the current schema
70
+ # It ensures the query in a subscription is valid against the current schema
41
71
  # If you do not run this, a change to the schema could break the app
42
72
  # Note: ensure to register schemas before running this
43
73
  NulogyMessageBusProducer.validate_existing!
44
74
  ```
45
75
 
76
+ Additionally, your GraphQL Schema will need to use `NulogyMessageBusProducer::Subscriptions::PostgresTransport`, `NulogyMessageBusProducer::Subscriptions::RiskySubscriptionBlocker` and expose subscriptions and queries for the objects to be integrated. Below is an example of how to expose `:sku_created` and `:sku_updated` events.
77
+ ```ruby
78
+ module ExampleDomainPublicApi
79
+ class Schema < GraphQL::Schema
80
+ use NulogyMessageBusProducer::Subscriptions::PostgresTransport
81
+
82
+ # You must use the Interpreter, as it will be the new default.
83
+ # See more here: https://graphql-ruby.org/queries/interpreter
84
+ use GraphQL::Analysis::AST
85
+ use GraphQL::Execution::Interpreter
86
+
87
+ # This prevents subscriptions which contain arguments or expand lists form being registered.
88
+ # Expanding unbounded lists inside of an event may severely impact the performance of the system.
89
+ # Arguments are a source of variation that may not be appropriately tested.
90
+ # For now, these are banned until we have a use-case which would require them.
91
+ query_analyzer NulogyMessageBusProducer::Subscriptions::RiskySubscriptionBlocker
92
+
93
+ query Types::QueryType
94
+ subscription Types::SubscriptionType
95
+ end
96
+ end
97
+
98
+ module ExampleDomainPublicApi
99
+ module Types
100
+ class QueryType < BaseObject
101
+ description "The query root for this schema"
102
+
103
+ field :sku, Types::SkuType, null: false do
104
+ argument :id, NulogyGraphqlApi::Types::UUID, required: true
105
+ end
106
+
107
+ def sku(id:)
108
+ Sku.find(id)
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ module ExampleDomainPublicApi
115
+ module Types
116
+ class SubscriptionType < Types::BaseObject
117
+ extend GraphQL::Subscriptions::SubscriptionRoot
118
+
119
+ description "The subscription root for this schema"
120
+
121
+ field :sku_created,
122
+ subscription: Subscriptions::SkuCreated,
123
+ description: "Event from creating a SKU. Tenancy is company.uuid"
124
+
125
+ field :sku_updated,
126
+ subscription: Subscriptions::SkuUpdated,
127
+ description: "Event from updating a SKU. Tenancy is company.uuid"
128
+ end
129
+ end
130
+ end
131
+
132
+ module ExampleDomainPublicApi
133
+ module Subscriptions
134
+ class SkuCreated < NulogyMessageBusProducer::BaseSubscription
135
+ field :sku, Types::SkuType, null: false
136
+ end
137
+ end
138
+ end
139
+
140
+ module ExampleDomainPublicApi
141
+ module Subscriptions
142
+ class SkuUpdated < NulogyMessageBusProducer::BaseSubscription
143
+ field :sku, Types::SkuType, null: false
144
+ end
145
+ end
146
+ end
147
+
148
+ module ExampleDomainPublicApi
149
+ module Types
150
+ class SkuType < Types::BaseType
151
+ field :code, GraphQL::Types::String, null: false
152
+
153
+ field :description, GraphQL::Types::String, null: true
154
+ field :setup_time, GraphQL::Types::Float, null: true
155
+ field :teardown_time, GraphQL::Types::Float, null: true
156
+ end
157
+ end
158
+ end
159
+ ```
160
+
161
+ Events would need to be triggered. Ideally, the underlying domain would be emitting domain events and the events would then be routed in the application layer to the GraphQL API. Where the domain is enemic, this could be done soley in the application layer. In either case, it needs to be invoked in the same transaction as the underlying service.
162
+ ```ruby
163
+ class CreateSku
164
+ def initialize(company)
165
+ @company = company
166
+ end
167
+
168
+ def create(attrs)
169
+ sku = Sku.new(attrs.merge(company: company))
170
+
171
+ if sku.save
172
+ # This needs to be invoked within the same transaction as Sku.save
173
+ # Otherwise, the sku could be saved without the event being triggered
174
+ trigger_subscription(:sku_created, sku)
175
+ end
176
+ end
177
+
178
+ private
179
+
180
+ def trigger_subscription(event, entity)
181
+ object = {
182
+ uuid: entity.id,
183
+ company_uuid: entity.company_id,
184
+ context: {}
185
+ }
186
+
187
+ NulogyMessageBusProducer.trigger_event(ExampleDomainPublicApi::Schema, event, object)
188
+ end
189
+ end
190
+ ```
191
+
192
+ ## Resending All Events to the Message Bus
193
+
194
+ There is a rake task for resending all events that are currently in the producer's event tables to the message bus.
195
+ It works by locking the table, deleting all of the events and re-inserting them. This ensures that order is preserved
196
+ for both existing and newly created events. Unfortunately, the lock will halt most activity on the application server.
197
+
198
+ ```shell script
199
+ # WARNING: THIS WILL LOCK THE EVENTS TABLE, AND BY EXTENSION ANY DOMAIN LOGIC WHICH EMITS EVENTS.
200
+ # THIS IS INTENDED TO BE USED FOR DISASTER RECOVERY ONLY.
201
+
202
+ rails message_bus_producer:repopulate_replication_slots
203
+ ```
204
+
46
205
  ## Rake Tasks
47
206
 
48
- | Rake command | Description |
49
- |--------------|----------------|
50
- | (default) | RSpec +RuboCop |
51
- | `spec` | RSpec |
52
- | `rubocop` | RuboCop |
207
+ | Rake command | Description |
208
+ |--------------|-----------------|
209
+ | (default) | RSpec + RuboCop |
210
+ | `spec` | RSpec |
211
+ | `rubocop` | RuboCop |
212
+
213
+ # Gem Development Setup
214
+
215
+ If you are doing work on this gem you will want to:
216
+ ```shell script
217
+ docker-compose up
218
+ rake db:setup
219
+ rake
220
+ ```
data/Rakefile CHANGED
@@ -8,8 +8,6 @@ end
8
8
  APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
9
9
  load "rails/tasks/engine.rake"
10
10
 
11
- Bundler::GemHelper.install_tasks
12
-
13
11
  require "rspec/core"
14
12
  require "rspec/core/rake_task"
15
13
 
@@ -20,3 +18,9 @@ require "rubocop/rake_task"
20
18
  RuboCop::RakeTask.new
21
19
 
22
20
  task default: [:spec, :rubocop]
21
+
22
+ require "rake/release"
23
+
24
+ Rake::Release::Task.load_all do |spec|
25
+ spec.version_tag = "nulogy_message_bus_producer-v#{spec.version}"
26
+ end
@@ -0,0 +1,6 @@
1
+ class RenameTenantIdAndPublic < ActiveRecord::Migration[5.2]
2
+ def change
3
+ rename_column NulogyMessageBusProducer.subscription_events_table_name, :tenant_id, :company_uuid
4
+ rename_column NulogyMessageBusProducer.subscription_events_table_name, :public_subscription_id, :subscription_id
5
+ end
6
+ end
@@ -1,51 +1,82 @@
1
1
  require "graphql"
2
- require "strong_migrations"
3
2
 
3
+ require "nulogy_message_bus_producer/config"
4
4
  require "nulogy_message_bus_producer/version"
5
5
  require "nulogy_message_bus_producer/engine"
6
6
 
7
7
  # The gem and its configuration
8
8
  module NulogyMessageBusProducer
9
- mattr_reader :registered_schemas, default: {}
9
+ mattr_accessor :config, default: Config.new
10
+
11
+ def self.configure(options = {})
12
+ config.update(options) if options.present?
13
+ yield(config) if block_given?
14
+ end
10
15
 
11
16
  def self.subscriptions_table_name=(table_name)
12
- NulogyMessageBusProducer::PublicSubscription.table_name = table_name
17
+ NulogyMessageBusProducer::Subscription.table_name = table_name
13
18
  end
14
19
 
15
20
  def self.subscriptions_table_name
16
- NulogyMessageBusProducer::PublicSubscription.table_name
21
+ NulogyMessageBusProducer::Subscription.table_name
17
22
  end
18
23
 
19
24
  def self.subscription_events_table_name=(table_name)
20
- NulogyMessageBusProducer::PublicSubscriptionEvent.table_name = table_name
25
+ NulogyMessageBusProducer::SubscriptionEvent.table_name = table_name
21
26
  end
22
27
 
23
28
  def self.subscription_events_table_name
24
- NulogyMessageBusProducer::PublicSubscriptionEvent.table_name
29
+ NulogyMessageBusProducer::SubscriptionEvent.table_name
25
30
  end
26
31
 
27
- def self.register_schema(schema_key, schema_name)
28
- registered_schemas[schema_key] = schema_name
32
+ def self.context_for_subscription(subscription)
33
+ config.context_for_subscription(subscription)
34
+ end
35
+
36
+ def self.trigger_event(schema, event_type, root_object)
37
+ schema_key = NulogyMessageBusProducer.resolve_schema_key(schema)
38
+
39
+ subscriptions = Subscription.where(
40
+ event_type: event_type,
41
+ schema_key: schema_key
42
+ )
43
+
44
+ subscriptions.each do |subscription|
45
+ args = {
46
+ subscriptionId: subscription.id,
47
+ subscriptionGroupId: subscription.subscription_group_id,
48
+ topicName: subscription.topic_name
49
+ }
50
+ schema.subscriptions.trigger(event_type, args, root_object)
51
+ end
29
52
  end
30
53
 
31
54
  def self.resolve_schema(schema_key)
32
- registered_schemas.fetch(schema_key) do
33
- raise KeyError, <<~MESSAGE unless block_given?
55
+ if config.registered_schemas.key?(schema_key)
56
+ config.registered_schemas[schema_key].constantize
57
+ elsif block_given?
58
+ yield
59
+ else
60
+ raise KeyError, <<~MESSAGE
34
61
  The schema registry did not contain an entry for the schema key '#{schema_key}'.
35
62
 
36
63
  Please register the schema first with `NulogyMessageBusProducer.schemas.register(schema_key, schema)`
37
- MESSAGE
38
64
 
39
- yield
40
- end.constantize
65
+ Schemas Registered:
66
+ #{config.registered_schemas.pretty_inspect}
67
+ MESSAGE
68
+ end
41
69
  end
42
70
 
43
71
  def self.resolve_schema_key(schema)
44
- registered_schemas.invert.fetch(schema.class.name) do
72
+ config.registered_schemas.invert.fetch(schema.name) do
45
73
  raise KeyError, <<~MESSAGE
46
- The schema registry did not contain an entry for the schema '#{schema.class.name}'.
74
+ The schema registry did not contain an entry for the schema '#{schema.name}'.
47
75
 
48
76
  Please register the schema first with `NulogyMessageBusProducer.schemas.register(schema_key, schema)`
77
+
78
+ Schemas Registered:
79
+ #{config.registered_schemas.pretty_inspect}
49
80
  MESSAGE
50
81
  end
51
82
  end
@@ -58,9 +89,9 @@ module NulogyMessageBusProducer
58
89
  return if validator.validate
59
90
 
60
91
  raise <<~MESSAGE
61
- #{'#' * 80}
92
+ #{"#" * 80}
62
93
 
63
- The GraphQL queries stored in public_subscriptions.query must always remain valid against the current schema.
94
+ The GraphQL queries stored in subscriptions.query must always remain valid against the current schema.
64
95
  If one is not valid, a domain event firing could fail and users would not be able to perform critical duties
65
96
  (e.g. adding production).
66
97
 
@@ -75,7 +106,7 @@ module NulogyMessageBusProducer
75
106
 
76
107
  #{validator.errors.join("\n")}
77
108
 
78
- #{'#' * 80}
109
+ #{"#" * 80}
79
110
  MESSAGE
80
111
  end
81
112
 
@@ -94,9 +125,12 @@ module NulogyMessageBusProducer
94
125
  def subscriptions_exist?
95
126
  ActiveRecord::Base
96
127
  .connection
97
- .exec_query(
98
- "SELECT tablename FROM pg_tables WHERE tablename = '#{NulogyMessageBusProducer.subscriptions_table_name}' AND schemaname = 'public';"
99
- ).present?
128
+ .exec_query(<<~SQL).present?
129
+ SELECT tablename
130
+ FROM pg_tables
131
+ WHERE tablename = '#{NulogyMessageBusProducer.subscriptions_table_name}'
132
+ AND schemaname = 'public';
133
+ SQL
100
134
  rescue # rubocop:disable Style/RescueStandardError
101
135
  false
102
136
  end
@@ -105,8 +139,10 @@ module NulogyMessageBusProducer
105
139
  end
106
140
 
107
141
  require "nulogy_message_bus_producer/application_record"
108
- require "nulogy_message_bus_producer/base_public_subscription"
109
- require "nulogy_message_bus_producer/postgres_public_subscriptions"
110
- require "nulogy_message_bus_producer/public_subscription"
111
- require "nulogy_message_bus_producer/public_subscription_event"
142
+ require "nulogy_message_bus_producer/base_subscription"
143
+ require "nulogy_message_bus_producer/subscription"
144
+ require "nulogy_message_bus_producer/subscription_event"
145
+ require "nulogy_message_bus_producer/repopulate_replication_slots"
112
146
  require "nulogy_message_bus_producer/subscriber_graphql_schema_validator"
147
+ require "nulogy_message_bus_producer/subscriptions/postgres_transport"
148
+ require "nulogy_message_bus_producer/subscriptions/risky_subscription_blocker"
@@ -5,7 +5,7 @@ module NulogyMessageBusProducer
5
5
  # class AggregateRootCreated < NulogyMessageBusProducer::BasePublicSubscription
6
6
  # field :aggregate_root, Domain::Public::Types::AggregateRootType, null: true
7
7
  # end
8
- class BasePublicSubscription < GraphQL::Schema::Subscription
8
+ class BaseSubscription < GraphQL::Schema::Subscription
9
9
  argument :subscription_id, ID, required: true
10
10
  argument :subscription_group_id, ID, required: true
11
11
  argument :topic_name, String, required: true
@@ -0,0 +1,72 @@
1
+ module NulogyMessageBusProducer
2
+ # Configuration for the gem
3
+ # This is a private class, so do not use it directly. Use the methods on NulogyMessageBusProducer.
4
+ class Config
5
+ FAILURE_MODES = [:raise, :soft_fail].freeze
6
+
7
+ attr_reader :registered_schemas
8
+ attr_writer :context_for_subscription
9
+
10
+ def initialize(options = {})
11
+ @registered_schemas = {}
12
+ producing_events_fails_with(:raise)
13
+
14
+ update(options)
15
+ end
16
+
17
+ def context_for_subscription(subscription)
18
+ @context_for_subscription ||= ->(_) {}
19
+ @context_for_subscription.call(subscription)
20
+ end
21
+
22
+ def register_schema(schema:, key:)
23
+ @registered_schemas[key] = schema
24
+ end
25
+
26
+ def producing_events_fails_with(mode, &block)
27
+ @event_error_handler =
28
+ case mode
29
+ when :raise
30
+ method(:raise_handler)
31
+ when :soft_fail
32
+ block || ->(_) {}
33
+ else
34
+ unknown_mode_error(mode)
35
+ end
36
+ end
37
+
38
+ def handle_event_generation_error(subscription_id:, context:, variables:, result:)
39
+ @event_error_handler.call(
40
+ subscription_id: subscription_id,
41
+ context: context,
42
+ variables: variables,
43
+ result: result
44
+ )
45
+ end
46
+
47
+ def update(options = {})
48
+ options.each { |key, value| public_send("#{key}=", value) }
49
+ end
50
+
51
+ private
52
+
53
+ def raise_handler(subscription_id:, context:, variables:, result:)
54
+ raise StandardError, <<~MESSAGE
55
+ A subscription event could not be produced for subscription #{subscription_id}
56
+
57
+ The GraphQL engine returned this response:
58
+
59
+ #{result.to_h}
60
+
61
+ context: #{context.inspect}
62
+ variables: #{variables.inspect}
63
+ MESSAGE
64
+ end
65
+
66
+ def unknown_mode_error(mode)
67
+ options = FAILURE_MODES.map(&:inspect).join(", ")
68
+
69
+ raise ArgumentError, "The failure mode `#{mode}` is not valid. The modes are: #{options}."
70
+ end
71
+ end
72
+ end