nulogy_message_bus_consumer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c30491ceed8cd0f6640c89cc9d9cdf285bdfd92db7b2f1539db46b1732833901
4
+ data.tar.gz: 3ecec8b99782d45a3b1654a82ecfb8f86162d315531a01d5be9264282f80c2fd
5
+ SHA512:
6
+ metadata.gz: 4375656d3a099245716be814300f55f63758bcbf8d9a44fc4f7f13e413e828e1918a19001ee3b87252a383e1405bf8f31a91990ebe917da0b38b8b185fc245a5
7
+ data.tar.gz: 24e4599d94648a59e415a1ed984ffceda98265ad244a374e4754ad4ee659f07ed17a75b681c7b82527a559ae3055e70fc1d1bb50d2212b0ca31dd0ede14b74a9
@@ -0,0 +1,22 @@
1
+ begin
2
+ require "bundler/setup"
3
+ rescue LoadError
4
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
5
+ end
6
+
7
+ require "rdoc/task"
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = "rdoc"
11
+ rdoc.title = "MessageBus"
12
+ rdoc.options << "--line-numbers"
13
+ rdoc.rdoc_files.include("README.md")
14
+ rdoc.rdoc_files.include("lib/**/*.rb")
15
+ end
16
+
17
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
18
+ load "rails/tasks/engine.rake"
19
+
20
+ load "rails/tasks/statistics.rake"
21
+
22
+ require "bundler/gem_tasks"
@@ -0,0 +1,2 @@
1
+ NulogyMessageBusConsumer::Engine.routes.draw do
2
+ end
@@ -0,0 +1,7 @@
1
+ class CreateMessageBusProcessedMessages < ActiveRecord::Migration[6.0]
2
+ def change
3
+ create_table :message_bus_processed_messages, id: :uuid, default: nil do |t|
4
+ t.datetime :created_at, null: false
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,52 @@
1
+ require "nulogy_message_bus_consumer/engine"
2
+ require "nulogy_message_bus_consumer/config"
3
+ require "nulogy_message_bus_consumer/message"
4
+ require "nulogy_message_bus_consumer/processed_message"
5
+ require "nulogy_message_bus_consumer/steps/connect_to_message_bus"
6
+ require "nulogy_message_bus_consumer/steps/deduplicate_messages"
7
+ require "nulogy_message_bus_consumer/steps/log_messages"
8
+ require "nulogy_message_bus_consumer/steps/monitor_replication_lag"
9
+ require "nulogy_message_bus_consumer/steps/result_validation"
10
+ require "nulogy_message_bus_consumer/steps/stream_individual_messages"
11
+
12
+ module NulogyMessageBusConsumer
13
+ module_function
14
+
15
+ def invoke_pipeline(*steps)
16
+ build_pipeline(*steps).call
17
+ end
18
+
19
+ def default_steps(config, logger)
20
+ [
21
+ # The first three are really system processing steps
22
+ Steps::ConnectToMessageBus.new(config, logger),
23
+ Steps::MonitorReplicationLag.new(logger),
24
+ Steps::StreamIndividualMessages.new(config, logger),
25
+ # Message processing steps start here.
26
+ Steps::LogMessages.new(logger),
27
+ Steps::DeduplicateMessages.new(logger),
28
+ Steps::ResultValidation.new,
29
+ ]
30
+ end
31
+
32
+ def build_pipeline(*steps)
33
+ last_step = ->(**_) { raise "Handlers are the end of the line. Do not use yield." }
34
+
35
+ steps.reverse.reduce(last_step) do |composed_steps, previous_step|
36
+ lambda do |**args|
37
+ invoke_next = compose_with_merged_args(args, composed_steps)
38
+ previous_step.call(**args, &invoke_next)
39
+ end
40
+ end
41
+ end
42
+
43
+ def compose_with_merged_args(context, func)
44
+ lambda do |**inner_args|
45
+ existing = context.keys & inner_args.keys
46
+
47
+ raise "Cannot override existing key(s): #{existing.join(', ')}" if existing.any?
48
+
49
+ func.call(**context.merge(inner_args))
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,11 @@
1
+ module NulogyMessageBusConsumer
2
+ class Config
3
+ attr_accessor :consumer_group_id
4
+ attr_accessor :bootstrap_servers
5
+ attr_accessor :topic_name
6
+
7
+ def initialize(options = {})
8
+ options.each { |key, value| public_send("#{key}=", value) }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ module NulogyMessageBusConsumer
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace NulogyMessageBusConsumer
4
+ end
5
+ end
@@ -0,0 +1,21 @@
1
+ module NulogyMessageBusConsumer
2
+ Message = Struct.new(
3
+ :id,
4
+ :public_subscription_id,
5
+ :tenant_id,
6
+ :event_data,
7
+ :raw_event_data,
8
+ keyword_init: true
9
+ ) do
10
+ def self.from_kafka(kafka_message)
11
+ envelope_data = JSON.parse(kafka_message.payload)
12
+
13
+ new(
14
+ id: envelope_data["id"],
15
+ public_subscription_id: envelope_data["public_subscription_id"],
16
+ tenant_id: envelope_data["tenant_id"],
17
+ event_data: envelope_data["event_json"]
18
+ )
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ module NulogyMessageBusConsumer
2
+ class ProcessedMessage < ActiveRecord::Base
3
+ self.table_name = "message_bus_processed_messages"
4
+ end
5
+ end
@@ -0,0 +1,30 @@
1
+ module NulogyMessageBusConsumer
2
+ module Steps
3
+ class ConnectToMessageBus
4
+ def initialize(config, logger)
5
+ @config = config
6
+ @logger = logger
7
+ end
8
+
9
+ def call(**_)
10
+ @logger.info("Connecting to the MessageBus")
11
+ consumer = Rdkafka::Config.new(consumer_config).consumer
12
+ @logger.info("Using consumer group id: #{@config.consumer_group_id}")
13
+
14
+ trap("TERM") { consumer.close }
15
+
16
+ yield(kafka_consumer: consumer)
17
+ end
18
+
19
+ private
20
+
21
+ def consumer_config
22
+ {
23
+ "bootstrap.servers": @config.bootstrap_servers,
24
+ "enable.auto.commit": false,
25
+ "group.id": @config.consumer_group_id,
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,49 @@
1
+ module NulogyMessageBusConsumer
2
+ module Steps
3
+ class DeduplicateMessages
4
+ def initialize(logger)
5
+ @logger = logger
6
+ end
7
+
8
+ def call(message:, **_)
9
+ return :success if duplicate_exists?(message)
10
+
11
+ result = :unknown
12
+ ProcessedMessage.transaction(joinable: false) do
13
+ result = yield
14
+
15
+ record_processed_message(message) if result == :success
16
+ end
17
+
18
+ result
19
+ end
20
+
21
+ private
22
+
23
+ def duplicate_exists?(message)
24
+ if ProcessedMessage.exists?(id: message.id)
25
+ log_duplicate(message)
26
+
27
+ return true
28
+ end
29
+
30
+ false
31
+ end
32
+
33
+ def record_processed_message(message)
34
+ ProcessedMessage.create!(id: message.id)
35
+ rescue ActiveRecord::RecordNotUnique
36
+ log_duplicate(message)
37
+
38
+ raise ActiveRecord::Rollback
39
+ end
40
+
41
+ def log_duplicate(message)
42
+ @logger.warn(JSON.dump({
43
+ event: "duplicate_message_detected",
44
+ kafka_message_id: message.id,
45
+ }))
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,28 @@
1
+ module NulogyMessageBusConsumer
2
+ module Steps
3
+ class LogMessages
4
+ def initialize(logger)
5
+ @logger = logger
6
+ end
7
+
8
+ def call(message:, **_)
9
+ @logger.info(JSON.dump({
10
+ event: "message_received",
11
+ kafka_message_id: message.id,
12
+ message: "Received #{message.id}",
13
+ }))
14
+
15
+ result = yield
16
+
17
+ @logger.info(JSON.dump({
18
+ event: "message_processed",
19
+ kafka_message_id: message.id,
20
+ message: "Processed #{message.id}",
21
+ result: result,
22
+ }))
23
+
24
+ result
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,51 @@
1
+ module NulogyMessageBusConsumer
2
+ module Steps
3
+ class MonitorReplicationLag
4
+ def initialize(logger)
5
+ @logger = logger
6
+ end
7
+
8
+ def call(kafka_consumer:, **_)
9
+ # Ensure that the process is terminated if there is a problem getting the consumption lag.
10
+ # This also ensures that the process will terminate on-boot if it cannot connect to Kafka,
11
+ # allowing the container to be terminated by ECS.
12
+ Thread.abort_on_exception = true
13
+
14
+ Thread.new do
15
+ # Delayed start. If we attempt to read consumer#committed immediately, it may fail.
16
+ # We suspect this is because the consumer#committed is called before the consumer
17
+ # has finished connecting. There appears to be a race condition.
18
+ sleep 30
19
+
20
+ loop do
21
+ lag_per_topic = kafka_consumer.lag(kafka_consumer.committed)
22
+
23
+ @logger.info(JSON.dump({
24
+ event: "consumer_lag",
25
+ topics: Calculator.add_max_lag(lag_per_topic),
26
+ }))
27
+ STDOUT.flush
28
+
29
+ sleep 60
30
+ end
31
+ end
32
+
33
+ yield
34
+ end
35
+
36
+ module Calculator
37
+ def self.add_max_lag(lag_by_topic)
38
+ lag_by_topic.each_value do |lag_by_partition|
39
+ lag_by_partition[:_max] = lag_by_partition.values.max || 0
40
+ end
41
+
42
+ lag_by_topic[:_max] = lag_by_topic
43
+ .map { |_topic, lag_by_partition| lag_by_partition[:_max] }
44
+ .max || 0
45
+
46
+ lag_by_topic
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,16 @@
1
+ module NulogyMessageBusConsumer
2
+ module Steps
3
+ class ResultValidation
4
+ def call(**_)
5
+ result = yield
6
+
7
+ return result if %i[success failure].include?(result)
8
+
9
+ raise(
10
+ StandardError,
11
+ "'#{result}' is not a valid processing outcome. Must be :success or :failure"
12
+ )
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,34 @@
1
+ module NulogyMessageBusConsumer
2
+ module Steps
3
+ class StreamIndividualMessages
4
+ def initialize(config, logger)
5
+ @config = config
6
+ @logger = logger
7
+ end
8
+
9
+ def call(kafka_consumer:, **_)
10
+ kafka_consumer.subscribe(@config.topic_name)
11
+ @logger.info "Listening for kafka messages on topic #{@config.topic_name}"
12
+
13
+ kafka_consumer.each do |kafka_message|
14
+ result = yield(
15
+ message: NulogyMessageBusConsumer::Message.from_kafka(kafka_message),
16
+ kafka_message: kafka_message
17
+ )
18
+
19
+ kafka_consumer.commit if result == :success
20
+ end
21
+ rescue StandardError => e
22
+ @logger.error(JSON.dump({
23
+ event: "message_processing_errored",
24
+ class: e.class,
25
+ message: e.message,
26
+ }))
27
+
28
+ kafka_consumer.unsubscribe
29
+
30
+ raise
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ module NulogyMessageBusConsumer
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,170 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nulogy_message_bus_consumer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nulogy
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-06-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: railties
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '5.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '5.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '='
46
+ - !ruby/object:Gem::Version
47
+ version: 6.0.3
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '='
53
+ - !ruby/object:Gem::Version
54
+ version: 6.0.3
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - '='
60
+ - !ruby/object:Gem::Version
61
+ version: 3.9.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - '='
67
+ - !ruby/object:Gem::Version
68
+ version: 3.9.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-json_expectations
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '='
74
+ - !ruby/object:Gem::Version
75
+ version: 2.2.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '='
81
+ - !ruby/object:Gem::Version
82
+ version: 2.2.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '='
88
+ - !ruby/object:Gem::Version
89
+ version: 0.81.0
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '='
95
+ - !ruby/object:Gem::Version
96
+ version: 0.81.0
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop-rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - '='
102
+ - !ruby/object:Gem::Version
103
+ version: 1.38.1
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - '='
109
+ - !ruby/object:Gem::Version
110
+ version: 1.38.1
111
+ - !ruby/object:Gem::Dependency
112
+ name: sqlite3
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - '='
116
+ - !ruby/object:Gem::Version
117
+ version: 1.4.2
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - '='
123
+ - !ruby/object:Gem::Version
124
+ version: 1.4.2
125
+ description:
126
+ email:
127
+ - tass@nulogy.com
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - Rakefile
133
+ - config/routes.rb
134
+ - db/migrate/20200509095105_create_message_bus_processed_messages.rb
135
+ - lib/nulogy_message_bus_consumer.rb
136
+ - lib/nulogy_message_bus_consumer/config.rb
137
+ - lib/nulogy_message_bus_consumer/engine.rb
138
+ - lib/nulogy_message_bus_consumer/message.rb
139
+ - lib/nulogy_message_bus_consumer/processed_message.rb
140
+ - lib/nulogy_message_bus_consumer/steps/connect_to_message_bus.rb
141
+ - lib/nulogy_message_bus_consumer/steps/deduplicate_messages.rb
142
+ - lib/nulogy_message_bus_consumer/steps/log_messages.rb
143
+ - lib/nulogy_message_bus_consumer/steps/monitor_replication_lag.rb
144
+ - lib/nulogy_message_bus_consumer/steps/result_validation.rb
145
+ - lib/nulogy_message_bus_consumer/steps/stream_individual_messages.rb
146
+ - lib/nulogy_message_bus_consumer/version.rb
147
+ homepage: https://github.com/nulogy/message-bus/tree/master/gems/nulogy_message_bus_consumer
148
+ licenses: []
149
+ metadata:
150
+ allowed_push_host: https://rubygems.org/
151
+ post_install_message:
152
+ rdoc_options: []
153
+ require_paths:
154
+ - lib
155
+ required_ruby_version: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ required_rubygems_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - ">="
163
+ - !ruby/object:Gem::Version
164
+ version: '0'
165
+ requirements: []
166
+ rubygems_version: 3.0.3
167
+ signing_key:
168
+ specification_version: 4
169
+ summary: Code for accessing the Nulogy Message Bus
170
+ test_files: []