sbmt-outbox 5.0.4 → 6.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +56 -7
  3. data/app/interactors/sbmt/outbox/process_item.rb +2 -1
  4. data/app/interactors/sbmt/outbox/retry_strategies/base.rb +15 -0
  5. data/app/interactors/sbmt/outbox/retry_strategies/compacted_log.rb +2 -32
  6. data/app/interactors/sbmt/outbox/retry_strategies/exponential_backoff.rb +3 -5
  7. data/app/interactors/sbmt/outbox/retry_strategies/latest_available.rb +39 -0
  8. data/app/interactors/sbmt/outbox/retry_strategies/no_delay.rb +13 -0
  9. data/app/models/sbmt/outbox/base_item.rb +9 -8
  10. data/app/models/sbmt/outbox/base_item_config.rb +11 -2
  11. data/config/initializers/yabeda.rb +32 -5
  12. data/lib/generators/helpers/migration.rb +2 -2
  13. data/lib/sbmt/outbox/cli.rb +50 -7
  14. data/lib/sbmt/outbox/engine.rb +25 -0
  15. data/lib/sbmt/outbox/logger.rb +6 -0
  16. data/lib/sbmt/outbox/v1/thread_pool.rb +110 -0
  17. data/lib/sbmt/outbox/v1/throttler.rb +54 -0
  18. data/lib/sbmt/outbox/v1/worker.rb +231 -0
  19. data/lib/sbmt/outbox/v2/box_processor.rb +148 -0
  20. data/lib/sbmt/outbox/v2/poll_throttler/base.rb +43 -0
  21. data/lib/sbmt/outbox/v2/poll_throttler/composite.rb +42 -0
  22. data/lib/sbmt/outbox/v2/poll_throttler/fixed_delay.rb +28 -0
  23. data/lib/sbmt/outbox/v2/poll_throttler/noop.rb +17 -0
  24. data/lib/sbmt/outbox/v2/poll_throttler/rate_limited.rb +24 -0
  25. data/lib/sbmt/outbox/v2/poll_throttler/redis_queue_size.rb +46 -0
  26. data/lib/sbmt/outbox/v2/poll_throttler/redis_queue_time_lag.rb +45 -0
  27. data/lib/sbmt/outbox/v2/poll_throttler.rb +49 -0
  28. data/lib/sbmt/outbox/v2/poller.rb +180 -0
  29. data/lib/sbmt/outbox/v2/processor.rb +101 -0
  30. data/lib/sbmt/outbox/v2/redis_job.rb +42 -0
  31. data/lib/sbmt/outbox/v2/tasks/base.rb +48 -0
  32. data/lib/sbmt/outbox/v2/tasks/default.rb +17 -0
  33. data/lib/sbmt/outbox/v2/tasks/poll.rb +34 -0
  34. data/lib/sbmt/outbox/v2/tasks/process.rb +31 -0
  35. data/lib/sbmt/outbox/v2/thread_pool.rb +152 -0
  36. data/lib/sbmt/outbox/v2/throttler.rb +13 -0
  37. data/lib/sbmt/outbox/v2/worker.rb +52 -0
  38. data/lib/sbmt/outbox/version.rb +1 -1
  39. data/lib/sbmt/outbox.rb +16 -2
  40. metadata +40 -4
  41. data/lib/sbmt/outbox/thread_pool.rb +0 -108
  42. data/lib/sbmt/outbox/throttler.rb +0 -52
  43. data/lib/sbmt/outbox/worker.rb +0 -233
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redlock"
4
+ require "sbmt/outbox/v2/poller"
5
+ require "sbmt/outbox/v2/processor"
6
+
7
+ module Sbmt
8
+ module Outbox
9
+ module V2
10
+ class Worker
11
+ def initialize(boxes:, poll_tactic: nil, processor_concurrency: nil, poller_partitions_count: nil, poller_threads_count: nil)
12
+ @poller = Poller.new(boxes, throttler_tactic: poll_tactic, threads_count: poller_threads_count, partitions_count: poller_partitions_count)
13
+ @processor = Processor.new(boxes, threads_count: processor_concurrency)
14
+ end
15
+
16
+ def start
17
+ start_async
18
+
19
+ loop do
20
+ sleep 0.1
21
+ break unless @poller.started && @processor.started
22
+ end
23
+ end
24
+
25
+ def start_async
26
+ @poller.start
27
+ @processor.start
28
+
29
+ loop do
30
+ sleep(0.1)
31
+ break if ready?
32
+ end
33
+ end
34
+
35
+ def stop
36
+ @poller.stop
37
+ @processor.stop
38
+ end
39
+
40
+ def ready?
41
+ @poller.ready? && @processor.ready?
42
+ end
43
+
44
+ def alive?
45
+ return false unless ready?
46
+
47
+ @poller.alive?(@poller.lock_timeout) && @processor.alive?(@processor.lock_timeout)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Sbmt
4
4
  module Outbox
5
- VERSION = "5.0.4"
5
+ VERSION = "6.0.0"
6
6
  end
7
7
  end
data/lib/sbmt/outbox.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails"
4
+ require "active_job"
5
+ require "active_record"
4
6
  require "dry-initializer"
5
7
  require "dry-monads"
6
8
  require "dry/monads/do"
@@ -53,12 +55,24 @@ module Sbmt
53
55
  @logger ||= Sbmt::Outbox::Logger.new
54
56
  end
55
57
 
58
+ def poller_config
59
+ @poller_config ||= config.poller
60
+ end
61
+
62
+ def processor_config
63
+ @processor_config ||= config.processor
64
+ end
65
+
66
+ def default_worker_version
67
+ @default_worker_version ||= config.default_worker_version&.to_i || 2
68
+ end
69
+
56
70
  def active_record_base_class
57
- @active_record_base_class ||= config.active_record_base_class.safe_constantize || ActiveRecord::Base
71
+ @active_record_base_class ||= config.active_record_base_class.safe_constantize || ::ActiveRecord::Base
58
72
  end
59
73
 
60
74
  def active_job_base_class
61
- @active_job_base_class ||= config.active_job_base_class.safe_constantize || ActiveJob::Base
75
+ @active_job_base_class ||= config.active_job_base_class.safe_constantize || ::ActiveJob::Base
62
76
  end
63
77
 
64
78
  def error_tracker
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sbmt-outbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.4
4
+ version: 6.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sbermarket Ruby-Platform Team
@@ -188,6 +188,20 @@ dependencies:
188
188
  - - "~>"
189
189
  - !ruby/object:Gem::Version
190
190
  version: '0.5'
191
+ - !ruby/object:Gem::Dependency
192
+ name: ruby-limiter
193
+ requirement: !ruby/object:Gem::Requirement
194
+ requirements:
195
+ - - "~>"
196
+ - !ruby/object:Gem::Version
197
+ version: '2.3'
198
+ type: :runtime
199
+ prerelease: false
200
+ version_requirements: !ruby/object:Gem::Requirement
201
+ requirements:
202
+ - - "~>"
203
+ - !ruby/object:Gem::Version
204
+ version: '2.3'
191
205
  - !ruby/object:Gem::Dependency
192
206
  name: appraisal
193
207
  requirement: !ruby/object:Gem::Requirement
@@ -506,8 +520,11 @@ files:
506
520
  - app/interactors/sbmt/outbox/partition_strategies/hash_partitioning.rb
507
521
  - app/interactors/sbmt/outbox/partition_strategies/number_partitioning.rb
508
522
  - app/interactors/sbmt/outbox/process_item.rb
523
+ - app/interactors/sbmt/outbox/retry_strategies/base.rb
509
524
  - app/interactors/sbmt/outbox/retry_strategies/compacted_log.rb
510
525
  - app/interactors/sbmt/outbox/retry_strategies/exponential_backoff.rb
526
+ - app/interactors/sbmt/outbox/retry_strategies/latest_available.rb
527
+ - app/interactors/sbmt/outbox/retry_strategies/no_delay.rb
511
528
  - app/jobs/sbmt/outbox/base_delete_stale_items_job.rb
512
529
  - app/jobs/sbmt/outbox/delete_stale_inbox_items_job.rb
513
530
  - app/jobs/sbmt/outbox/delete_stale_outbox_items_job.rb
@@ -563,10 +580,29 @@ files:
563
580
  - lib/sbmt/outbox/redis_client_factory.rb
564
581
  - lib/sbmt/outbox/tasks/delete_failed_items.rake
565
582
  - lib/sbmt/outbox/tasks/retry_failed_items.rake
566
- - lib/sbmt/outbox/thread_pool.rb
567
- - lib/sbmt/outbox/throttler.rb
583
+ - lib/sbmt/outbox/v1/thread_pool.rb
584
+ - lib/sbmt/outbox/v1/throttler.rb
585
+ - lib/sbmt/outbox/v1/worker.rb
586
+ - lib/sbmt/outbox/v2/box_processor.rb
587
+ - lib/sbmt/outbox/v2/poll_throttler.rb
588
+ - lib/sbmt/outbox/v2/poll_throttler/base.rb
589
+ - lib/sbmt/outbox/v2/poll_throttler/composite.rb
590
+ - lib/sbmt/outbox/v2/poll_throttler/fixed_delay.rb
591
+ - lib/sbmt/outbox/v2/poll_throttler/noop.rb
592
+ - lib/sbmt/outbox/v2/poll_throttler/rate_limited.rb
593
+ - lib/sbmt/outbox/v2/poll_throttler/redis_queue_size.rb
594
+ - lib/sbmt/outbox/v2/poll_throttler/redis_queue_time_lag.rb
595
+ - lib/sbmt/outbox/v2/poller.rb
596
+ - lib/sbmt/outbox/v2/processor.rb
597
+ - lib/sbmt/outbox/v2/redis_job.rb
598
+ - lib/sbmt/outbox/v2/tasks/base.rb
599
+ - lib/sbmt/outbox/v2/tasks/default.rb
600
+ - lib/sbmt/outbox/v2/tasks/poll.rb
601
+ - lib/sbmt/outbox/v2/tasks/process.rb
602
+ - lib/sbmt/outbox/v2/thread_pool.rb
603
+ - lib/sbmt/outbox/v2/throttler.rb
604
+ - lib/sbmt/outbox/v2/worker.rb
568
605
  - lib/sbmt/outbox/version.rb
569
- - lib/sbmt/outbox/worker.rb
570
606
  homepage: https://github.com/SberMarket-Tech/sbmt-outbox
571
607
  licenses:
572
608
  - MIT
@@ -1,108 +0,0 @@
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
@@ -1,52 +0,0 @@
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
@@ -1,233 +0,0 @@
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