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,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ class ProcessItem < Sbmt::Outbox::DryInteractor
6
+ param :item_class, reader: :private
7
+ param :item_id, reader: :private
8
+
9
+ METRICS_COUNTERS = %i[error_counter retry_counter sent_counter fetch_error_counter discarded_counter].freeze
10
+
11
+ delegate :log_success, :log_failure, to: "Sbmt::Outbox.logger"
12
+ delegate :item_process_middlewares, to: "Sbmt::Outbox"
13
+ delegate :box_type, :box_name, :owner, to: :item_class
14
+
15
+ attr_accessor :process_latency
16
+
17
+ def call
18
+ log_success(
19
+ "Start processing #{box_type} item.\n" \
20
+ "Record: #{item_class.name}##{item_id}"
21
+ )
22
+
23
+ item = nil
24
+
25
+ item_class.transaction do
26
+ item = yield fetch_item
27
+
28
+ if item.processed_at?
29
+ item.config.retry_strategies.each do |retry_strategy|
30
+ yield check_retry_strategy(item, retry_strategy)
31
+ end
32
+ else
33
+ self.process_latency = Time.current - item.created_at
34
+ end
35
+
36
+ middlewares = Middleware::Builder.new(item_process_middlewares)
37
+ payload = yield build_payload(item)
38
+ transports = yield fetch_transports(item)
39
+
40
+ middlewares.call(item) do
41
+ transports.each do |transport|
42
+ yield process_item(transport, item, payload)
43
+ end
44
+
45
+ track_successed(item)
46
+
47
+ Success(item)
48
+ end
49
+ rescue Dry::Monads::Do::Halt => e
50
+ e.result
51
+ rescue => e
52
+ track_failed(e, item)
53
+ Failure(e.message)
54
+ end
55
+ ensure
56
+ report_metrics(item)
57
+ end
58
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
59
+
60
+ private
61
+
62
+ def fetch_item
63
+ item = item_class
64
+ .lock("FOR UPDATE")
65
+ .find_by(id: item_id)
66
+
67
+ unless item
68
+ track_failed("not found")
69
+ return Failure(:not_found)
70
+ end
71
+
72
+ unless item.for_processing?
73
+ log_error("already processed")
74
+ counters[:fetch_error_counter] += 1
75
+ return Failure(:already_processed)
76
+ end
77
+
78
+ Success(item)
79
+ end
80
+
81
+ def check_retry_strategy(item, retry_strategy)
82
+ result = retry_strategy.call(item)
83
+
84
+ return Success() if result.success?
85
+
86
+ case result.failure
87
+ when :skip_processing
88
+ Failure(:skip_processing)
89
+ when :discard_item
90
+ track_discarded(item)
91
+ Failure(:discard_item)
92
+ else
93
+ track_failed("retry stratagy returned unknown failure: #{result.failure}")
94
+ Failure(:retry_strategy_failure)
95
+ end
96
+ end
97
+
98
+ def build_payload(item)
99
+ builder = item.payload_builder
100
+
101
+ if builder
102
+ payload = item.payload_builder.call(item)
103
+ return payload if payload.success?
104
+
105
+ track_failed("payload builder returned failure: #{payload.failure}", item)
106
+ Failure(:payload_failure)
107
+ else
108
+ Success(item.payload)
109
+ end
110
+ end
111
+
112
+ def fetch_transports(item)
113
+ transports = item.transports
114
+ return Success(transports) if transports.present?
115
+
116
+ track_failed("missing transports", item)
117
+ Failure(:missing_transports)
118
+ end
119
+
120
+ # rubocop:disable Metrics/MethodLength
121
+ def process_item(transport, item, payload)
122
+ transport_error = nil
123
+
124
+ result = item_class.transaction(requires_new: true) do
125
+ transport.call(item, payload)
126
+ rescue => e
127
+ transport_error = e
128
+ raise ActiveRecord::Rollback
129
+ end
130
+
131
+ if transport_error
132
+ track_failed(transport_error, item)
133
+ return Failure(:transport_failure)
134
+ end
135
+
136
+ case result
137
+ when Dry::Monads::Result
138
+ if result.failure?
139
+ track_failed("transport #{transport} returned failure: #{result.failure}", item)
140
+ Failure(:transport_failure)
141
+ else
142
+ Success()
143
+ end
144
+ when false
145
+ track_failed("transport #{transport} returned #{result.inspect}", item)
146
+ Failure(:transport_failure)
147
+ else
148
+ Success()
149
+ end
150
+ end
151
+ # rubocop:enable Metrics/MethodLength
152
+
153
+ def track_failed(ex_or_msg, item = nil)
154
+ log_error(ex_or_msg, item)
155
+
156
+ item&.touch_processed_at
157
+ item&.add_error(ex_or_msg)
158
+
159
+ if item.nil?
160
+ report_error(ex_or_msg)
161
+ counters[:fetch_error_counter] += 1
162
+ elsif item.max_retries_exceeded?
163
+ report_error(ex_or_msg, item)
164
+ counters[:error_counter] += 1
165
+ item.failed!
166
+ else
167
+ counters[:retry_counter] += 1
168
+ item.pending!
169
+ end
170
+ end
171
+
172
+ def track_successed(item)
173
+ msg = "Successfully delivered #{box_type} item.\n" \
174
+ "Record: #{item_class.name}##{item_id}.\n" \
175
+ "#{item.log_details.to_json}"
176
+ log_success(msg)
177
+
178
+ item.touch_processed_at
179
+ item.delivered!
180
+
181
+ counters[:sent_counter] += 1
182
+ end
183
+
184
+ def track_discarded(item)
185
+ msg = "Skipped and discarded #{box_type} item.\n" \
186
+ "Record: #{item_class.name}##{item_id}.\n" \
187
+ "#{item.log_details.to_json}"
188
+ log_success(msg)
189
+
190
+ item.touch_processed_at
191
+ item.discarded!
192
+
193
+ counters[:discarded_counter] += 1
194
+ end
195
+
196
+ def log_error(ex_or_msg, item = nil)
197
+ text = format_exception_error(ex_or_msg)
198
+
199
+ msg = "Failed processing #{box_type} item with error: #{text}.\n" \
200
+ "Record: #{item_class.name}##{item_id}.\n" \
201
+ "#{item&.log_details&.to_json}"
202
+
203
+ log_failure(msg, backtrace: format_backtrace(ex_or_msg))
204
+ end
205
+
206
+ def format_exception_error(e)
207
+ text = if e.respond_to?(:cause) && !e.cause.nil?
208
+ "#{format_exception_error(e.cause)}. "
209
+ else
210
+ ""
211
+ end
212
+
213
+ if e.respond_to?(:message)
214
+ "#{text}#{e.class.name} #{e.message}"
215
+ else
216
+ "#{text}#{e}"
217
+ end
218
+ end
219
+
220
+ def format_backtrace(e)
221
+ if e.respond_to?(:backtrace) && !e.backtrace.nil?
222
+ e.backtrace.join("\n")
223
+ end
224
+ end
225
+
226
+ def report_error(ex_or_msg, item = nil)
227
+ Outbox.error_tracker.error(
228
+ ex_or_msg,
229
+ box_name: item_class.box_name,
230
+ item_class: item_class.name,
231
+ item_id: item_id,
232
+ item_details: item&.log_details&.to_json
233
+ )
234
+ end
235
+
236
+ def report_metrics(item)
237
+ labels = labels_for(item)
238
+
239
+ METRICS_COUNTERS.each do |counter_name|
240
+ Yabeda
241
+ .outbox
242
+ .send(counter_name)
243
+ .increment(labels, by: counters[counter_name])
244
+ end
245
+
246
+ track_process_latency(labels) if process_latency
247
+
248
+ return unless counters[:sent_counter].positive?
249
+
250
+ Yabeda
251
+ .outbox
252
+ .last_sent_event_id
253
+ .set(labels, item_id)
254
+ end
255
+
256
+ def labels_for(item)
257
+ {type: box_type, name: box_name, owner: owner, partition: item&.partition}
258
+ end
259
+
260
+ def counters
261
+ @counters ||= Hash.new(0)
262
+ end
263
+
264
+ def track_process_latency(labels)
265
+ Yabeda.outbox.process_latency.measure(labels, process_latency.round(3))
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ module RetryStrategies
6
+ class CompactedLog < Outbox::DryInteractor
7
+ param :outbox_item
8
+
9
+ def call
10
+ unless outbox_item.has_attribute?(:event_key)
11
+ return Failure(:missing_event_key)
12
+ end
13
+
14
+ if outbox_item.event_key.nil?
15
+ return Failure(:empty_event_key)
16
+ end
17
+
18
+ if delivered_later?
19
+ Failure(:discard_item)
20
+ else
21
+ Success()
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def delivered_later?
28
+ scope = outbox_item.class
29
+ .where("id > ?", outbox_item)
30
+ .where(event_key: outbox_item.event_key, status: Sbmt::Outbox::BaseItem.statuses[:delivered])
31
+
32
+ if outbox_item.has_attribute?(:event_name) && outbox_item.event_name.present?
33
+ scope = scope.where(event_name: outbox_item.event_name)
34
+ end
35
+
36
+ scope.exists?
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ module RetryStrategies
6
+ class ExponentialBackoff < Outbox::DryInteractor
7
+ param :outbox_item
8
+
9
+ def call
10
+ delay = backoff(outbox_item.config).interval_at(outbox_item.errors_count - 1)
11
+
12
+ still_early = outbox_item.processed_at + delay.seconds > Time.current
13
+
14
+ if still_early
15
+ Failure(:skip_processing)
16
+ else
17
+ Success()
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def backoff(config)
24
+ @backoff ||= ::ExponentialBackoff.new([
25
+ config.minimal_retry_interval,
26
+ config.maximal_retry_interval
27
+ ]).tap do |x|
28
+ x.multiplier = config.multiplier_retry_interval
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redlock"
4
+
5
+ module Sbmt
6
+ module Outbox
7
+ class BaseDeleteStaleItemsJob < Outbox.active_job_base_class
8
+ MIN_RETENTION_PERIOD = 1.day
9
+ LOCK_TTL = 10_800_000
10
+ BATCH_SIZE = 1000
11
+ SLEEP_TIME = 1
12
+
13
+ class << self
14
+ def enqueue
15
+ item_classes.each do |item_class|
16
+ perform_later(item_class.to_s)
17
+ end
18
+ end
19
+
20
+ def item_classes
21
+ raise NotImplementedError
22
+ end
23
+ end
24
+
25
+ delegate :config, :logger, to: "Sbmt::Outbox"
26
+ delegate :box_type, :box_name, to: :item_class
27
+
28
+ attr_accessor :item_class
29
+
30
+ def perform(item_class_name)
31
+ self.item_class = item_class_name.constantize
32
+
33
+ client = if Gem::Version.new(Redlock::VERSION) >= Gem::Version.new("2.0.0")
34
+ RedisClientFactory.build(config.redis)
35
+ else
36
+ Redis.new(config.redis)
37
+ end
38
+
39
+ lock_manager = Redlock::Client.new([client], retry_count: 0)
40
+
41
+ lock_manager.lock("#{self.class.name}:#{item_class_name}:lock", LOCK_TTL) do |locked|
42
+ if locked
43
+ duration = item_class.config.retention
44
+
45
+ validate_retention!(duration)
46
+
47
+ logger.with_tags(box_type: box_type, box_name: box_name) do
48
+ delete_stale_items(Time.current - duration)
49
+ end
50
+ else
51
+ logger.log_info("Failed to acquire lock #{self.class.name}:#{item_class_name}")
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def validate_retention!(duration)
59
+ return if duration >= MIN_RETENTION_PERIOD
60
+
61
+ raise "Retention period for #{box_name} must be longer than #{MIN_RETENTION_PERIOD.inspect}"
62
+ end
63
+
64
+ def delete_stale_items(waterline)
65
+ logger.log_info("Start deleting #{box_type} items for #{box_name} older than #{waterline}")
66
+
67
+ scope = item_class.where("created_at < ?", waterline)
68
+
69
+ while (ids = scope.limit(BATCH_SIZE).ids).present?
70
+ item_class.where(id: ids).delete_all
71
+ sleep SLEEP_TIME
72
+ end
73
+
74
+ logger.log_info("Successfully deleted #{box_type} items for #{box_name} older than #{waterline}")
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ class DeleteStaleInboxItemsJob < BaseDeleteStaleItemsJob
6
+ queue_as :inbox
7
+
8
+ class << self
9
+ def item_classes
10
+ Outbox.inbox_item_classes
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ class DeleteStaleOutboxItemsJob < BaseDeleteStaleItemsJob
6
+ queue_as :outbox
7
+
8
+ class << self
9
+ def item_classes
10
+ Outbox.outbox_item_classes
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ class BaseItem < Outbox.active_record_base_class
6
+ self.abstract_class = true
7
+
8
+ class << self
9
+ delegate :owner, to: :config
10
+
11
+ def box_type
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def box_name
16
+ @box_name ||= name.underscore
17
+ end
18
+
19
+ def config
20
+ @config ||= lookup_config.new(box_name)
21
+ end
22
+
23
+ def partition_buckets
24
+ @partition_buckets ||=
25
+ (0...config.partition_size)
26
+ .to_a
27
+ .each_with_object({}) do |x, m|
28
+ m[x] = (0...config.bucket_size)
29
+ .to_a
30
+ .select { |p| p % config.partition_size == x }
31
+ end
32
+ end
33
+
34
+ def bucket_partitions
35
+ @bucket_partitions ||=
36
+ partition_buckets.each_with_object({}) do |(partition, buckets), m|
37
+ buckets.each do |bucket|
38
+ m[bucket] = partition
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ enum status: {
45
+ pending: 0,
46
+ failed: 1,
47
+ delivered: 2,
48
+ discarded: 3
49
+ }
50
+
51
+ scope :for_processing, -> { where(status: :pending) }
52
+
53
+ validates :uuid, :event_key, :bucket, :payload, presence: true
54
+
55
+ delegate :box_name, :config, to: "self.class"
56
+
57
+ after_initialize do
58
+ self.uuid ||= SecureRandom.uuid if has_attribute?(:uuid)
59
+ end
60
+
61
+ def payload
62
+ if has_attribute?(:proto_payload)
63
+ proto_payload
64
+ else
65
+ self[:payload]
66
+ end
67
+ end
68
+
69
+ def payload=(value)
70
+ if has_attribute?(:proto_payload)
71
+ self.proto_payload = value
72
+ else
73
+ self[:payload] = value
74
+ end
75
+ end
76
+
77
+ def for_processing?
78
+ pending?
79
+ end
80
+
81
+ def options
82
+ options = (self[:options] || {}).symbolize_keys
83
+ options = default_options.deep_merge(extra_options).deep_merge(options)
84
+ options.symbolize_keys
85
+ end
86
+
87
+ def transports
88
+ if config.transports.empty?
89
+ raise Error, "Transports are not defined"
90
+ end
91
+
92
+ if has_attribute?(:event_name)
93
+ config.transports.fetch(event_name)
94
+ else
95
+ config.transports.fetch(:_all_)
96
+ end
97
+ end
98
+
99
+ def log_details
100
+ default_log_details.deep_merge(extra_log_details)
101
+ end
102
+
103
+ def payload_builder
104
+ nil
105
+ end
106
+
107
+ def touch_processed_at
108
+ self.processed_at = Time.current
109
+ end
110
+
111
+ def retriable?
112
+ config.max_retries > 0
113
+ end
114
+
115
+ def max_retries_exceeded?
116
+ return true unless retriable?
117
+
118
+ errors_count > config.max_retries
119
+ end
120
+
121
+ def increment_errors_counter
122
+ increment(:errors_count)
123
+ end
124
+
125
+ def add_error(ex_or_msg)
126
+ increment_errors_counter
127
+
128
+ return unless has_attribute?(:error_log)
129
+
130
+ self.error_log = "-----\n#{Time.zone.now} \n #{ex_or_msg}\n #{add_backtrace(ex_or_msg)}"
131
+ end
132
+
133
+ def partition
134
+ self.class.bucket_partitions.fetch(bucket)
135
+ end
136
+
137
+ private
138
+
139
+ def default_options
140
+ raise NotImplementedError
141
+ end
142
+
143
+ # Override in descendants
144
+ def extra_options
145
+ {}
146
+ end
147
+
148
+ def default_log_details
149
+ raise NotImplementedError
150
+ end
151
+
152
+ # Override in descendants
153
+ def extra_log_details
154
+ {}
155
+ end
156
+
157
+ def add_backtrace(ex)
158
+ return unless ex.respond_to?(:backtrace)
159
+ return if ex.backtrace.nil?
160
+
161
+ ex.backtrace.first(30).join("\n")
162
+ end
163
+ end
164
+ end
165
+ end