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