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,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redlock"
4
+ require "sbmt/outbox/thread_pool"
5
+
6
+ module Sbmt
7
+ module Outbox
8
+ class Worker
9
+ Job = Struct.new(
10
+ :item_class,
11
+ :partition,
12
+ :buckets,
13
+ :log_tags,
14
+ :yabeda_labels,
15
+ :resource_key,
16
+ :resource_path,
17
+ keyword_init: true
18
+ )
19
+
20
+ delegate :config,
21
+ :logger,
22
+ :batch_process_middlewares,
23
+ to: "Sbmt::Outbox"
24
+ delegate :stop, to: :thread_pool
25
+ delegate :general_timeout, :cutoff_timeout, :batch_size, to: "Sbmt::Outbox.config.process_items"
26
+ delegate :job_counter,
27
+ :job_execution_runtime,
28
+ :item_execution_runtime,
29
+ :job_items_counter,
30
+ :job_timeout_counter,
31
+ to: "Yabeda.box_worker"
32
+
33
+ def initialize(boxes:, concurrency: 10)
34
+ self.queue = Queue.new
35
+ build_jobs(boxes).each { |job| queue << job }
36
+ self.thread_pool = ThreadPool.new { queue.pop }
37
+ self.concurrency = [concurrency, queue.size].min
38
+ self.thread_workers = {}
39
+ init_redis
40
+ end
41
+
42
+ def start
43
+ raise "Outbox is already started" if started
44
+ self.started = true
45
+ self.thread_workers = {}
46
+
47
+ thread_pool.start(concurrency: concurrency) do |worker_number, job|
48
+ touch_thread_worker!
49
+ result = ThreadPool::PROCESSED
50
+ logger.with_tags(**job.log_tags.merge(worker: worker_number)) do
51
+ lock_manager.lock("#{job.resource_path}:lock", general_timeout * 1000) do |locked|
52
+ labels = job.yabeda_labels.merge(worker_number: worker_number)
53
+
54
+ if locked
55
+ job_execution_runtime.measure(labels) do
56
+ ::Rails.application.executor.wrap do
57
+ safe_process_job(job, worker_number, labels)
58
+ end
59
+ end
60
+ else
61
+ result = ThreadPool::SKIPPED
62
+ logger.log_info("Skip processing already locked #{job.resource_key}")
63
+ end
64
+
65
+ job_counter.increment(labels.merge(state: locked ? "processed" : "skipped"), by: 1)
66
+ end
67
+ end
68
+
69
+ result
70
+ ensure
71
+ queue << job
72
+ end
73
+ rescue => e
74
+ Outbox.error_tracker.error(e)
75
+ raise
76
+ ensure
77
+ self.started = false
78
+ end
79
+
80
+ def ready?
81
+ started && thread_workers.any?
82
+ end
83
+
84
+ def alive?
85
+ return false unless started
86
+
87
+ deadline = Time.current - general_timeout
88
+ thread_workers.all? do |_worker_number, time|
89
+ deadline < time
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ attr_accessor :queue, :thread_pool, :concurrency, :lock_manager, :redis, :thread_workers, :started
96
+
97
+ def init_redis
98
+ self.redis = ConnectionPool::Wrapper.new(size: concurrency) { RedisClientFactory.build(config.redis) }
99
+
100
+ client = if Gem::Version.new(Redlock::VERSION) >= Gem::Version.new("2.0.0")
101
+ redis
102
+ else
103
+ ConnectionPool::Wrapper.new(size: concurrency) { Redis.new(config.redis) }
104
+ end
105
+
106
+ self.lock_manager = Redlock::Client.new([client], retry_count: 0)
107
+ end
108
+
109
+ def build_jobs(boxes)
110
+ res = boxes.map do |item_class|
111
+ partitions = (0...item_class.config.partition_size).to_a
112
+ partitions.map do |partition|
113
+ buckets = item_class.partition_buckets.fetch(partition)
114
+ resource_key = "#{item_class.box_name}/#{partition}"
115
+
116
+ Job.new(
117
+ item_class: item_class,
118
+ partition: partition,
119
+ buckets: buckets,
120
+ log_tags: {
121
+ box_type: item_class.box_type,
122
+ box_name: item_class.box_name,
123
+ box_partition: partition,
124
+ trace_id: nil
125
+ },
126
+ yabeda_labels: {
127
+ type: item_class.box_type,
128
+ name: item_class.box_name,
129
+ partition: partition
130
+ },
131
+ resource_key: resource_key,
132
+ resource_path: "sbmt/outbox/worker/#{resource_key}"
133
+ )
134
+ end
135
+ end.flatten
136
+
137
+ res.shuffle! if Outbox.config.worker.shuffle_jobs
138
+ res
139
+ end
140
+
141
+ def touch_thread_worker!
142
+ thread_workers[thread_pool.worker_number] = Time.current
143
+ end
144
+
145
+ def safe_process_job(job, worker_number, labels)
146
+ middlewares = Middleware::Builder.new(batch_process_middlewares)
147
+
148
+ middlewares.call(job) do
149
+ start_id ||= redis.call("GETDEL", "#{job.resource_path}:last_id").to_i + 1
150
+ logger.log_info("Start processing #{job.resource_key} from id #{start_id}")
151
+ process_job_with_timeouts(job, start_id, labels)
152
+ end
153
+ rescue => e
154
+ log_fatal(e, job, worker_number)
155
+ track_fatal(e, job, worker_number)
156
+ end
157
+
158
+ def process_job_with_timeouts(job, start_id, labels)
159
+ count = 0
160
+ last_id = nil
161
+ lock_timer = Cutoff.new(general_timeout)
162
+ requeue_timer = Cutoff.new(cutoff_timeout)
163
+
164
+ process_job(job, start_id, labels) do |item|
165
+ job_items_counter.increment(labels, by: 1)
166
+ last_id = item.id
167
+ count += 1
168
+ lock_timer.checkpoint!
169
+ requeue_timer.checkpoint!
170
+ end
171
+
172
+ logger.log_info("Finish processing #{job.resource_key} at id #{last_id}")
173
+ rescue Cutoff::CutoffExceededError
174
+ job_timeout_counter.increment(labels, by: 1)
175
+
176
+ msg = if lock_timer.exceeded?
177
+ "Lock timeout"
178
+ elsif requeue_timer.exceeded?
179
+ redis.call("SET", "#{job.resource_path}:last_id", last_id, "EX", general_timeout) if last_id
180
+ "Requeue timeout"
181
+ end
182
+ raise "Unknown timer has been timed out" unless msg
183
+
184
+ logger.log_info("#{msg} while processing #{job.resource_key} at id #{last_id}")
185
+ end
186
+
187
+ def process_job(job, start_id, labels)
188
+ Outbox.database_switcher.use_slave do
189
+ item_class = job.item_class
190
+
191
+ scope = item_class
192
+ .for_processing
193
+ .select(:id)
194
+
195
+ if item_class.has_attribute?(:bucket)
196
+ scope = scope.where(bucket: job.buckets)
197
+ elsif job.partition > 0
198
+ raise "Could not filter by partition #{job.resource_key}"
199
+ end
200
+
201
+ scope.find_each(start: start_id, batch_size: batch_size) do |item|
202
+ touch_thread_worker!
203
+ item_execution_runtime.measure(labels) do
204
+ Outbox.database_switcher.use_master do
205
+ ProcessItem.call(job.item_class, item.id)
206
+ end
207
+ yield item
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ def log_fatal(e, job, worker_number)
214
+ backtrace = e.backtrace.join("\n") if e.respond_to?(:backtrace)
215
+
216
+ logger.log_error(
217
+ "Failed processing #{job.resource_key} with error: #{e.class} #{e.message}",
218
+ backtrace: backtrace
219
+ )
220
+ end
221
+
222
+ def track_fatal(e, job, worker_number)
223
+ job_counter
224
+ .increment(
225
+ job.yabeda_labels.merge(worker_number: worker_number, state: "failed"),
226
+ by: 1
227
+ )
228
+
229
+ Outbox.error_tracker.error(e, **job.log_tags)
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "dry-initializer"
5
+ require "dry-monads"
6
+ require "dry/monads/do"
7
+ require "yabeda"
8
+ require "exponential_backoff"
9
+ require "cutoff"
10
+ require "http_health_check"
11
+ require "redis-client"
12
+ require "connection_pool"
13
+
14
+ begin
15
+ require "sentry-rails"
16
+
17
+ require_relative "outbox/middleware/sentry/tracing_batch_process_middleware"
18
+ require_relative "outbox/middleware/sentry/tracing_item_process_middleware"
19
+ rescue LoadError
20
+ # optional dependency
21
+ end
22
+
23
+ begin
24
+ require_relative "outbox/instrumentation/open_telemetry_loader"
25
+ rescue LoadError
26
+ # optional dependency
27
+ end
28
+
29
+ require_relative "outbox/version"
30
+ require_relative "outbox/errors"
31
+ require_relative "outbox/error_tracker"
32
+ require_relative "outbox/logger"
33
+ require_relative "outbox/database_switcher"
34
+ require_relative "outbox/engine"
35
+ require_relative "outbox/middleware/builder"
36
+ require_relative "outbox/middleware/runner"
37
+ require_relative "outbox/probes/probe"
38
+ require_relative "outbox/redis_client_factory"
39
+
40
+ module Sbmt
41
+ module Outbox
42
+ class << self
43
+ attr_accessor :current_worker
44
+ end
45
+
46
+ module_function
47
+
48
+ def config
49
+ @config ||= Rails.application.config.outbox
50
+ end
51
+
52
+ def logger
53
+ @logger ||= Sbmt::Outbox::Logger.new
54
+ end
55
+
56
+ def active_record_base_class
57
+ @active_record_base_class ||= config.active_record_base_class.safe_constantize || ActiveRecord::Base
58
+ end
59
+
60
+ def active_job_base_class
61
+ @active_job_base_class ||= config.active_job_base_class.safe_constantize || ActiveJob::Base
62
+ end
63
+
64
+ def error_tracker
65
+ @error_tracker ||= config.error_tracker.constantize
66
+ end
67
+
68
+ def database_switcher
69
+ @database_switcher ||= config.database_switcher.constantize
70
+ end
71
+
72
+ def outbox_item_classes
73
+ @outbox_item_classes ||= if config.outbox_item_classes.empty?
74
+ (yaml_config[:outbox_items] || {}).keys.map { |name| name.camelize.constantize }
75
+ else
76
+ config.outbox_item_classes.map(&:constantize)
77
+ end
78
+ end
79
+
80
+ def inbox_item_classes
81
+ @inbox_item_classes ||= if config.inbox_item_classes.empty?
82
+ (yaml_config[:inbox_items] || {}).keys.map { |name| name.camelize.constantize }
83
+ else
84
+ config.inbox_item_classes.map(&:constantize)
85
+ end
86
+ end
87
+
88
+ def item_classes
89
+ @item_classes ||= outbox_item_classes + inbox_item_classes
90
+ end
91
+
92
+ def item_classes_by_name
93
+ @item_classes_by_name ||= item_classes.index_by(&:box_name)
94
+ end
95
+
96
+ def yaml_config
97
+ return @yaml_config if defined?(@yaml_config)
98
+
99
+ paths = if config.paths.empty?
100
+ [Rails.root.join("config/outbox.yml").to_s]
101
+ else
102
+ config.paths
103
+ end
104
+
105
+ @yaml_config = paths.each_with_object({}.with_indifferent_access) do |path, memo|
106
+ memo.deep_merge!(
107
+ load_yaml(path)
108
+ )
109
+ end
110
+ end
111
+
112
+ def load_yaml(path)
113
+ data = if Gem::Version.new(Psych::VERSION) >= Gem::Version.new("4.0.0")
114
+ YAML.safe_load(ERB.new(File.read(path)).result, aliases: true)
115
+ else
116
+ YAML.safe_load(ERB.new(File.read(path)).result, [], [], true)
117
+ end
118
+
119
+ data
120
+ .with_indifferent_access
121
+ .fetch(Rails.env, {})
122
+ end
123
+
124
+ def batch_process_middlewares
125
+ @batch_process_middlewares ||= config.batch_process_middlewares.map(&:constantize)
126
+ end
127
+
128
+ def item_process_middlewares
129
+ @item_process_middlewares ||= config.item_process_middlewares.map(&:constantize)
130
+ end
131
+
132
+ def create_item_middlewares
133
+ @create_item_middlewares ||= config.create_item_middlewares.map(&:constantize)
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sbmt/outbox"