nulogy_message_bus_producer 2.0.0 → 3.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +188 -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 +47 -19
  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 +6 -0
  8. data/lib/nulogy_message_bus_producer/repopulate_replication_slots.rb +25 -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} +1 -2
  11. data/lib/nulogy_message_bus_producer/{public_subscription_event.rb → subscription_event.rb} +1 -1
  12. data/lib/nulogy_message_bus_producer/subscriptions/no_variables.rb +43 -0
  13. data/lib/nulogy_message_bus_producer/subscriptions/postgres_transport.rb +85 -0
  14. data/lib/nulogy_message_bus_producer/subscriptions/risky_subscription_blocker.rb +70 -0
  15. data/lib/nulogy_message_bus_producer/version.rb +1 -1
  16. data/lib/tasks/engine/message_bus_producer.rake +11 -0
  17. data/spec/dummy/config/database.yml +1 -1
  18. data/spec/dummy/config/puma.rb +2 -2
  19. data/spec/dummy/db/migrate/20201005164116_create_active_storage_tables.active_storage.rb +5 -0
  20. data/spec/dummy/db/schema.rb +3 -5
  21. data/spec/dummy/log/development.log +510 -0
  22. data/spec/dummy/log/test.log +18126 -0
  23. data/spec/integration/lib/nulogy_message_bus_producer/repopulate_replication_slots_spec.rb +141 -0
  24. data/spec/integration/lib/nulogy_message_bus_producer/subscriber_graphql_schema_validator_spec.rb +49 -0
  25. data/spec/integration/lib/nulogy_message_bus_producer/subscription_spec.rb +61 -0
  26. data/spec/integration/lib/nulogy_message_bus_producer/subscriptions/no_variables_spec.rb +46 -0
  27. data/spec/integration/lib/nulogy_message_bus_producer/subscriptions/postgres_transport_spec.rb +135 -0
  28. data/spec/integration/lib/nulogy_message_bus_producer/subscriptions/risky_subscription_blocker_spec.rb +49 -0
  29. data/spec/integration_spec_helper.rb +5 -0
  30. data/spec/spec_helper.rb +0 -40
  31. data/spec/support/kafka.rb +105 -0
  32. data/spec/support/kafka_connect.rb +31 -0
  33. data/spec/support/spec_utils.rb +16 -0
  34. data/spec/support/sql_helpers.rb +45 -0
  35. data/spec/support/subscription_helpers.rb +52 -0
  36. data/spec/support/test_graphql_schema.rb +48 -0
  37. metadata +89 -38
  38. data/lib/nulogy_message_bus_producer/postgres_public_subscriptions.rb +0 -117
  39. data/spec/integration/lib/graphql_api/postgres_public_subscriptions_spec.rb +0 -122
  40. data/spec/integration/lib/graphql_api/validators/subscriber_graphql_schema_validator_spec.rb +0 -76
  41. data/spec/unit/lib/graphql_api/models/public_subscription_spec.rb +0 -66
  42. data/spec/unit_spec_helper.rb +0 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 270f095b931c9560de5dc65248349f1efe633f2bfd9a6771691d861feff8b09d
4
- data.tar.gz: 0b8b4786d163434c1cdb5821544ff5f07cd9f4ee1149dd5da75099b411b939ed
3
+ metadata.gz: b81166f96e6dc18fa79dcd9a3d5fd56785e929866c9f6c2bf1571c3b7ca3965d
4
+ data.tar.gz: e0d7c148e751180d9f336146a17cb56b343d9459af5f436a452a4af4649904d3
5
5
  SHA512:
6
- metadata.gz: 05004d9c4bd4741eb554836d0e5e29fb7456ac9842c62f9627ebde63d7c7aa28d3b00a0b5fc693b182d6e9a4810b56018410f16d834c717a8bed2060bfbb87b4
7
- data.tar.gz: 41f41b4cc5c111c67ca7d23c0a4487843bca9aa024eded62293ea62eda533dc077299b1ca247ab477ee0359338d397bd18f6463f39da209b57b3548aca11e04d
6
+ metadata.gz: 9aaf43cc6e873323874a31baa272fcb00ac8b4792b9bead305510ea3f52ec236e5654c3bdcbf5630a3af1466530e434e20958d4134cf75df5cbaf8c16d8a4416
7
+ data.tar.gz: 33e75bf74472c78810a433036a9658736e9f4b6cb8d506092ffb631bd3a9092ed914be9bb5196783e83d95dcfbabb34b8d45cbdb964154121f95f5caa8f75f14
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,207 @@ 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
+ # This prevents creating subscriptions with variables.
94
+ # Since variables are not saved with the query,
95
+ # the Subscription would always fail when generating events.
96
+ query_analyzer NulogyMessageBusProducer::Subscriptions::NoVariables
97
+
98
+ query Types::QueryType
99
+ subscription Types::SubscriptionType
100
+ end
101
+ end
102
+
103
+ module ExampleDomainPublicApi
104
+ module Types
105
+ class QueryType < BaseObject
106
+ description "The query root for this schema"
107
+
108
+ field :sku, Types::SkuType, null: false do
109
+ argument :id, NulogyGraphqlApi::Types::UUID, required: true
110
+ end
111
+
112
+ def sku(id:)
113
+ Sku.find(id)
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ module ExampleDomainPublicApi
120
+ module Types
121
+ class SubscriptionType < Types::BaseObject
122
+ extend GraphQL::Subscriptions::SubscriptionRoot
123
+
124
+ description "The subscription root for this schema"
125
+
126
+ field :sku_created,
127
+ subscription: Subscriptions::SkuCreated,
128
+ description: "Event from creating a SKU. Tenancy is company.uuid"
129
+
130
+ field :sku_updated,
131
+ subscription: Subscriptions::SkuUpdated,
132
+ description: "Event from updating a SKU. Tenancy is company.uuid"
133
+ end
134
+ end
135
+ end
136
+
137
+ module ExampleDomainPublicApi
138
+ module Subscriptions
139
+ class SkuCreated < NulogyMessageBusProducer::BaseSubscription
140
+ field :sku, Types::SkuType, null: false
141
+ end
142
+ end
143
+ end
144
+
145
+ module ExampleDomainPublicApi
146
+ module Subscriptions
147
+ class SkuUpdated < NulogyMessageBusProducer::BaseSubscription
148
+ field :sku, Types::SkuType, null: false
149
+ end
150
+ end
151
+ end
152
+
153
+ module ExampleDomainPublicApi
154
+ module Types
155
+ class SkuType < Types::BaseType
156
+ field :code, GraphQL::Types::String, null: false
157
+
158
+ field :description, GraphQL::Types::String, null: true
159
+ field :setup_time, GraphQL::Types::Float, null: true
160
+ field :teardown_time, GraphQL::Types::Float, null: true
161
+ end
162
+ end
163
+ end
164
+ ```
165
+
166
+ 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.
167
+ ```ruby
168
+ class CreateSku
169
+ def initialize(company)
170
+ @company = company
171
+ end
172
+
173
+ def create(attrs)
174
+ sku = Sku.new(attrs.merge(company: company))
175
+
176
+ if sku.save
177
+ # This needs to be invoked within the same transaction as Sku.save
178
+ # Otherwise, the sku could be saved without the event being triggered
179
+ trigger_subscription("skuCreated", sku)
180
+ end
181
+ end
182
+
183
+ private
184
+
185
+ def trigger_subscription(event, entity)
186
+ object = {
187
+ uuid: entity.id,
188
+ company_uuid: entity.company_id,
189
+ context: {}
190
+ }
191
+
192
+ NulogyMessageBusProducer.trigger_event(ExampleDomainPublicApi::Schema, event, object)
193
+ end
194
+ end
195
+ ```
196
+
197
+ ## Resending All Events to the Message Bus
198
+
199
+ There is a rake task for resending all events that are currently in the producer's event tables to the message bus.
200
+ It works by locking the table, deleting all of the events and re-inserting them. This ensures that order is preserved
201
+ for both existing and newly created events. Unfortunately, the lock will halt most activity on the application server.
202
+
203
+ ```shell script
204
+ # WARNING: THIS WILL LOCK THE EVENTS TABLE, AND BY EXTENSION ANY DOMAIN LOGIC WHICH EMITS EVENTS.
205
+ # THIS IS INTENDED TO BE USED FOR DISASTER RECOVERY ONLY.
206
+
207
+ rails message_bus_producer:repopulate_replication_slots
208
+ ```
209
+
46
210
  ## Rake Tasks
47
211
 
48
- | Rake command | Description |
49
- |--------------|----------------|
50
- | (default) | RSpec +RuboCop |
51
- | `spec` | RSpec |
52
- | `rubocop` | RuboCop |
212
+ | Rake command | Description |
213
+ |--------------|-----------------|
214
+ | (default) | RSpec + RuboCop |
215
+ | `spec` | RSpec |
216
+ | `rubocop` | RuboCop |
217
+
218
+ # Gem Development Setup
219
+
220
+ If you are doing work on this gem you will want to:
221
+ ```shell script
222
+ docker-compose up
223
+ rake db:setup
224
+ rake
225
+ ```
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,5 +1,4 @@
1
1
  require "graphql"
2
- require "strong_migrations"
3
2
 
4
3
  require "nulogy_message_bus_producer/config"
5
4
  require "nulogy_message_bus_producer/version"
@@ -15,39 +14,65 @@ module NulogyMessageBusProducer
15
14
  end
16
15
 
17
16
  def self.subscriptions_table_name=(table_name)
18
- NulogyMessageBusProducer::PublicSubscription.table_name = table_name
17
+ NulogyMessageBusProducer::Subscription.table_name = table_name
19
18
  end
20
19
 
21
20
  def self.subscriptions_table_name
22
- NulogyMessageBusProducer::PublicSubscription.table_name
21
+ NulogyMessageBusProducer::Subscription.table_name
23
22
  end
24
23
 
25
24
  def self.subscription_events_table_name=(table_name)
26
- NulogyMessageBusProducer::PublicSubscriptionEvent.table_name = table_name
25
+ NulogyMessageBusProducer::SubscriptionEvent.table_name = table_name
27
26
  end
28
27
 
29
28
  def self.subscription_events_table_name
30
- NulogyMessageBusProducer::PublicSubscriptionEvent.table_name
29
+ NulogyMessageBusProducer::SubscriptionEvent.table_name
31
30
  end
32
31
 
33
- def self.resolve_schema(schema_key)
34
- config.registered_schemas.fetch(schema_key) do
35
- raise KeyError, <<~MESSAGE unless block_given?
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
+ subscriptions = Subscription.where(event_type: event_type, schema_key: schema_key)
39
+
40
+ subscriptions.each do |subscription|
41
+ args = {
42
+ subscriptionId: subscription.id,
43
+ subscriptionGroupId: subscription.subscription_group_id,
44
+ topicName: subscription.topic_name
45
+ }
46
+ schema.subscriptions.trigger(event_type, args, root_object)
47
+ end
48
+ end
49
+
50
+ def self.resolve_schema(schema_key) # rubocop:disable Metrics/MethodLength
51
+ if config.registered_schemas.key?(schema_key)
52
+ config.registered_schemas[schema_key].constantize
53
+ elsif block_given?
54
+ yield
55
+ else
56
+ raise KeyError, <<~MESSAGE
36
57
  The schema registry did not contain an entry for the schema key '#{schema_key}'.
37
58
 
38
59
  Please register the schema first with `NulogyMessageBusProducer.schemas.register(schema_key, schema)`
39
- MESSAGE
40
60
 
41
- yield
42
- end.constantize
61
+ Schemas Registered:
62
+ #{config.registered_schemas.pretty_inspect}
63
+ MESSAGE
64
+ end
43
65
  end
44
66
 
45
67
  def self.resolve_schema_key(schema)
46
- config.registered_schemas.invert.fetch(schema.class.name) do
68
+ config.registered_schemas.invert.fetch(schema.name) do
47
69
  raise KeyError, <<~MESSAGE
48
- The schema registry did not contain an entry for the schema '#{schema.class.name}'.
70
+ The schema registry did not contain an entry for the schema '#{schema.name}'.
49
71
 
50
72
  Please register the schema first with `NulogyMessageBusProducer.schemas.register(schema_key, schema)`
73
+
74
+ Schemas Registered:
75
+ #{config.registered_schemas.pretty_inspect}
51
76
  MESSAGE
52
77
  end
53
78
  end
@@ -62,7 +87,7 @@ module NulogyMessageBusProducer
62
87
  raise <<~MESSAGE
63
88
  #{"#" * 80}
64
89
 
65
- The GraphQL queries stored in public_subscriptions.query must always remain valid against the current schema.
90
+ The GraphQL queries stored in subscriptions.query must always remain valid against the current schema.
66
91
  If one is not valid, a domain event firing could fail and users would not be able to perform critical duties
67
92
  (e.g. adding production).
68
93
 
@@ -101,7 +126,7 @@ module NulogyMessageBusProducer
101
126
  FROM pg_tables
102
127
  WHERE tablename = '#{NulogyMessageBusProducer.subscriptions_table_name}'
103
128
  AND schemaname = 'public';
104
- SQL
129
+ SQL
105
130
  rescue # rubocop:disable Style/RescueStandardError
106
131
  false
107
132
  end
@@ -110,8 +135,11 @@ module NulogyMessageBusProducer
110
135
  end
111
136
 
112
137
  require "nulogy_message_bus_producer/application_record"
113
- require "nulogy_message_bus_producer/base_public_subscription"
114
- require "nulogy_message_bus_producer/postgres_public_subscriptions"
115
- require "nulogy_message_bus_producer/public_subscription"
116
- require "nulogy_message_bus_producer/public_subscription_event"
138
+ require "nulogy_message_bus_producer/base_subscription"
139
+ require "nulogy_message_bus_producer/subscription"
140
+ require "nulogy_message_bus_producer/subscription_event"
141
+ require "nulogy_message_bus_producer/repopulate_replication_slots"
117
142
  require "nulogy_message_bus_producer/subscriber_graphql_schema_validator"
143
+ require "nulogy_message_bus_producer/subscriptions/postgres_transport"
144
+ require "nulogy_message_bus_producer/subscriptions/no_variables"
145
+ 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
@@ -5,6 +5,7 @@ module NulogyMessageBusProducer
5
5
  FAILURE_MODES = [:raise, :soft_fail].freeze
6
6
 
7
7
  attr_reader :registered_schemas
8
+ attr_writer :context_for_subscription
8
9
 
9
10
  def initialize(options = {})
10
11
  @registered_schemas = {}
@@ -13,6 +14,11 @@ module NulogyMessageBusProducer
13
14
  update(options)
14
15
  end
15
16
 
17
+ def context_for_subscription(subscription)
18
+ @context_for_subscription ||= ->(_) {}
19
+ @context_for_subscription.call(subscription)
20
+ end
21
+
16
22
  def register_schema(schema:, key:)
17
23
  @registered_schemas[key] = schema
18
24
  end
@@ -0,0 +1,25 @@
1
+ module NulogyMessageBusProducer
2
+ # This modules repopulates the replication slot (what debezium uses to populate Kafka)
3
+ # by re-generating the subscription events
4
+ module RepopulateReplicationSlots
5
+ def self.repopulate
6
+ table_name = NulogyMessageBusProducer::SubscriptionEvent.table_name
7
+ temp_table_name = "#{NulogyMessageBusProducer::SubscriptionEvent.table_name}_tmp_event_repopulation"
8
+
9
+ ActiveRecord::Base.connection.execute(<<~SQL)
10
+ BEGIN;
11
+ LOCK TABLE #{table_name} IN SHARE MODE;
12
+
13
+ DROP TABLE IF EXISTS #{temp_table_name};
14
+ CREATE TEMPORARY TABLE #{temp_table_name} AS
15
+ SELECT * FROM #{table_name} ORDER BY created_at ASC;
16
+
17
+ TRUNCATE #{table_name};
18
+ INSERT INTO #{table_name}
19
+ (SELECT * FROM #{temp_table_name} ORDER BY created_at ASC);
20
+
21
+ COMMIT;
22
+ SQL
23
+ end
24
+ end
25
+ end
@@ -13,7 +13,7 @@ module NulogyMessageBusProducer
13
13
  @errors = []
14
14
  end
15
15
 
16
- def validate(subscription_or_subscriptions = NulogyMessageBusProducer::PublicSubscription.all)
16
+ def validate(subscription_or_subscriptions = NulogyMessageBusProducer::Subscription.all)
17
17
  Array(subscription_or_subscriptions).each do |subscription|
18
18
  schema = find_schema(subscription)
19
19
  next unless schema