sbmt-outbox 5.0.0

Sign up to get free protection for your applications and to get access to all the features.
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