sbmt-outbox 5.0.4 → 6.0.1

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 (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 +45 -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 +109 -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.1"
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.1
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