nulogy_message_bus_consumer 1.0.0.alpha → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/lib/nulogy_message_bus_consumer.rb +16 -7
  3. data/lib/nulogy_message_bus_consumer/config.rb +7 -1
  4. data/lib/nulogy_message_bus_consumer/message.rb +11 -13
  5. data/lib/nulogy_message_bus_consumer/steps/timed_task.rb +42 -0
  6. data/lib/nulogy_message_bus_consumer/tasks/log_consumer_lag.rb +45 -0
  7. data/lib/nulogy_message_bus_consumer/tasks/prune_processed_messages.rb +37 -0
  8. data/lib/nulogy_message_bus_consumer/{steps → tasks}/supervise_consumer_lag.rb +15 -26
  9. data/lib/nulogy_message_bus_consumer/version.rb +1 -1
  10. data/spec/dummy/Rakefile +6 -0
  11. data/spec/dummy/app/assets/config/manifest.js +3 -0
  12. data/spec/dummy/app/assets/stylesheets/application.css +15 -0
  13. data/spec/dummy/app/channels/application_cable/channel.rb +4 -0
  14. data/spec/dummy/app/channels/application_cable/connection.rb +4 -0
  15. data/spec/dummy/app/controllers/application_controller.rb +2 -0
  16. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  17. data/spec/dummy/app/javascript/packs/application.js +15 -0
  18. data/spec/dummy/app/jobs/application_job.rb +7 -0
  19. data/spec/dummy/app/mailers/application_mailer.rb +4 -0
  20. data/spec/dummy/app/models/application_record.rb +3 -0
  21. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  22. data/spec/dummy/app/views/layouts/mailer.html.erb +13 -0
  23. data/spec/dummy/app/views/layouts/mailer.text.erb +1 -0
  24. data/spec/dummy/bin/rails +4 -0
  25. data/spec/dummy/bin/rake +4 -0
  26. data/spec/dummy/bin/setup +33 -0
  27. data/spec/dummy/config.ru +5 -0
  28. data/spec/dummy/config/application.rb +29 -0
  29. data/spec/dummy/config/boot.rb +5 -0
  30. data/spec/dummy/config/cable.yml +10 -0
  31. data/spec/dummy/config/credentials/message-bus-us-east-1.key +1 -0
  32. data/spec/dummy/config/credentials/message-bus-us-east-1.yml.enc +1 -0
  33. data/spec/dummy/config/database.yml +27 -0
  34. data/spec/dummy/config/environment.rb +5 -0
  35. data/spec/dummy/config/environments/development.rb +62 -0
  36. data/spec/dummy/config/environments/production.rb +112 -0
  37. data/spec/dummy/config/environments/test.rb +49 -0
  38. data/spec/dummy/config/initializers/application_controller_renderer.rb +8 -0
  39. data/spec/dummy/config/initializers/assets.rb +12 -0
  40. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  41. data/spec/dummy/config/initializers/content_security_policy.rb +28 -0
  42. data/spec/dummy/config/initializers/cookies_serializer.rb +5 -0
  43. data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  44. data/spec/dummy/config/initializers/inflections.rb +16 -0
  45. data/spec/dummy/config/initializers/message_bus_consumer.rb +5 -0
  46. data/spec/dummy/config/initializers/mime_types.rb +4 -0
  47. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  48. data/spec/dummy/config/locales/en.yml +33 -0
  49. data/spec/dummy/config/puma.rb +36 -0
  50. data/spec/dummy/config/routes.rb +3 -0
  51. data/spec/dummy/config/spring.rb +6 -0
  52. data/spec/dummy/config/storage.yml +34 -0
  53. data/spec/dummy/db/schema.rb +21 -0
  54. data/spec/dummy/log/development.log +4 -0
  55. data/spec/dummy/log/production.log +18 -0
  56. data/spec/dummy/log/test.log +7949 -0
  57. data/spec/dummy/public/404.html +67 -0
  58. data/spec/dummy/public/422.html +67 -0
  59. data/spec/dummy/public/500.html +66 -0
  60. data/spec/dummy/public/apple-touch-icon-precomposed.png +0 -0
  61. data/spec/dummy/public/apple-touch-icon.png +0 -0
  62. data/spec/dummy/public/favicon.ico +0 -0
  63. data/spec/dummy/tmp/development_secret.txt +1 -0
  64. data/spec/integration/nulogy_message_bus_consumer/auditor_spec.rb +59 -0
  65. data/spec/integration/nulogy_message_bus_consumer/kafka_utils_spec.rb +41 -0
  66. data/spec/integration/nulogy_message_bus_consumer/steps/commit_on_success_spec.rb +131 -0
  67. data/spec/integration/nulogy_message_bus_consumer/steps/connect_to_message_bus_spec.rb +53 -0
  68. data/spec/integration/nulogy_message_bus_consumer/tasks/prune_processed_messages_spec.rb +32 -0
  69. data/spec/integration/nulogy_message_bus_consumer/tasks/supervise_consumer_lag_spec.rb +33 -0
  70. data/spec/integration/test_topic_spec.rb +39 -0
  71. data/spec/spec_helper.rb +50 -0
  72. data/spec/support/kafka.rb +74 -0
  73. data/spec/support/middleware_tap.rb +12 -0
  74. data/spec/support/skip.rb +9 -0
  75. data/spec/support/test_topic.rb +48 -0
  76. data/spec/unit/nulogy_message_bus_consumer/config_spec.rb +20 -0
  77. data/spec/unit/nulogy_message_bus_consumer/lag_tracker.rb +35 -0
  78. data/spec/unit/nulogy_message_bus_consumer/message_spec.rb +84 -0
  79. data/spec/unit/nulogy_message_bus_consumer/pipeline_spec.rb +49 -0
  80. data/spec/unit/nulogy_message_bus_consumer/steps/commit_on_success_spec.rb +58 -0
  81. data/spec/unit/nulogy_message_bus_consumer/steps/deduplicate_messages_spec.rb +56 -0
  82. data/spec/unit/nulogy_message_bus_consumer/steps/log_messages_spec.rb +70 -0
  83. data/spec/unit/nulogy_message_bus_consumer/steps/stream_messages_spec.rb +35 -0
  84. data/spec/unit/nulogy_message_bus_consumer/tasks/calculator_spec.rb +67 -0
  85. data/spec/unit/nulogy_message_bus_consumer_spec.rb +30 -0
  86. metadata +167 -13
  87. data/lib/nulogy_message_bus_consumer/steps/log_consumer_lag.rb +0 -51
@@ -0,0 +1,67 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>The page you were looking for doesn't exist (404)</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <style>
7
+ .rails-default-error-page {
8
+ background-color: #EFEFEF;
9
+ color: #2E2F30;
10
+ text-align: center;
11
+ font-family: arial, sans-serif;
12
+ margin: 0;
13
+ }
14
+
15
+ .rails-default-error-page div.dialog {
16
+ width: 95%;
17
+ max-width: 33em;
18
+ margin: 4em auto 0;
19
+ }
20
+
21
+ .rails-default-error-page div.dialog > div {
22
+ border: 1px solid #CCC;
23
+ border-right-color: #999;
24
+ border-left-color: #999;
25
+ border-bottom-color: #BBB;
26
+ border-top: #B00100 solid 4px;
27
+ border-top-left-radius: 9px;
28
+ border-top-right-radius: 9px;
29
+ background-color: white;
30
+ padding: 7px 12% 0;
31
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
32
+ }
33
+
34
+ .rails-default-error-page h1 {
35
+ font-size: 100%;
36
+ color: #730E15;
37
+ line-height: 1.5em;
38
+ }
39
+
40
+ .rails-default-error-page div.dialog > p {
41
+ margin: 0 0 1em;
42
+ padding: 1em;
43
+ background-color: #F7F7F7;
44
+ border: 1px solid #CCC;
45
+ border-right-color: #999;
46
+ border-left-color: #999;
47
+ border-bottom-color: #999;
48
+ border-bottom-left-radius: 4px;
49
+ border-bottom-right-radius: 4px;
50
+ border-top-color: #DADADA;
51
+ color: #666;
52
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
53
+ }
54
+ </style>
55
+ </head>
56
+
57
+ <body class="rails-default-error-page">
58
+ <!-- This file lives in public/404.html -->
59
+ <div class="dialog">
60
+ <div>
61
+ <h1>The page you were looking for doesn't exist.</h1>
62
+ <p>You may have mistyped the address or the page may have moved.</p>
63
+ </div>
64
+ <p>If you are the application owner check the logs for more information.</p>
65
+ </div>
66
+ </body>
67
+ </html>
@@ -0,0 +1,67 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>The change you wanted was rejected (422)</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <style>
7
+ .rails-default-error-page {
8
+ background-color: #EFEFEF;
9
+ color: #2E2F30;
10
+ text-align: center;
11
+ font-family: arial, sans-serif;
12
+ margin: 0;
13
+ }
14
+
15
+ .rails-default-error-page div.dialog {
16
+ width: 95%;
17
+ max-width: 33em;
18
+ margin: 4em auto 0;
19
+ }
20
+
21
+ .rails-default-error-page div.dialog > div {
22
+ border: 1px solid #CCC;
23
+ border-right-color: #999;
24
+ border-left-color: #999;
25
+ border-bottom-color: #BBB;
26
+ border-top: #B00100 solid 4px;
27
+ border-top-left-radius: 9px;
28
+ border-top-right-radius: 9px;
29
+ background-color: white;
30
+ padding: 7px 12% 0;
31
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
32
+ }
33
+
34
+ .rails-default-error-page h1 {
35
+ font-size: 100%;
36
+ color: #730E15;
37
+ line-height: 1.5em;
38
+ }
39
+
40
+ .rails-default-error-page div.dialog > p {
41
+ margin: 0 0 1em;
42
+ padding: 1em;
43
+ background-color: #F7F7F7;
44
+ border: 1px solid #CCC;
45
+ border-right-color: #999;
46
+ border-left-color: #999;
47
+ border-bottom-color: #999;
48
+ border-bottom-left-radius: 4px;
49
+ border-bottom-right-radius: 4px;
50
+ border-top-color: #DADADA;
51
+ color: #666;
52
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
53
+ }
54
+ </style>
55
+ </head>
56
+
57
+ <body class="rails-default-error-page">
58
+ <!-- This file lives in public/422.html -->
59
+ <div class="dialog">
60
+ <div>
61
+ <h1>The change you wanted was rejected.</h1>
62
+ <p>Maybe you tried to change something you didn't have access to.</p>
63
+ </div>
64
+ <p>If you are the application owner check the logs for more information.</p>
65
+ </div>
66
+ </body>
67
+ </html>
@@ -0,0 +1,66 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>We're sorry, but something went wrong (500)</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <style>
7
+ .rails-default-error-page {
8
+ background-color: #EFEFEF;
9
+ color: #2E2F30;
10
+ text-align: center;
11
+ font-family: arial, sans-serif;
12
+ margin: 0;
13
+ }
14
+
15
+ .rails-default-error-page div.dialog {
16
+ width: 95%;
17
+ max-width: 33em;
18
+ margin: 4em auto 0;
19
+ }
20
+
21
+ .rails-default-error-page div.dialog > div {
22
+ border: 1px solid #CCC;
23
+ border-right-color: #999;
24
+ border-left-color: #999;
25
+ border-bottom-color: #BBB;
26
+ border-top: #B00100 solid 4px;
27
+ border-top-left-radius: 9px;
28
+ border-top-right-radius: 9px;
29
+ background-color: white;
30
+ padding: 7px 12% 0;
31
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
32
+ }
33
+
34
+ .rails-default-error-page h1 {
35
+ font-size: 100%;
36
+ color: #730E15;
37
+ line-height: 1.5em;
38
+ }
39
+
40
+ .rails-default-error-page div.dialog > p {
41
+ margin: 0 0 1em;
42
+ padding: 1em;
43
+ background-color: #F7F7F7;
44
+ border: 1px solid #CCC;
45
+ border-right-color: #999;
46
+ border-left-color: #999;
47
+ border-bottom-color: #999;
48
+ border-bottom-left-radius: 4px;
49
+ border-bottom-right-radius: 4px;
50
+ border-top-color: #DADADA;
51
+ color: #666;
52
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
53
+ }
54
+ </style>
55
+ </head>
56
+
57
+ <body class="rails-default-error-page">
58
+ <!-- This file lives in public/500.html -->
59
+ <div class="dialog">
60
+ <div>
61
+ <h1>We're sorry, but something went wrong.</h1>
62
+ </div>
63
+ <p>If you are the application owner check the logs for more information.</p>
64
+ </div>
65
+ </body>
66
+ </html>
File without changes
File without changes
@@ -0,0 +1 @@
1
+ f68518d3020fd6430af89b8a7e2261ff0bcc4e6325685baca3256f5f99bf51e8003353fa85ce95984de17cf3a19223bcd8d2cfa7df0fc07887ef937919493208
@@ -0,0 +1,59 @@
1
+ RSpec.describe "Auditing pipeline" do # rubocop:disable RSpec/DescribeClass
2
+ let(:topic) { TestTopic.new }
3
+ let(:config) do
4
+ NulogyMessageBusConsumer::Config.new(
5
+ consumer_group_id: random_consumer_group,
6
+ bootstrap_servers: test_bootstrap_servers,
7
+ topic_name: topic.topic_name
8
+ )
9
+ end
10
+ let(:logger) { spy }
11
+
12
+ after { topic.close }
13
+
14
+ context "when some messages have not been processed" do
15
+ it "logs the list of unprocessed messages" do
16
+ produce_message(id: uuid(1))
17
+ process_message(id: uuid(1))
18
+ produce_message(id: uuid(2))
19
+
20
+ expect(logger).to receive(:warn).with(include_json(
21
+ event: "unprocessed_message",
22
+ kafka_message: {id: uuid(2)}
23
+ ))
24
+
25
+ run_audit_pipeline
26
+ end
27
+ end
28
+
29
+ context "when all messages have been processed" do
30
+ it "does not log anything" do
31
+ produce_message(id: uuid(1))
32
+ process_message(id: uuid(1))
33
+
34
+ run_audit_pipeline
35
+
36
+ expect(logger).to have_not_received(:warn)
37
+ end
38
+ end
39
+
40
+ def run_audit_pipeline
41
+ NulogyMessageBusConsumer
42
+ .consumer_audit_pipeline(config: config, logger: logger)
43
+ .invoke
44
+ end
45
+
46
+ def produce_message(id:)
47
+ topic.produce_one_message(
48
+ payload: JSON.dump(id: id)
49
+ )
50
+ end
51
+
52
+ def process_message(id:)
53
+ NulogyMessageBusConsumer::ProcessedMessage.create!(id: id)
54
+ end
55
+
56
+ def uuid(id)
57
+ format("00000000-0000-0000-0000-%012d", id)
58
+ end
59
+ end
@@ -0,0 +1,41 @@
1
+ RSpec.describe NulogyMessageBusConsumer::KafkaUtils do
2
+ subject(:utils) { NulogyMessageBusConsumer::KafkaUtils }
3
+
4
+ let(:topic) { TestTopic.new }
5
+
6
+ after { topic.close }
7
+
8
+ describe "#seek_beginning" do
9
+ it "updates the consumer offset to the beginning of the topic" do
10
+ topic.produce_one_message(payload: "First Message")
11
+ expect(topic.consume_one_message).to have_attributes(payload: "First Message")
12
+ expect(topic.consume_one_message).to eq(nil)
13
+
14
+ utils.seek_beginning(topic.consumer)
15
+
16
+ expect(topic.consume_one_message).to have_attributes(payload: "First Message")
17
+ end
18
+ end
19
+
20
+ describe "#seek_end" do
21
+ it "updates the consumer offset to the end of the topic" do
22
+ topic.produce_one_message(payload: "First Message")
23
+
24
+ utils.seek_ending(topic.consumer)
25
+
26
+ expect(topic.consume_one_message).to eq(nil)
27
+ end
28
+ end
29
+
30
+ describe "#every_message_until_none_are_left" do
31
+ it "does not keep the connection open when there are no messages" do
32
+ topic.produce_one_message(payload: "The Only Message")
33
+
34
+ enum = utils.every_message_until_none_are_left(topic.consumer)
35
+
36
+ expect(enum).to match([
37
+ have_attributes(payload: "The Only Message")
38
+ ])
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,131 @@
1
+ RSpec.describe NulogyMessageBusConsumer::Steps::CommitOnSuccess do
2
+ let(:test_topic) { TestTopic.new }
3
+ let(:consumer) { test_topic.consumer }
4
+ let(:logger) { NulogyMessageBusConsumer::NullLogger.new }
5
+ let(:handler) { spy }
6
+ let(:pipeline) do
7
+ NulogyMessageBusConsumer::Pipeline.new([
8
+ NulogyMessageBusConsumer::Steps::ConnectToMessageBus.new(test_topic.config, logger, kafka_consumer: consumer),
9
+ NulogyMessageBusConsumer::Steps::SeekBeginningOfTopic.new,
10
+ NulogyMessageBusConsumer::Steps::StreamMessagesUntilNoneAreLeft.new(logger),
11
+ NulogyMessageBusConsumer::Steps::CommitOnSuccess.new,
12
+ handler
13
+ ])
14
+ end
15
+ let(:deduped_pipeline) do
16
+ NulogyMessageBusConsumer::Pipeline.new([
17
+ NulogyMessageBusConsumer::Steps::ConnectToMessageBus.new(test_topic.config, logger, kafka_consumer: consumer),
18
+ NulogyMessageBusConsumer::Steps::SeekBeginningOfTopic.new,
19
+ NulogyMessageBusConsumer::Steps::StreamMessagesUntilNoneAreLeft.new(logger),
20
+ NulogyMessageBusConsumer::Steps::DeduplicateMessages.new(logger),
21
+ NulogyMessageBusConsumer::Steps::CommitOnSuccess.new,
22
+ handler
23
+ ])
24
+ end
25
+
26
+ after { test_topic.close }
27
+
28
+ context "when successful" do
29
+ it "commits and processes the next message" do
30
+ expect(handler).to receive(:call).with(a_message_with(key: "test 1")).and_return(:success)
31
+ expect(handler).to receive(:call).with(a_message_with(key: "test 2")).and_return(:success)
32
+ expect(consumer).to receive(:commit).twice
33
+
34
+ test_topic.produce_one_message(key: "test 1")
35
+ test_topic.produce_one_message(key: "test 2")
36
+
37
+ pipeline.invoke
38
+ end
39
+ end
40
+
41
+ context "when failing by :failure" do
42
+ it "reprocesses the message" do
43
+ expect(handler).to receive(:call).with(a_message_with(key: "test 1")).and_return(:failure)
44
+ expect(handler).to receive(:call).with(a_message_with(key: "test 1")).and_return(:success)
45
+ expect(handler).to receive(:call).with(a_message_with(key: "test 2")).and_return(:success)
46
+ expect(consumer).to receive(:commit).twice
47
+
48
+ test_topic.produce_one_message(key: "test 1")
49
+ test_topic.produce_one_message(key: "test 2")
50
+
51
+ pipeline.invoke
52
+ pipeline.invoke
53
+ end
54
+
55
+ # This test is more illustrative of how we expect it to work.
56
+ # Specifically, testing the "auto.offset.store" setting for the consumer.
57
+ context "when a partition has a failing message" do
58
+ let(:handler) { ->(message:, **_) { message.event_data[:type] == "good" ? :success : :failure } }
59
+
60
+ it "processes messages from other partitions without committing offsets for partitions with failing messages" do
61
+ Kafka.create_topic(test_topic.topic_name)
62
+
63
+ # Produce message to a single partition. This partition will be blocked by the second message.
64
+ test_topic.produce_one_message(partition: 1, event_json: {type: "good"}) # success
65
+ test_topic.produce_one_message(partition: 1, event_json: {type: "bad"}) # failure
66
+ blocked_id = test_topic.produce_one_message(partition: 1, event_json: {type: "good"}) # blocked
67
+
68
+ consume_from_partition(1) do
69
+ deduped_pipeline.invoke
70
+ end
71
+
72
+ # produce to another partition
73
+ success_id = test_topic.produce_one_message(partition: 2, event_json: {type: "good"}) # success
74
+
75
+ consume_from_partition(2) do
76
+ deduped_pipeline.invoke
77
+ end
78
+
79
+ # try consuming from all partitions again -- it will fail on the blocked one again
80
+ deduped_pipeline.invoke
81
+
82
+ # Wait for assignment after a reconnect
83
+ NulogyMessageBusConsumer::KafkaUtils.wait_for_assignment(consumer)
84
+
85
+ lag = consumer.lag(consumer.committed)
86
+ expect(lag.dig(test_topic.topic_name, 1)).to be >= 1
87
+ expect(lag.dig(test_topic.topic_name, 2)).to be(0)
88
+ expect(NulogyMessageBusConsumer::ProcessedMessage.exists?(success_id)).to be(true)
89
+ expect(NulogyMessageBusConsumer::ProcessedMessage.exists?(blocked_id)).to be(false)
90
+ end
91
+ end
92
+ end
93
+
94
+ context "when failing by exception" do
95
+ it "reprocesses the message" do
96
+ expect(handler).to receive(:call).with(a_message_with(key: "test 1")).and_raise("intentional error")
97
+ expect(handler).to receive(:call).with(a_message_with(key: "test 1")).and_return(:success)
98
+ expect(handler).to receive(:call).with(a_message_with(key: "test 2")).and_return(:success)
99
+ expect(consumer).to receive(:commit).twice
100
+
101
+ test_topic.produce_one_message(key: "test 1")
102
+ test_topic.produce_one_message(key: "test 2")
103
+
104
+ expect {
105
+ pipeline.invoke
106
+ }.to raise_error("intentional error")
107
+
108
+ pipeline.invoke
109
+ end
110
+ end
111
+
112
+ def a_message_with(matcher)
113
+ hash_including(
114
+ message: have_attributes(matcher)
115
+ )
116
+ end
117
+
118
+ def consume_from_partition(partition_number)
119
+ original_assignment = consumer.assignment
120
+ topic_partitions = original_assignment
121
+ .to_h
122
+ .transform_values { |values| values.select { |t| t.partition == partition_number } }
123
+ new_assignment = Rdkafka::Consumer::TopicPartitionList.new(topic_partitions)
124
+
125
+ consumer.assign(new_assignment)
126
+
127
+ yield
128
+ ensure
129
+ consumer.assign(original_assignment)
130
+ end
131
+ end