nulogy_message_bus_consumer 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c30491ceed8cd0f6640c89cc9d9cdf285bdfd92db7b2f1539db46b1732833901
4
- data.tar.gz: 3ecec8b99782d45a3b1654a82ecfb8f86162d315531a01d5be9264282f80c2fd
3
+ metadata.gz: c2ef8a36c5d0ba49ef96b39045cada64669993cd7f627951199ffaee2413c0cb
4
+ data.tar.gz: 63bb5a74cc469511876d7443be41ee0fab4b43782b394e469cc8fc724f82ed3e
5
5
  SHA512:
6
- metadata.gz: 4375656d3a099245716be814300f55f63758bcbf8d9a44fc4f7f13e413e828e1918a19001ee3b87252a383e1405bf8f31a91990ebe917da0b38b8b185fc245a5
7
- data.tar.gz: 24e4599d94648a59e415a1ed984ffceda98265ad244a374e4754ad4ee659f07ed17a75b681c7b82527a559ae3055e70fc1d1bb50d2212b0ca31dd0ede14b74a9
6
+ metadata.gz: d1c99f7925a176c7d4e0637c4fb95cfe88768c76bc316352b2ac829758c79c6e765c6c051256a8b46cc9ffdb16e310ed5d46fc9aa33460ebd96073fd59509e8e
7
+ data.tar.gz: 683efad3187b13a70d88bb13ebe84e1ce44509a2f64a4324014d521e331df2d50baac5718e8758d93700eaf73e19e1251985784bace259e30fd7fc1fa3fc0438
data/Rakefile CHANGED
@@ -16,7 +16,6 @@ end
16
16
 
17
17
  APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
18
18
  load "rails/tasks/engine.rake"
19
-
20
19
  load "rails/tasks/statistics.rake"
21
20
 
22
21
  require "bundler/gem_tasks"
@@ -1,52 +1,62 @@
1
+ require "rdkafka"
2
+
1
3
  require "nulogy_message_bus_consumer/engine"
4
+
2
5
  require "nulogy_message_bus_consumer/config"
6
+ require "nulogy_message_bus_consumer/handlers/log_unprocessed_messages"
7
+ require "nulogy_message_bus_consumer/kafka_utils"
3
8
  require "nulogy_message_bus_consumer/message"
9
+ require "nulogy_message_bus_consumer/null_logger"
10
+ require "nulogy_message_bus_consumer/pipeline"
4
11
  require "nulogy_message_bus_consumer/processed_message"
12
+ require "nulogy_message_bus_consumer/steps/commit_on_success"
5
13
  require "nulogy_message_bus_consumer/steps/connect_to_message_bus"
6
14
  require "nulogy_message_bus_consumer/steps/deduplicate_messages"
7
15
  require "nulogy_message_bus_consumer/steps/log_messages"
8
16
  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"
17
+ require "nulogy_message_bus_consumer/steps/seek_beginning_of_topic"
18
+ require "nulogy_message_bus_consumer/steps/stream_messages"
19
+ require "nulogy_message_bus_consumer/steps/stream_messages_until_none_are_left"
11
20
 
12
21
  module NulogyMessageBusConsumer
13
22
  module_function
14
23
 
24
+ mattr_accessor :config
25
+ mattr_accessor :logger
26
+
27
+ def configure(options = {})
28
+ self.config ||= Config.new
29
+ config.update(options) if options.present?
30
+ yield(config) if block_given?
31
+ end
32
+
33
+ def logger
34
+ @@logger ||= NullLogger.new
35
+ end
36
+
15
37
  def invoke_pipeline(*steps)
16
- build_pipeline(*steps).call
38
+ Pipeline.new(steps).invoke
17
39
  end
18
40
 
19
- def default_steps(config, logger)
20
- [
41
+ def recommended_consumer_pipeline(config: self.config, logger: self.logger)
42
+ Pipeline.new([
21
43
  # The first three are really system processing steps
22
44
  Steps::ConnectToMessageBus.new(config, logger),
23
45
  Steps::MonitorReplicationLag.new(logger),
24
- Steps::StreamIndividualMessages.new(config, logger),
46
+ Steps::StreamMessages.new(logger),
25
47
  # Message processing steps start here.
26
48
  Steps::LogMessages.new(logger),
49
+ Steps::CommitOnSuccess.new,
27
50
  Steps::DeduplicateMessages.new(logger),
28
- Steps::ResultValidation.new,
29
- ]
51
+ ])
30
52
  end
31
53
 
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
54
+ def consumer_audit_pipeline(config: self.config, logger: self.logger)
55
+ Pipeline.new([
56
+ Steps::ConnectToMessageBus.new(config, logger),
57
+ Steps::SeekBeginningOfTopic.new,
58
+ Steps::StreamMessagesUntilNoneAreLeft.new(logger),
59
+ Handlers::LogUnprocessedMessages.new(logger)
60
+ ])
51
61
  end
52
- end
62
+ end
@@ -5,6 +5,10 @@ module NulogyMessageBusConsumer
5
5
  attr_accessor :topic_name
6
6
 
7
7
  def initialize(options = {})
8
+ update(options)
9
+ end
10
+
11
+ def update(options = {})
8
12
  options.each { |key, value| public_send("#{key}=", value) }
9
13
  end
10
14
  end
@@ -0,0 +1,17 @@
1
+ module NulogyMessageBusConsumer
2
+ module Handlers
3
+ class LogUnprocessedMessages
4
+ def initialize(logger)
5
+ @logger = logger
6
+ end
7
+
8
+ def call(message:, **_)
9
+ return if ProcessedMessage.exists?(id: message.id)
10
+ @logger.warn(JSON.dump(
11
+ event: "unprocessed_message",
12
+ kafka_message: message.to_h,
13
+ ))
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,61 @@
1
+ module NulogyMessageBusConsumer
2
+ module KafkaUtils
3
+ module_function
4
+
5
+ def wait_for_assignment(consumer)
6
+ wait_for { !consumer.assignment.empty? }
7
+ end
8
+
9
+ def wait_for_unassignment(consumer)
10
+ wait_for { consumer.assignment.empty? }
11
+ end
12
+
13
+ def wait_for(attempts: 100, interval: 0.1)
14
+ attempts.times do
15
+ break if yield
16
+ sleep interval
17
+ end
18
+ end
19
+
20
+ def every_message_until_none_are_left(consumer)
21
+ Enumerator.new do |yielder|
22
+ while message = consumer.poll(250)
23
+ yielder.yield(message)
24
+ end
25
+ end
26
+ end
27
+
28
+ def seek_beginning(consumer)
29
+ wait_for_assignment(consumer)
30
+ assigned_partitions(consumer).each do |topic_name, partition|
31
+ message = Message.new(
32
+ topic: topic_name,
33
+ partition: partition,
34
+ offset: BEGINNING_OFFSET
35
+ )
36
+ consumer.seek(message)
37
+ end
38
+ end
39
+
40
+ def seek_ending(consumer)
41
+ wait_for_assignment(consumer)
42
+ assigned_partitions(consumer).each do |topic_name, partition|
43
+ message = Message.new(
44
+ topic: topic_name,
45
+ partition: partition,
46
+ offset: END_OFFSET
47
+ )
48
+ consumer.seek(message)
49
+ end
50
+ end
51
+
52
+ def assigned_partitions(consumer)
53
+ consumer.assignment.to_h
54
+ .flat_map { |topic_name, partitions| [topic_name].product(partitions) }
55
+ .map { |topic_name, partition| [topic_name, partition.partition] }
56
+ end
57
+
58
+ BEGINNING_OFFSET = 0
59
+ END_OFFSET = -1
60
+ end
61
+ end
@@ -1,21 +1,40 @@
1
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
2
+ class Message
3
+ def initialize(attrs = {})
4
+ attrs.each { |key, value| instance_variable_set("@#{key}", value) }
5
+ end
6
+
7
+ attr_reader :event_data
8
+ attr_reader :id
9
+ attr_reader :key
10
+ attr_reader :offset
11
+ attr_reader :partition
12
+ attr_reader :public_subscription_id
13
+ attr_reader :tenant_id
14
+ attr_reader :timestamp
15
+ attr_reader :topic
16
+
10
17
  def self.from_kafka(kafka_message)
11
- envelope_data = JSON.parse(kafka_message.payload)
18
+ envelope_data = JSON.parse(kafka_message.payload, symbolize_names: true)
12
19
 
13
20
  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"]
21
+ event_data: envelope_data[:event_json],
22
+ id: envelope_data[:id],
23
+ key: kafka_message.key,
24
+ offset: kafka_message.offset,
25
+ partition: kafka_message.partition,
26
+ public_subscription_id: envelope_data[:public_subscription_id],
27
+ tenant_id: envelope_data[:tenant_id],
28
+ timestamp: kafka_message.timestamp,
29
+ topic: kafka_message.topic,
18
30
  )
19
31
  end
32
+
33
+ def to_h
34
+ instance_variable_names.each_with_object({}) do |attribute_name, hash|
35
+ attribute_name.sub!("@", "")
36
+ hash[attribute_name] = public_send(attribute_name)
37
+ end
38
+ end
20
39
  end
21
40
  end
@@ -0,0 +1,9 @@
1
+ module NulogyMessageBusConsumer
2
+ class NullLogger
3
+ def info(*_) end
4
+
5
+ def error(*_) end
6
+
7
+ def warn(*_) end
8
+ end
9
+ end
@@ -0,0 +1,41 @@
1
+ module NulogyMessageBusConsumer
2
+ class Pipeline
3
+ def initialize(steps)
4
+ @steps = steps
5
+ end
6
+
7
+ def invoke
8
+ convert_steps_into_lambda.call
9
+ end
10
+
11
+ def insert(step, after:)
12
+ index = @steps.find_index { |step| step.is_a?(after) }
13
+ @steps.insert(index + 1, step)
14
+ end
15
+
16
+ def append(step)
17
+ @steps << step
18
+ end
19
+
20
+ private
21
+
22
+ def convert_steps_into_lambda
23
+ last_step = ->(**_) { raise "Handlers are the end of the line. Do not use yield." }
24
+
25
+ @steps.reverse.reduce(last_step) do |composed_steps, previous_step|
26
+ lambda do |**args|
27
+ invoke_next = compose_with_merged_args(args, composed_steps)
28
+ previous_step.call(**args, &invoke_next)
29
+ end
30
+ end
31
+ end
32
+
33
+ def compose_with_merged_args(existing_args, func)
34
+ lambda do |**yielded_args|
35
+ args_to_be_overridden = existing_args.keys & yielded_args.keys
36
+ raise "Cannot override existing argument(s): #{args_to_be_overridden.join(', ')}" if args_to_be_overridden.any?
37
+ func.call(**existing_args.merge(yielded_args))
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,36 @@
1
+ module NulogyMessageBusConsumer
2
+ module Steps
3
+ class CommitOnSuccess
4
+ def call(kafka_consumer:, message:, **_)
5
+ result = yield
6
+
7
+ raise_if_invalid(result)
8
+
9
+ if result == :success
10
+ kafka_consumer.commit
11
+ else
12
+ reconnect_to_reprocess_same_message(kafka_consumer)
13
+ end
14
+
15
+ result
16
+ end
17
+
18
+ private
19
+
20
+ def reconnect_to_reprocess_same_message(kafka_consumer)
21
+ subscriptions = kafka_consumer.subscription
22
+ kafka_consumer.unsubscribe
23
+ kafka_consumer.subscribe(*subscriptions.to_h.keys)
24
+ end
25
+
26
+ def raise_if_invalid(result)
27
+ return if %i[success failure].include?(result)
28
+
29
+ raise(
30
+ StandardError,
31
+ "'#{result}' is not a valid processing outcome. Must be :success or :failure"
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
@@ -11,8 +11,12 @@ module NulogyMessageBusConsumer
11
11
  consumer = Rdkafka::Config.new(consumer_config).consumer
12
12
  @logger.info("Using consumer group id: #{@config.consumer_group_id}")
13
13
 
14
+ consumer.subscribe(@config.topic_name)
15
+ @logger.info("Listening for kafka messages on topic #{@config.topic_name}")
16
+
14
17
  trap("TERM") { consumer.close }
15
18
 
19
+ KafkaUtils.wait_for_assignment(consumer)
16
20
  yield(kafka_consumer: consumer)
17
21
  end
18
22
 
@@ -15,7 +15,7 @@ module NulogyMessageBusConsumer
15
15
  # Delayed start. If we attempt to read consumer#committed immediately, it may fail.
16
16
  # We suspect this is because the consumer#committed is called before the consumer
17
17
  # has finished connecting. There appears to be a race condition.
18
- sleep 30
18
+ KafkaUtils.wait_for_assignment(kafka_consumer)
19
19
 
20
20
  loop do
21
21
  lag_per_topic = kafka_consumer.lag(kafka_consumer.committed)
@@ -0,0 +1,10 @@
1
+ module NulogyMessageBusConsumer
2
+ module Steps
3
+ class SeekBeginningOfTopic
4
+ def call(kafka_consumer:, **_)
5
+ KafkaUtils.seek_beginning(kafka_consumer)
6
+ yield
7
+ end
8
+ end
9
+ end
10
+ end
@@ -1,22 +1,16 @@
1
1
  module NulogyMessageBusConsumer
2
2
  module Steps
3
- class StreamIndividualMessages
4
- def initialize(config, logger)
5
- @config = config
3
+ class StreamMessages
4
+ def initialize(logger)
6
5
  @logger = logger
7
6
  end
8
7
 
9
8
  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
9
  kafka_consumer.each do |kafka_message|
14
- result = yield(
15
- message: NulogyMessageBusConsumer::Message.from_kafka(kafka_message),
10
+ yield(
11
+ message: Message.from_kafka(kafka_message),
16
12
  kafka_message: kafka_message
17
13
  )
18
-
19
- kafka_consumer.commit if result == :success
20
14
  end
21
15
  rescue StandardError => e
22
16
  @logger.error(JSON.dump({
@@ -25,8 +19,6 @@ module NulogyMessageBusConsumer
25
19
  message: e.message,
26
20
  }))
27
21
 
28
- kafka_consumer.unsubscribe
29
-
30
22
  raise
31
23
  end
32
24
  end
@@ -0,0 +1,26 @@
1
+ module NulogyMessageBusConsumer
2
+ module Steps
3
+ class StreamMessagesUntilNoneAreLeft
4
+ def initialize(logger)
5
+ @logger = logger
6
+ end
7
+
8
+ def call(kafka_consumer:, **_)
9
+ KafkaUtils.every_message_until_none_are_left(kafka_consumer).each do |kafka_message|
10
+ yield(
11
+ message: Message.from_kafka(kafka_message),
12
+ kafka_message: kafka_message
13
+ )
14
+ end
15
+ rescue StandardError => e
16
+ @logger.error(JSON.dump({
17
+ event: "message_processing_errored",
18
+ class: e.class,
19
+ message: e.message,
20
+ }))
21
+
22
+ raise
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,3 +1,3 @@
1
1
  module NulogyMessageBusConsumer
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -0,0 +1,16 @@
1
+ namespace :message_bus_consumer do
2
+ desc "Verifies that the messages in the message bus have been processed"
3
+ task :audit => :environment do
4
+ config = build_audit_config
5
+
6
+ NulogyMessageBusConsumer
7
+ .consumer_audit_pipeline(config: config)
8
+ .invoke
9
+ end
10
+
11
+ def build_audit_config
12
+ audit_config = NulogyMessageBusConsumer.config.dup
13
+ audit_config.consumer_group_id = "#{audit_config.consumer_group_id}_consumer_audit"
14
+ NulogyMessageBusConsumer.config
15
+ end
16
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nulogy_message_bus_consumer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nulogy
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-06-05 00:00:00.000000000 Z
11
+ date: 2020-07-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '5.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rdkafka
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: rails
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +80,20 @@ dependencies:
66
80
  - - '='
67
81
  - !ruby/object:Gem::Version
68
82
  version: 3.9.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec-rails
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '='
88
+ - !ruby/object:Gem::Version
89
+ version: 4.0.1
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '='
95
+ - !ruby/object:Gem::Version
96
+ version: 4.0.1
69
97
  - !ruby/object:Gem::Dependency
70
98
  name: rspec-json_expectations
71
99
  requirement: !ruby/object:Gem::Requirement
@@ -109,20 +137,20 @@ dependencies:
109
137
  - !ruby/object:Gem::Version
110
138
  version: 1.38.1
111
139
  - !ruby/object:Gem::Dependency
112
- name: sqlite3
140
+ name: pg
113
141
  requirement: !ruby/object:Gem::Requirement
114
142
  requirements:
115
143
  - - '='
116
144
  - !ruby/object:Gem::Version
117
- version: 1.4.2
145
+ version: 1.2.3
118
146
  type: :development
119
147
  prerelease: false
120
148
  version_requirements: !ruby/object:Gem::Requirement
121
149
  requirements:
122
150
  - - '='
123
151
  - !ruby/object:Gem::Version
124
- version: 1.4.2
125
- description:
152
+ version: 1.2.3
153
+ description:
126
154
  email:
127
155
  - tass@nulogy.com
128
156
  executables: []
@@ -135,20 +163,27 @@ files:
135
163
  - lib/nulogy_message_bus_consumer.rb
136
164
  - lib/nulogy_message_bus_consumer/config.rb
137
165
  - lib/nulogy_message_bus_consumer/engine.rb
166
+ - lib/nulogy_message_bus_consumer/handlers/log_unprocessed_messages.rb
167
+ - lib/nulogy_message_bus_consumer/kafka_utils.rb
138
168
  - lib/nulogy_message_bus_consumer/message.rb
169
+ - lib/nulogy_message_bus_consumer/null_logger.rb
170
+ - lib/nulogy_message_bus_consumer/pipeline.rb
139
171
  - lib/nulogy_message_bus_consumer/processed_message.rb
172
+ - lib/nulogy_message_bus_consumer/steps/commit_on_success.rb
140
173
  - lib/nulogy_message_bus_consumer/steps/connect_to_message_bus.rb
141
174
  - lib/nulogy_message_bus_consumer/steps/deduplicate_messages.rb
142
175
  - lib/nulogy_message_bus_consumer/steps/log_messages.rb
143
176
  - 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
177
+ - lib/nulogy_message_bus_consumer/steps/seek_beginning_of_topic.rb
178
+ - lib/nulogy_message_bus_consumer/steps/stream_messages.rb
179
+ - lib/nulogy_message_bus_consumer/steps/stream_messages_until_none_are_left.rb
146
180
  - lib/nulogy_message_bus_consumer/version.rb
181
+ - lib/tasks/engine/message_bus_consumer.rake
147
182
  homepage: https://github.com/nulogy/message-bus/tree/master/gems/nulogy_message_bus_consumer
148
183
  licenses: []
149
184
  metadata:
150
185
  allowed_push_host: https://rubygems.org/
151
- post_install_message:
186
+ post_install_message:
152
187
  rdoc_options: []
153
188
  require_paths:
154
189
  - lib
@@ -164,7 +199,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
164
199
  version: '0'
165
200
  requirements: []
166
201
  rubygems_version: 3.0.3
167
- signing_key:
202
+ signing_key:
168
203
  specification_version: 4
169
204
  summary: Code for accessing the Nulogy Message Bus
170
205
  test_files: []
@@ -1,16 +0,0 @@
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