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,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