nulogy_message_bus_consumer 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.
- checksums.yaml +7 -0
- data/Rakefile +22 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20200509095105_create_message_bus_processed_messages.rb +7 -0
- data/lib/nulogy_message_bus_consumer.rb +52 -0
- data/lib/nulogy_message_bus_consumer/config.rb +11 -0
- data/lib/nulogy_message_bus_consumer/engine.rb +5 -0
- data/lib/nulogy_message_bus_consumer/message.rb +21 -0
- data/lib/nulogy_message_bus_consumer/processed_message.rb +5 -0
- data/lib/nulogy_message_bus_consumer/steps/connect_to_message_bus.rb +30 -0
- data/lib/nulogy_message_bus_consumer/steps/deduplicate_messages.rb +49 -0
- data/lib/nulogy_message_bus_consumer/steps/log_messages.rb +28 -0
- data/lib/nulogy_message_bus_consumer/steps/monitor_replication_lag.rb +51 -0
- data/lib/nulogy_message_bus_consumer/steps/result_validation.rb +16 -0
- data/lib/nulogy_message_bus_consumer/steps/stream_individual_messages.rb +34 -0
- data/lib/nulogy_message_bus_consumer/version.rb +3 -0
- metadata +170 -0
checksums.yaml
ADDED
@@ -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
|
data/Rakefile
ADDED
@@ -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"
|
data/config/routes.rb
ADDED
@@ -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,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,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
|
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: []
|