Skip to content

Commit 5fac810

Browse files
committed
Add a message bus for transactional outbox deliveries
A service that updates database entities, and also writes a related event to kafka can have some issues if we decide to wrap the database updates in a transaction, then some error or delay occurs when writing and event to kafka. Especially if the event is written synchronously. This can also cause lock conntention and increased resource useage on the database server at scale. * https://microservices.io/patterns/data/transactional-outbox.html * https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html One solution is to write the event stream to a dedicated database table, and have an additonal process to handle writing the events to kafka. This means that the application doesn't need to manage it's own connections to kafka, and transactions can be used in the normal way without any downsides or performance degredation. This change provides a new OutboxMessageBus that can be configured with an active record model. e.g. migration: ``` class CreateKafkaOutboxEvents < ActiveRecord::Migration[7.0] def change create_table :kafka_outbox_events do |t| t.string :topic t.string :key t.column :payload, :longblob # for avro - text would be more appropriate for JSON t.timestamps end add_index :kafka_outbox_events, :topic end end ``` config/initializers/streamy.rb: ``` require "streamy/message_buses/outbox_message_bus" class KafkaOutboxEvent < ActiveRecord::Base; end Streamy.message_bus = Streamy::MessageBuses::OutboxMessageBus.new(model: KafkaOutboxEvent) ``` This implimentation only allows for the use of a single table as the outbox. If we wanted to e.g. use a table per topic then the implimentation will need to be a bit more complex. For now, I suspect that indexing on the topic collum will be good enough, as we can run multiple consuming workers each selecting a different topic concurrently. We will only be able to use a single worker to select rows (with locking) in the consuming process where the backend is MySQL 5.7 however with an upgrade to MySQL 8+ we can make use of `SKIP LOCKED` to increase concurrency if required.
1 parent 2c66bb8 commit 5fac810

File tree

2 files changed

+123
-0
lines changed

2 files changed

+123
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
require "streamy/kafka_configuration"
2+
require "waterdrop"
3+
require "active_support/core_ext/hash/indifferent_access"
4+
require "active_support/json"
5+
6+
module Streamy
7+
module MessageBuses
8+
class OutboxMessageBus < MessageBus
9+
def initialize(config)
10+
@model = config[:model]
11+
end
12+
13+
def deliver(key:, topic:, payload:, priority:)
14+
@model.create(key: key, topic: topic, payload: payload)
15+
end
16+
17+
def deliver_many(messages)
18+
@model.create(messages.map { |message| message.except(:priority) })
19+
end
20+
end
21+
end
22+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
require "test_helper"
2+
require "waterdrop"
3+
require "streamy/message_buses/outbox_message_bus"
4+
5+
module Streamy
6+
class OutboxMessageBusTest < Minitest::Test
7+
attr_reader :bus
8+
9+
def setup
10+
@model = mock("outbox_model")
11+
@bus = MessageBuses::OutboxMessageBus.new(model: @model)
12+
end
13+
14+
def example_delivery(priority)
15+
bus.deliver(
16+
payload: payload.to_s,
17+
key: "prk-sg-001",
18+
topic: "charcuterie",
19+
priority: priority
20+
)
21+
end
22+
23+
def payload
24+
{
25+
type: "sausage",
26+
body: { meat: "pork", herbs: "sage" },
27+
event_time: "2018"
28+
}
29+
end
30+
31+
def expected_event(key: "prk-sg-001")
32+
{
33+
payload: {
34+
type: "sausage",
35+
body: {
36+
meat: "pork",
37+
herbs: "sage"
38+
},
39+
event_time: "2018"
40+
}.to_s,
41+
key: key,
42+
topic: "charcuterie"
43+
}
44+
end
45+
46+
def test_standard_priority_deliver
47+
@model.expects(:create).with(expected_event)
48+
example_delivery(:standard)
49+
end
50+
51+
def test_low_priority_deliver
52+
@model.expects(:create).with(expected_event)
53+
example_delivery(:low)
54+
end
55+
56+
def test_essential_priority_deliver
57+
@model.expects(:create).with(expected_event)
58+
example_delivery(:essential)
59+
end
60+
61+
def test_all_priority_delivery
62+
@model.expects(:create).with(expected_event)
63+
example_delivery(:essential)
64+
65+
@model.expects(:create).with(expected_event)
66+
example_delivery(:low)
67+
68+
@model.expects(:create).with(expected_event)
69+
example_delivery(:standard)
70+
end
71+
72+
def test_batch_delivery
73+
@model.expects(:create).with([
74+
expected_event(key: "prk-sg-001"),
75+
expected_event(key: "prk-sg-002"),
76+
expected_event(key: "prk-sg-003")
77+
])
78+
79+
bus.deliver_many([
80+
{
81+
payload: payload.to_s,
82+
key: "prk-sg-001",
83+
topic: "charcuterie",
84+
priority: :standard
85+
},
86+
{
87+
payload: payload.to_s,
88+
key: "prk-sg-002",
89+
topic: "charcuterie",
90+
priority: :standard
91+
},
92+
{
93+
payload: payload.to_s,
94+
key: "prk-sg-003",
95+
topic: "charcuterie",
96+
priority: :standard
97+
}
98+
])
99+
end
100+
end
101+
end

0 commit comments

Comments
 (0)