transactional_outbox 1.0.0beta1

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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +19 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE +21 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +43 -0
  9. data/Rakefile +12 -0
  10. data/lib/generators/transactional_outbox/migration/migration_generator.rb +24 -0
  11. data/lib/generators/transactional_outbox/migration/templates/active_record.rb.erb +18 -0
  12. data/lib/generators/transactional_outbox/migration/templates/sequel.rb.erb +26 -0
  13. data/lib/transactional_outbox/adapters_container.rb +24 -0
  14. data/lib/transactional_outbox/constants.rb +16 -0
  15. data/lib/transactional_outbox/database/adapters/active_record.rb +25 -0
  16. data/lib/transactional_outbox/database/adapters/interface.rb +25 -0
  17. data/lib/transactional_outbox/database/adapters/null.rb +37 -0
  18. data/lib/transactional_outbox/database/adapters/sequel.rb +26 -0
  19. data/lib/transactional_outbox/database/adapters.rb +7 -0
  20. data/lib/transactional_outbox/database.rb +25 -0
  21. data/lib/transactional_outbox/event/builder.rb +18 -0
  22. data/lib/transactional_outbox/event/contextable.rb +35 -0
  23. data/lib/transactional_outbox/event/payloadable.rb +17 -0
  24. data/lib/transactional_outbox/event.rb +76 -0
  25. data/lib/transactional_outbox/exceptions.rb +12 -0
  26. data/lib/transactional_outbox/exponential_backoff.rb +7 -0
  27. data/lib/transactional_outbox/producer/adapters/interface.rb +19 -0
  28. data/lib/transactional_outbox/producer/adapters/kafka.rb +25 -0
  29. data/lib/transactional_outbox/producer/adapters/null.rb +20 -0
  30. data/lib/transactional_outbox/producer/adapters.rb +8 -0
  31. data/lib/transactional_outbox/producer.rb +31 -0
  32. data/lib/transactional_outbox/railtie.rb +13 -0
  33. data/lib/transactional_outbox/relay/event_processor.rb +54 -0
  34. data/lib/transactional_outbox/relay/failover.rb +9 -0
  35. data/lib/transactional_outbox/relay/graceful_shutdown.rb +37 -0
  36. data/lib/transactional_outbox/relay/monitor.rb +28 -0
  37. data/lib/transactional_outbox/relay/runner.rb +50 -0
  38. data/lib/transactional_outbox/relay/worker_set/processor.rb +55 -0
  39. data/lib/transactional_outbox/relay/worker_set/worker.rb +68 -0
  40. data/lib/transactional_outbox/relay/worker_set.rb +38 -0
  41. data/lib/transactional_outbox/relay.rb +9 -0
  42. data/lib/transactional_outbox/tasks/relay.rake +8 -0
  43. data/lib/transactional_outbox/version.rb +5 -0
  44. data/lib/transactional_outbox.rb +82 -0
  45. metadata +257 -0
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "event/contextable"
4
+ require_relative "event/payloadable"
5
+
6
+ module TransactionalOutbox
7
+ class Event
8
+ extend Dry::Configurable
9
+ extend Contextable
10
+ extend Payloadable
11
+
12
+ setting :schema
13
+ setting :aggregate_type
14
+ setting :event_type
15
+ setting :topic
16
+ setting :event_builder
17
+
18
+ config.values.keys.each do |attr|
19
+ define_singleton_method(attr) do |value|
20
+ config[attr] = value
21
+ end
22
+ end
23
+
24
+ def create!(context)
25
+ validate_context!(context)
26
+
27
+ event = build_event(context)
28
+
29
+ save([event])
30
+ end
31
+
32
+ def bulk_create!(contexts)
33
+ validate_contexts!(contexts)
34
+
35
+ events = build_events(contexts)
36
+
37
+ save(events)
38
+ end
39
+
40
+ private
41
+
42
+ def config = @config ||= self.class.config
43
+
44
+ def event_builder
45
+ @event_builder ||= begin
46
+ klass = config.event_builder || TransactionalOutbox.config.default_event_builder
47
+
48
+ Object.const_get(klass)
49
+ end
50
+ end
51
+
52
+ def save(rows) = TransactionalOutbox::Database.new.insert_events(rows)
53
+
54
+ def validate_event(schema, payload)
55
+ return true if config.schema.nil?
56
+
57
+ errors = JSON::Validator.fully_validate(schema, payload)
58
+
59
+ return true if errors.empty?
60
+
61
+ raise TransactionalOutbox::InvalidPayloadError, errors.join(", ")
62
+ end
63
+
64
+ def build_event(context)
65
+ payload = build_payload(context)
66
+
67
+ validate_event(config.schema, payload)
68
+
69
+ event_builder.build(self.class.config, payload, context)
70
+ end
71
+
72
+ def build_events(contexts) = contexts.map { build_event(_1) }
73
+
74
+ def validate_contexts!(contexts) = contexts.each { |context| validate_context!(context) }
75
+ end
76
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ module Exceptions
5
+ class UnsupportedEventTypeError < StandardError; end
6
+ class InvalidPayloadError < StandardError; end
7
+ class UnknownAdapterError < StandardError; end
8
+ class AdapterAlreadyExistsError < StandardError; end
9
+ class MigrationFileNotExistsError < StandardError; end
10
+ class InvalidContextError < StandardError; end
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class ExponentialBackoff
5
+ def self.calculate_retry_delay(retry_num) = 2**retry_num
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class Producer
5
+ class Adapters
6
+ class Interface
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ def produce_batch(_topic, _batch) = raise NotImplementedError
12
+
13
+ private
14
+
15
+ attr_reader :client
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class Producer
5
+ class Adapters
6
+ class Kafka < Interface
7
+ def produce_batch(topic, events)
8
+ buffer_events(topic, events)
9
+
10
+ producer.deliver_messages
11
+ end
12
+
13
+ private
14
+
15
+ def producer = @producer ||= client.producer
16
+
17
+ def buffer_events(topic, events)
18
+ events.each do |event|
19
+ producer.produce(event.to_json, topic:)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class Producer
5
+ class Adapters
6
+ class Null < Interface
7
+ def messages = @messages ||= {}
8
+ def clear_store = @messages = {}
9
+
10
+ def produce_batch(topic, events)
11
+ msg = messages[topic] ||= []
12
+
13
+ msg.concat(events)
14
+
15
+ TransactionalOutbox.config.logger.info("Batch produced")
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class Producer
5
+ class Adapters < TransactionalOutbox::AdaptersContainer
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class Producer
5
+ extend Forwardable
6
+
7
+ def_delegators :@adapter, :produce_batch
8
+
9
+ attr_reader :adapter
10
+
11
+ def initialize
12
+ @adapter = TransactionalOutbox::Producer::Adapters.resolve(fetch_adapter).new(fetch_client)
13
+ end
14
+
15
+ private
16
+
17
+ def config = @config ||= TransactionalOutbox.config
18
+
19
+ def fetch_adapter
20
+ return :null if config.test_environment
21
+
22
+ config.producer.adapter.to_sym
23
+ end
24
+
25
+ def fetch_client
26
+ return if config.test_environment
27
+
28
+ config.producer.client
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class Railtie < ::Rails::Railtie
5
+ generators do
6
+ require_relative "../generators/transactional_outbox/migration/migration_generator"
7
+ end
8
+
9
+ rake_tasks do
10
+ Dir[File.join(File.dirname(__FILE__), "tasks/*.rake")].each { |f| load f }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class Relay
5
+ class EventProcessor
6
+ attr_reader :topic, :db, :producer
7
+
8
+ def initialize(topic)
9
+ @topic = topic
10
+ @db = TransactionalOutbox::Database.new
11
+ @producer = TransactionalOutbox::Producer.new
12
+ end
13
+
14
+ def call
15
+ events = db.fetch_events(topic, config.relay.batch_size)
16
+
17
+ return if events.empty?
18
+
19
+ process_events(events)
20
+ rescue StandardError => e
21
+ TransactionalOutbox::Relay.monitor.publish(
22
+ TransactionalOutbox::WORKER_EXCEPTIONS_TOTAL_MONITOR_EVENT,
23
+ { topic:, exception: e.class.to_s }
24
+ )
25
+
26
+ resolve_failover.call(e, events)
27
+ end
28
+
29
+ private
30
+
31
+ def config = @config ||= TransactionalOutbox.config
32
+
33
+ def resolve_failover
34
+ configured = config.relay.failover
35
+
36
+ return Object.get_const(configured) if configured.is_a?(String)
37
+
38
+ configured
39
+ end
40
+
41
+ def process_events(events)
42
+ producer.produce_batch(topic, events)
43
+
44
+ db.delete_events(events.map { |x| x[:id] })
45
+
46
+ TransactionalOutbox::Relay.monitor.publish(
47
+ TransactionalOutbox::WORKER_EVENTS_PROCESSED_MONITOR_EVENT, { topic:, count: events.size }
48
+ )
49
+
50
+ config.logger.info("Events have sent to topic #{topic}: #{events.size}")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class Relay
5
+ class Failover
6
+ def self.call(exception, _events) = raise exception
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class Relay
5
+ class GracefulShutdown
6
+ class << self
7
+ def call(worker_set)
8
+ worker_set.stop_workers
9
+
10
+ wait_workers(worker_set)
11
+ end
12
+
13
+ private
14
+
15
+ def config = @config ||= TransactionalOutbox.config
16
+
17
+ def wait_workers(worker_set)
18
+ shutdown_time = Time.now.utc
19
+
20
+ while shutdown_time + config.shutdown_waiting_time_seconds > Time.now.utc
21
+ if worker_set.all_stopped?
22
+ TransactionalOutbox::Relay.monitor.publish(TransactionalOutbox::RUNNER_STOPPED_MONITOR_EVENT)
23
+
24
+ config.logger.info("All workers have stopped.")
25
+
26
+ return true
27
+ end
28
+
29
+ sleep(0.1)
30
+ end
31
+
32
+ false
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class Relay
5
+ class Monitor
6
+ extend Forwardable
7
+
8
+ def_delegators :@notifications, :publish, :subscribe
9
+
10
+ def initialize
11
+ @notifications = Dry::Monitor::Notifications.new(:transactional_outbox).tap { |n| register_events(n) }
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :notifications
17
+
18
+ def register_events(notifications)
19
+ notifications.register_event(TransactionalOutbox::RUNNER_INIT_MONITOR_EVENT)
20
+ notifications.register_event(TransactionalOutbox::RUNNER_STOPPED_MONITOR_EVENT)
21
+ notifications.register_event(TransactionalOutbox::WORKER_RUN_MONITOR_EVENT)
22
+ notifications.register_event(TransactionalOutbox::WORKER_EVENTS_PROCESSED_MONITOR_EVENT)
23
+ notifications.register_event(TransactionalOutbox::WORKER_STOPPED_MONITOR_EVENT)
24
+ notifications.register_event(TransactionalOutbox::WORKER_EXCEPTIONS_TOTAL_MONITOR_EVENT)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "worker_set"
4
+ require_relative "worker_set/processor"
5
+ require_relative "graceful_shutdown"
6
+
7
+ module TransactionalOutbox
8
+ class Relay
9
+ class Runner
10
+ class << self
11
+ def start
12
+ TransactionalOutbox::Relay.monitor.publish(TransactionalOutbox::RUNNER_INIT_MONITOR_EVENT)
13
+
14
+ worker_set = TransactionalOutbox::Relay::WorkerSet.new
15
+
16
+ start_relay(worker_set)
17
+ rescue StandardError => e
18
+ shutdown(e, worker_set, 1)
19
+ rescue SignalException => e
20
+ shutdown(e, worker_set, 0)
21
+ end
22
+
23
+ private
24
+
25
+ def start_relay(worker_set) = TransactionalOutbox::Relay::WorkerSet::Processor.new(worker_set).call
26
+ def config = @config ||= TransactionalOutbox.config
27
+
28
+ def start_graceful_shutdown(worker_set)
29
+ return unless worker_set
30
+
31
+ TransactionalOutbox::Relay::GracefulShutdown.call(worker_set)
32
+ end
33
+
34
+ def call_exit(code)
35
+ return true if config.test_environment
36
+
37
+ exit(code)
38
+ end
39
+
40
+ def shutdown(exception, worker_set, exit_code)
41
+ config.logger.info("Received exception: #{exception}. Shutting down...")
42
+
43
+ start_graceful_shutdown(worker_set)
44
+
45
+ call_exit(exit_code)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ class Relay
5
+ class WorkerSet
6
+ class Processor
7
+ def initialize(worker_set)
8
+ @worker_set = worker_set
9
+ @db = TransactionalOutbox::Database.new
10
+ end
11
+
12
+ def call # rubocop:disable Metrics/MethodLength
13
+ @retry_counter = 0
14
+
15
+ loop do
16
+ topics = db.fetch_topics
17
+
18
+ process_topics(topics)
19
+
20
+ break if config.test_environment
21
+
22
+ @retry_counter = 0
23
+ rescue StandardError => e
24
+ config.logger.error("Exception: #{e}, trying to retry...")
25
+
26
+ raise e if @retry_counter >= config.relay.max_runner_retries_count
27
+
28
+ @retry_counter += 1
29
+
30
+ sleep(calculate_retry_delay)
31
+
32
+ retry
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :worker_set, :db
39
+
40
+ def config = @config ||= TransactionalOutbox.config
41
+ def calculate_retry_delay = TransactionalOutbox::ExponentialBackoff.calculate_retry_delay(@retry_counter)
42
+
43
+ def process_topics(topics)
44
+ topics.each do |topic|
45
+ worker = worker_set.get_worker(topic)
46
+
47
+ worker ? worker_set.try_to_recover_worker(topic) : worker_set.add_worker(topic)
48
+ end
49
+
50
+ sleep(config.relay.delay_between_worker_set_processor_cycles)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../event_processor"
4
+
5
+ module TransactionalOutbox
6
+ class Relay
7
+ class WorkerSet
8
+ class Worker
9
+ attr_reader :topic, :db, :producer
10
+
11
+ def initialize(topic)
12
+ @topic = topic
13
+ end
14
+
15
+ def run
16
+ @thread = spawn_thread
17
+ end
18
+
19
+ def shutdown
20
+ return true if thread[:stopped]
21
+
22
+ thread[:shutdown] = true
23
+ end
24
+
25
+ def shutting_down? = !thread.nil? && !!thread[:shutdown]
26
+ def stopped? = !thread.nil? && !!thread[:stopped]
27
+
28
+ private
29
+
30
+ attr_reader :thread
31
+
32
+ def config = @config ||= TransactionalOutbox.config
33
+
34
+ def spawn_thread # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
35
+ TransactionalOutbox::Relay.monitor.publish(TransactionalOutbox::WORKER_RUN_MONITOR_EVENT, { topic: })
36
+
37
+ Thread.new do
38
+ loop do
39
+ TransactionalOutbox::Relay::EventProcessor.new(topic).call
40
+
41
+ if Thread.current[:shutdown]
42
+ config.logger.info("Thread for topic #{topic} successfully shutted down")
43
+
44
+ mark_thread_as_stopped
45
+
46
+ break
47
+ end
48
+
49
+ break if config.test_environment
50
+
51
+ sleep(config.relay.wait_between_batches_seconds)
52
+ end
53
+ rescue StandardError => e
54
+ mark_thread_as_stopped
55
+
56
+ raise e
57
+ end
58
+ end
59
+
60
+ def mark_thread_as_stopped
61
+ Thread.current[:stopped] = true
62
+
63
+ TransactionalOutbox::Relay.monitor.publish(TransactionalOutbox::WORKER_STOPPED_MONITOR_EVENT, { topic: })
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "worker_set/worker"
4
+
5
+ module TransactionalOutbox
6
+ class Relay
7
+ class WorkerSet
8
+ def initialize
9
+ @workers = {}
10
+ end
11
+
12
+ def get_worker(topic) = workers[topic]
13
+
14
+ def add_worker(topic)
15
+ return if workers.key?(topic)
16
+
17
+ create_worker(topic)
18
+ end
19
+
20
+ def try_to_recover_worker(topic)
21
+ worker = get_worker(topic)
22
+
23
+ return if !worker.nil? && (worker.shutting_down? || !worker.stopped?)
24
+
25
+ create_worker(topic)
26
+ end
27
+
28
+ def stop_workers = workers.each_value(&:shutdown)
29
+ def all_stopped? = workers.values.all?(&:stopped?)
30
+
31
+ private
32
+
33
+ attr_reader :workers
34
+
35
+ def create_worker(topic) = workers[topic] = Worker.new(topic).tap(&:run)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "relay/monitor"
4
+
5
+ module TransactionalOutbox
6
+ class Relay
7
+ def self.monitor = @monitor ||= TransactionalOutbox::Relay::Monitor.new
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :relay do
4
+ desc "Runs transactional outbox event relay"
5
+ task run: :environment do
6
+ TransactionalOutbox::Relay::Runner.start
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TransactionalOutbox
4
+ VERSION = "1.0.0beta1"
5
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ require "dry-container"
6
+ require "dry-configurable"
7
+ require "dry-monitor"
8
+ require "dry-validation"
9
+ require "json-schema"
10
+ require "securerandom"
11
+
12
+ require_relative "transactional_outbox/version"
13
+ require_relative "transactional_outbox/adapters_container"
14
+ require_relative "transactional_outbox/database/adapters"
15
+ require_relative "transactional_outbox/database/adapters/interface"
16
+ require_relative "transactional_outbox/database/adapters/null"
17
+ require_relative "transactional_outbox/database/adapters/active_record"
18
+ require_relative "transactional_outbox/database/adapters/sequel"
19
+ require_relative "transactional_outbox/producer/adapters"
20
+ require_relative "transactional_outbox/producer/adapters/interface"
21
+ require_relative "transactional_outbox/producer/adapters/null"
22
+ require_relative "transactional_outbox/producer/adapters/kafka"
23
+ require_relative "transactional_outbox/constants"
24
+ require_relative "transactional_outbox/database"
25
+ require_relative "transactional_outbox/event"
26
+ require_relative "transactional_outbox/event/builder"
27
+ require_relative "transactional_outbox/exceptions"
28
+ require_relative "transactional_outbox/exponential_backoff"
29
+ require_relative "transactional_outbox/producer"
30
+
31
+ require_relative "transactional_outbox/relay"
32
+ require_relative "transactional_outbox/relay/failover"
33
+ require_relative "transactional_outbox/relay/runner"
34
+
35
+ require_relative "transactional_outbox/railtie" if defined?(Rails::Railtie)
36
+
37
+ TransactionalOutbox::Database::Adapters.register(:null, TransactionalOutbox::Database::Adapters::Null)
38
+ TransactionalOutbox::Database::Adapters.register(:sequel, TransactionalOutbox::Database::Adapters::Sequel)
39
+ TransactionalOutbox::Database::Adapters.register(:active_record, TransactionalOutbox::Database::Adapters::ActiveRecord)
40
+
41
+ TransactionalOutbox::Producer::Adapters.register(:null, TransactionalOutbox::Producer::Adapters::Null)
42
+ TransactionalOutbox::Producer::Adapters.register(:kafka, TransactionalOutbox::Producer::Adapters::Kafka)
43
+
44
+ module TransactionalOutbox
45
+ include Constants
46
+ include Exceptions
47
+
48
+ extend Dry::Configurable
49
+
50
+ setting :logger
51
+ setting :outbox_table_name, default: "outbox_events"
52
+ setting :default_event_builder, default: TransactionalOutbox::Event::Builder
53
+ setting :shutdown_waiting_time_seconds, default: 10
54
+ setting :migrations_directory
55
+ setting :test_environment, default: false
56
+
57
+ setting :relay do
58
+ setting :batch_size, default: 20
59
+ setting :wait_between_batches_seconds, default: 0.1
60
+ setting :failover, default: TransactionalOutbox::Relay::Failover
61
+ setting :max_runner_retries_count, default: 5
62
+ setting :delay_between_worker_set_processor_cycles, default: 1
63
+ end
64
+
65
+ setting :db do
66
+ setting :adapter
67
+ setting :connection_data
68
+ end
69
+
70
+ setting :producer do
71
+ setting :adapter
72
+ setting :client
73
+ end
74
+
75
+ class << self
76
+ def transaction(event)
77
+ Database.new.transaction do
78
+ yield(event.new)
79
+ end
80
+ end
81
+ end
82
+ end