sbmt-outbox 5.0.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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +440 -0
  3. data/Rakefile +3 -0
  4. data/app/interactors/sbmt/outbox/base_create_item.rb +55 -0
  5. data/app/interactors/sbmt/outbox/create_inbox_item.rb +10 -0
  6. data/app/interactors/sbmt/outbox/create_outbox_item.rb +10 -0
  7. data/app/interactors/sbmt/outbox/dry_interactor.rb +16 -0
  8. data/app/interactors/sbmt/outbox/partition_strategies/hash_partitioning.rb +20 -0
  9. data/app/interactors/sbmt/outbox/partition_strategies/number_partitioning.rb +26 -0
  10. data/app/interactors/sbmt/outbox/process_item.rb +269 -0
  11. data/app/interactors/sbmt/outbox/retry_strategies/compacted_log.rb +41 -0
  12. data/app/interactors/sbmt/outbox/retry_strategies/exponential_backoff.rb +34 -0
  13. data/app/jobs/sbmt/outbox/base_delete_stale_items_job.rb +78 -0
  14. data/app/jobs/sbmt/outbox/delete_stale_inbox_items_job.rb +15 -0
  15. data/app/jobs/sbmt/outbox/delete_stale_outbox_items_job.rb +15 -0
  16. data/app/models/sbmt/outbox/base_item.rb +165 -0
  17. data/app/models/sbmt/outbox/base_item_config.rb +106 -0
  18. data/app/models/sbmt/outbox/inbox_item.rb +38 -0
  19. data/app/models/sbmt/outbox/inbox_item_config.rb +13 -0
  20. data/app/models/sbmt/outbox/outbox_item.rb +52 -0
  21. data/app/models/sbmt/outbox/outbox_item_config.rb +13 -0
  22. data/config/initializers/schked.rb +9 -0
  23. data/config/initializers/yabeda.rb +71 -0
  24. data/config/schedule.rb +9 -0
  25. data/exe/outbox +16 -0
  26. data/lib/generators/helpers/config.rb +46 -0
  27. data/lib/generators/helpers/initializer.rb +41 -0
  28. data/lib/generators/helpers/items.rb +17 -0
  29. data/lib/generators/helpers/migration.rb +73 -0
  30. data/lib/generators/helpers/paas.rb +17 -0
  31. data/lib/generators/helpers/values.rb +49 -0
  32. data/lib/generators/helpers.rb +8 -0
  33. data/lib/generators/outbox/install/USAGE +10 -0
  34. data/lib/generators/outbox/install/install_generator.rb +33 -0
  35. data/lib/generators/outbox/install/templates/Outboxfile +3 -0
  36. data/lib/generators/outbox/install/templates/outbox.rb +32 -0
  37. data/lib/generators/outbox/install/templates/outbox.yml +51 -0
  38. data/lib/generators/outbox/item/USAGE +12 -0
  39. data/lib/generators/outbox/item/item_generator.rb +94 -0
  40. data/lib/generators/outbox/item/templates/inbox_item.rb.tt +7 -0
  41. data/lib/generators/outbox/item/templates/outbox_item.rb.tt +16 -0
  42. data/lib/generators/outbox/transport/USAGE +19 -0
  43. data/lib/generators/outbox/transport/templates/inbox_transport.yml.erb +9 -0
  44. data/lib/generators/outbox/transport/templates/outbox_transport.yml.erb +10 -0
  45. data/lib/generators/outbox/transport/transport_generator.rb +60 -0
  46. data/lib/generators/outbox.rb +23 -0
  47. data/lib/sbmt/outbox/ascii_art.rb +62 -0
  48. data/lib/sbmt/outbox/cli.rb +100 -0
  49. data/lib/sbmt/outbox/database_switcher.rb +15 -0
  50. data/lib/sbmt/outbox/engine.rb +45 -0
  51. data/lib/sbmt/outbox/error_tracker.rb +26 -0
  52. data/lib/sbmt/outbox/errors.rb +14 -0
  53. data/lib/sbmt/outbox/instrumentation/open_telemetry_loader.rb +34 -0
  54. data/lib/sbmt/outbox/logger.rb +35 -0
  55. data/lib/sbmt/outbox/middleware/builder.rb +23 -0
  56. data/lib/sbmt/outbox/middleware/open_telemetry/tracing_create_item_middleware.rb +42 -0
  57. data/lib/sbmt/outbox/middleware/open_telemetry/tracing_item_process_middleware.rb +49 -0
  58. data/lib/sbmt/outbox/middleware/runner.rb +29 -0
  59. data/lib/sbmt/outbox/middleware/sentry/tracing_batch_process_middleware.rb +48 -0
  60. data/lib/sbmt/outbox/middleware/sentry/tracing_item_process_middleware.rb +65 -0
  61. data/lib/sbmt/outbox/middleware/sentry/transaction.rb +28 -0
  62. data/lib/sbmt/outbox/probes/probe.rb +38 -0
  63. data/lib/sbmt/outbox/redis_client_factory.rb +36 -0
  64. data/lib/sbmt/outbox/tasks/delete_failed_items.rake +17 -0
  65. data/lib/sbmt/outbox/tasks/retry_failed_items.rake +20 -0
  66. data/lib/sbmt/outbox/thread_pool.rb +108 -0
  67. data/lib/sbmt/outbox/throttler.rb +52 -0
  68. data/lib/sbmt/outbox/version.rb +7 -0
  69. data/lib/sbmt/outbox/worker.rb +233 -0
  70. data/lib/sbmt/outbox.rb +136 -0
  71. data/lib/sbmt-outbox.rb +3 -0
  72. metadata +594 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ module Middleware
6
+ module OpenTelemetry
7
+ class TracingCreateItemMiddleware
8
+ def call(item_class, item_attributes)
9
+ return yield unless defined?(::OpenTelemetry)
10
+
11
+ span_attributes = {
12
+ "messaging.system" => "outbox",
13
+ "messaging.outbox.box_type" => item_class.box_type.to_s,
14
+ "messaging.outbox.box_name" => item_class.box_name,
15
+ "messaging.outbox.owner" => item_class.owner,
16
+ "messaging.destination" => item_class.name,
17
+ "messaging.destination_kind" => "database"
18
+ }
19
+
20
+ options = item_attributes[:options] ||= {}
21
+ headers = options[:headers] || options["headers"]
22
+ headers ||= options[:headers] ||= {}
23
+ tracer.in_span(span_name(item_class), attributes: span_attributes.compact, kind: :producer) do
24
+ ::OpenTelemetry.propagation.inject(headers)
25
+ yield
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def tracer
32
+ ::Sbmt::Outbox::Instrumentation::OpenTelemetryLoader.instance.tracer
33
+ end
34
+
35
+ def span_name(item_class)
36
+ "#{item_class.box_type}/#{item_class.box_name} create item"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ module Middleware
6
+ module OpenTelemetry
7
+ class TracingItemProcessMiddleware
8
+ def call(item)
9
+ return yield unless defined?(::OpenTelemetry)
10
+
11
+ item_class = item.class
12
+ item_options = item.options || {}
13
+ item_options_headers = item_options[:headers] || item_options["headers"]
14
+
15
+ return yield unless item_class && item_options_headers
16
+
17
+ span_attributes = {
18
+ "messaging.system" => "outbox",
19
+ "messaging.outbox.item_id" => item.id.to_s,
20
+ "messaging.outbox.box_type" => item_class.box_type.to_s,
21
+ "messaging.outbox.box_name" => item_class.box_name,
22
+ "messaging.outbox.owner" => item_class.owner,
23
+ "messaging.destination" => item_class.name,
24
+ "messaging.destination_kind" => "database",
25
+ "messaging.operation" => "process"
26
+ }
27
+
28
+ extracted_context = ::OpenTelemetry.propagation.extract(item_options_headers)
29
+ ::OpenTelemetry::Context.with_current(extracted_context) do
30
+ tracer.in_span(span_name(item_class), attributes: span_attributes.compact, kind: :consumer) do
31
+ yield
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def tracer
39
+ ::Sbmt::Outbox::Instrumentation::OpenTelemetryLoader.instance.tracer
40
+ end
41
+
42
+ def span_name(item_class)
43
+ "#{item_class.box_type}/#{item_class.box_name} process item"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ module Middleware
6
+ class Runner
7
+ attr_reader :stack
8
+
9
+ def initialize(stack)
10
+ @stack = stack
11
+ end
12
+
13
+ def call(*args)
14
+ return yield if stack.empty?
15
+
16
+ chain = stack.map { |i| i.new }
17
+ traverse_chain = proc do
18
+ if chain.empty?
19
+ yield
20
+ else
21
+ chain.shift.call(*args, &traverse_chain)
22
+ end
23
+ end
24
+ traverse_chain.call
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sbmt/outbox/middleware/sentry/transaction"
4
+
5
+ module Sbmt
6
+ module Outbox
7
+ module Middleware
8
+ module Sentry
9
+ class TracingBatchProcessMiddleware
10
+ include Transaction
11
+
12
+ def call(job)
13
+ return yield unless ::Sentry.initialized?
14
+
15
+ scope = ::Sentry.get_current_scope
16
+
17
+ # transaction will be nil if sentry tracing is not enabled
18
+ transaction = start_transaction(scope, job)
19
+ job.log_tags[:trace_id] = scope&.tags&.[](:trace_id)
20
+
21
+ begin
22
+ yield
23
+ rescue
24
+ finish_sentry_transaction(scope, transaction, 500)
25
+ raise
26
+ end
27
+
28
+ finish_sentry_transaction(scope, transaction, 200)
29
+ end
30
+
31
+ private
32
+
33
+ def start_transaction(scope, job)
34
+ start_sentry_transaction(scope, op(job), transaction_name(job), job.log_tags)
35
+ end
36
+
37
+ def op(job)
38
+ "sbmt.#{job.log_tags[:box_type]&.downcase}.batch_process"
39
+ end
40
+
41
+ def transaction_name(job)
42
+ "Sbmt.#{job.log_tags[:box_type]&.capitalize}.#{job.log_tags[:box_name]&.capitalize}"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sbmt/outbox/middleware/sentry/transaction"
4
+
5
+ module Sbmt
6
+ module Outbox
7
+ module Middleware
8
+ module Sentry
9
+ class TracingItemProcessMiddleware
10
+ include Transaction
11
+
12
+ attr_reader :new_transaction
13
+
14
+ def call(item)
15
+ return yield unless ::Sentry.initialized?
16
+
17
+ scope = ::Sentry.get_current_scope
18
+
19
+ # transaction will be nil if sentry tracing is not enabled
20
+ transaction = scope&.get_transaction || start_transaction(scope, item.class)
21
+ span = transaction&.start_child(op: op(item.class), description: "Starting item processing")
22
+ span&.set_data(:item_id, item.id)
23
+
24
+ begin
25
+ yield
26
+ rescue
27
+ finish_span(span, 500)
28
+ finish_sentry_transaction(scope, transaction, 500) if new_transaction
29
+ raise
30
+ end
31
+
32
+ finish_span(span, 200)
33
+ finish_sentry_transaction(scope, transaction, 200) if new_transaction
34
+ end
35
+
36
+ private
37
+
38
+ def finish_span(span, status)
39
+ return unless span
40
+
41
+ span.set_data(:status, status)
42
+ span.finish
43
+ end
44
+
45
+ def start_transaction(scope, item_class)
46
+ @new_transaction = true
47
+ start_sentry_transaction(scope, op(item_class), transaction_name(item_class), tags(item_class))
48
+ end
49
+
50
+ def transaction_name(item_class)
51
+ "Sbmt.#{item_class.box_type.capitalize}.#{item_class.box_name.capitalize}"
52
+ end
53
+
54
+ def tags(item_class)
55
+ {box_type: item_class.box_type, box_name: item_class.box_name}
56
+ end
57
+
58
+ def op(item_class)
59
+ "sbmt.#{item_class.box_type.downcase}.item_process"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ module Middleware
6
+ module Sentry
7
+ module Transaction
8
+ def start_sentry_transaction(scope, op, name, tags = {})
9
+ trace_id = SecureRandom.base58
10
+ scope&.set_tags(tags.merge(trace_id: trace_id))
11
+ transaction = ::Sentry.start_transaction(op: op, name: name)
12
+ scope&.set_span(transaction) if transaction
13
+
14
+ transaction
15
+ end
16
+
17
+ def finish_sentry_transaction(scope, transaction, status)
18
+ return unless transaction
19
+
20
+ transaction.set_http_status(status)
21
+ transaction.finish
22
+ scope.clear
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ module Probes
6
+ class Probe
7
+ DEFAULT_PROBE_PORT = 5555
8
+ class << self
9
+ def run_probes
10
+ ::HttpHealthCheck.run_server_async(
11
+ port: probe_port,
12
+ rack_app: HttpHealthCheck::RackApp.configure do |c|
13
+ c.logger Rails.logger
14
+ c.probe "/readiness/outbox" do |_env|
15
+ code = Sbmt::Outbox.current_worker.ready? ? 200 : 500
16
+ [code, {}, ["Outbox version: #{Sbmt::Outbox::VERSION}"]]
17
+ end
18
+
19
+ c.probe "/liveness/outbox" do |_env|
20
+ code = Sbmt::Outbox.current_worker.alive? ? 200 : 500
21
+ [code, {}, ["Outbox version: #{Sbmt::Outbox::VERSION}"]]
22
+ end
23
+ end
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ def probe_port
30
+ return DEFAULT_PROBE_PORT if Outbox.yaml_config["probes"].nil?
31
+
32
+ Sbmt::Outbox.yaml_config.fetch(:probes).fetch(:port)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ module RedisClientFactory
6
+ def self.build(options)
7
+ options = options.deep_symbolize_keys
8
+
9
+ unless options.key?(:reconnect_attempts)
10
+ options[:reconnect_attempts] = 3
11
+ end
12
+
13
+ if options.key?(:sentinels)
14
+ if (url = options.delete(:url))
15
+ uri = URI.parse(url)
16
+ if !options.key?(:name) && uri.host
17
+ options[:name] = uri.host
18
+ end
19
+
20
+ if !options.key?(:password) && uri.password && !uri.password.empty?
21
+ options[:password] = uri.password
22
+ end
23
+
24
+ if !options.key?(:username) && uri.user && !uri.user.empty?
25
+ options[:username] = uri.user
26
+ end
27
+ end
28
+
29
+ RedisClient.sentinel(**options).new_client
30
+ else
31
+ RedisClient.config(**options).new_client
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :outbox do
4
+ desc "Delete messages that we were unable to deliver"
5
+ # rake 'outbox:delete_failed_items[some/box_item]'
6
+ # rake 'outbox:delete_failed_items[some/box_item,1,2,3,4,5]'
7
+ task :delete_failed_items, %i[] => :environment do |_, args|
8
+ item_class_name, *ids = args.extras
9
+ item_class_name = item_class_name.classify
10
+ raise "Invalid item name" unless Sbmt::Outbox.item_classes.map(&:to_s).include?(item_class_name)
11
+ item_class = item_class_name.constantize
12
+
13
+ scope = item_class.failed
14
+ scope = scope.where(id: ids) unless ids.empty?
15
+ scope.in_batches.delete_all
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :outbox do
4
+ desc "Retry messages that we were unable to deliver"
5
+ # rake 'outbox:retry_failed_items[some/box_item]'
6
+ # rake 'outbox:retry_failed_items[some/box_item,1,2,3,4,5]'
7
+ task :retry_failed_items, %i[] => :environment do |_, args|
8
+ item_class_name, *ids = args.extras
9
+ item_class_name = item_class_name.classify
10
+ raise "Invalid item name" unless Sbmt::Outbox.item_classes.map(&:to_s).include?(item_class_name)
11
+ item_class = item_class_name.constantize
12
+
13
+ scope = item_class.failed
14
+ scope = scope.where(id: ids) unless ids.empty?
15
+ scope.in_batches.update_all(
16
+ status: Sbmt::Outbox::BaseItem.statuses[:pending],
17
+ errors_count: 0
18
+ )
19
+ end
20
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sbmt/outbox/throttler"
4
+
5
+ module Sbmt
6
+ module Outbox
7
+ class ThreadPool
8
+ BREAK = Object.new.freeze
9
+ SKIPPED = Object.new.freeze
10
+ PROCESSED = Object.new.freeze
11
+
12
+ def initialize(&block)
13
+ self.task_source = block
14
+ self.task_mutex = Mutex.new
15
+ self.stopped = true
16
+ end
17
+
18
+ def next_task
19
+ task_mutex.synchronize do
20
+ return if stopped
21
+ item = task_source.call
22
+
23
+ if item == BREAK
24
+ self.stopped = true
25
+ return
26
+ end
27
+
28
+ item
29
+ end
30
+ end
31
+
32
+ def start(concurrency:)
33
+ self.stopped = false
34
+ result = run_threads(count: concurrency) do |item|
35
+ yield worker_number, item
36
+ end
37
+
38
+ raise result if result.is_a?(Exception)
39
+ nil
40
+ ensure
41
+ self.stopped = true
42
+ end
43
+
44
+ def stop
45
+ self.stopped = true
46
+ end
47
+
48
+ def worker_number
49
+ Thread.current["thread_pool_worker_number:#{object_id}"]
50
+ end
51
+
52
+ private
53
+
54
+ attr_accessor :task_source, :task_mutex, :stopped
55
+
56
+ def run_threads(count:)
57
+ exception = nil
58
+
59
+ in_threads(count: count) do |worker_num|
60
+ self.worker_number = worker_num
61
+ # We don't want to start all threads at the same time
62
+ random_sleep = rand * (worker_num + 1)
63
+
64
+ throttler = Throttler.new(
65
+ limit: Outbox.config.worker.rate_limit,
66
+ interval: Outbox.config.worker.rate_interval + random_sleep
67
+ )
68
+
69
+ sleep(random_sleep)
70
+
71
+ last_result = nil
72
+ until exception
73
+ throttler.wait if last_result == PROCESSED
74
+ item = next_task
75
+ break unless item
76
+
77
+ begin
78
+ last_result = yield item
79
+ rescue Exception => e # rubocop:disable Lint/RescueException
80
+ exception = e
81
+ end
82
+ end
83
+ end
84
+
85
+ exception
86
+ end
87
+
88
+ def in_threads(count:)
89
+ threads = []
90
+
91
+ Thread.handle_interrupt(Exception => :never) do
92
+ Thread.handle_interrupt(Exception => :immediate) do
93
+ count.times do |i|
94
+ threads << Thread.new { yield(i) }
95
+ end
96
+ threads.map(&:value)
97
+ end
98
+ ensure
99
+ threads.each(&:kill)
100
+ end
101
+ end
102
+
103
+ def worker_number=(num)
104
+ Thread.current["thread_pool_worker_number:#{object_id}"] = num
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ # Based on https://github.com/Shopify/limiter/blob/master/lib/limiter/rate_queue.rb
6
+ # We cannot use that gem because we have to support Ruby 2.5,
7
+ # but Shopify's limiter requires minimum Ruby 2.6
8
+ class Throttler
9
+ def initialize(limit: nil, interval: nil)
10
+ @limit = limit
11
+ @interval = limit
12
+ @map = (0...@limit).map { |i| base_time + (gap * i) }
13
+ @index = 0
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def wait
18
+ time = nil
19
+
20
+ @mutex.synchronize do
21
+ time = @map[@index]
22
+
23
+ sleep_until(time + @interval)
24
+
25
+ @map[@index] = now
26
+ @index = (@index + 1) % @limit
27
+ end
28
+
29
+ time
30
+ end
31
+
32
+ private
33
+
34
+ def sleep_until(time)
35
+ period = time - now
36
+ sleep(period) if period > 0
37
+ end
38
+
39
+ def base_time
40
+ now - @interval
41
+ end
42
+
43
+ def gap
44
+ @interval.to_f / @limit.to_f
45
+ end
46
+
47
+ def now
48
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ VERSION = "5.0.0"
6
+ end
7
+ end