nulogy_message_bus_consumer 0.1.0 → 0.2.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 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