sbmt-outbox 5.0.4 → 6.0.1

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 +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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 838bf63855d71b86ca0ce7be93fa36c7b35496d8a450485bd1b8f41c29e76d6e
4
- data.tar.gz: b37e5777fad8e3d532bf8265513edf89354ffa5b3ce312a5da43b1dfa294165e
3
+ metadata.gz: d7ed66a6f328704ae2838f6c0d4b8f6c42788d546d227ef3035d6774bd84e38c
4
+ data.tar.gz: 5e7eac918594e2268757d25aba26d18da783d50d31f5cf61e03bb921c2390e6a
5
5
  SHA512:
6
- metadata.gz: 10ecf87fd36a86a85a29e003d4335cee56751a30720ff58fa7aa52395d3118eb1bb168c9685422da2f7dee758c34dc47b8ee32e8ecd0ec8bd103b83d8e041716
7
- data.tar.gz: 756e0a98911120354c14c27ca6732272c51c470a8fd2393d9318d60b60de126c959cf297c84a8b8c4ee75a26a2e48ae74396fd57f8d24a138cc849841da2be1f
6
+ metadata.gz: af1f6fa94feb668716256f9a9e4c462585da9341da9823b2d960c5af2e6b392fc4736eb3cfbfb80cc8a255399ba80c01b9b6cdf6f28644dbdbd1538e8342dd94
7
+ data.tar.gz: 35957d9cc3e507b708f6b74cb1da20a687dfd0b251321a03f22245e9de6ebd73ea40cfa5620ffe4a7b6a2822b8411582385ae5681b8fe809165ef2da88f11882
data/README.md CHANGED
@@ -117,8 +117,8 @@ create_table :my_outbox_items do |t|
117
117
  end
118
118
 
119
119
  add_index :my_outbox_items, :uuid, unique: true
120
- add_index :my_outbox_items, [:status, :bucket]
121
- add_index :my_outbox_items, [:event_name, :event_key]
120
+ add_index :my_outbox_items, [:status, :bucket, :errors_count]
121
+ add_index :my_outbox_items, [:event_name, :event_key, :id]
122
122
  add_index :my_outbox_items, :created_at
123
123
  ```
124
124
 
@@ -222,13 +222,51 @@ Rails.application.config.outbox.tap do |config|
222
222
  x[:batch_size] = 200
223
223
  end
224
224
 
225
- # optional
225
+ # optional (worker v1: DEPRECATED)
226
226
  config.worker.tap do |worker|
227
227
  # number of batches that one thread will process per rate interval
228
228
  worker[:rate_limit] = 10
229
229
  # rate interval in seconds
230
230
  worker[:rate_interval] = 60
231
231
  end
232
+
233
+ # optional (worker v2: default)
234
+ c.poller = ActiveSupport::OrderedOptions.new.tap do |pc|
235
+ # max parallel threads (per box-item, globally)
236
+ pc.concurrency = 6
237
+ # max threads count (per worker process)
238
+ pc.threads_count = 1
239
+ # maximum processing time of the batch, after which the batch will be considered hung and processing will be aborted
240
+ pc.general_timeout = 60
241
+ # poll buffer consists of regular items (errors_count = 0, i.e. without any processing errors) and retryable items (errors_count > 0)
242
+ # max poll buffer size = regular_items_batch_size + retryable_items_batch_size
243
+ pc.regular_items_batch_size = 200
244
+ pc.retryable_items_batch_size = 100
245
+
246
+ # poll tactic: default is optimal for most cases: rate limit + redis job-queue size threshold
247
+ # poll tactic: aggressive is for high-intencity data: without rate limits + redis job-queue size threshold
248
+ # poll tactic: low-priority is for low-intencity data: rate limits + redis job-queue size threshold + + redis job-queue lag threshold
249
+ pc.tactic = "default"
250
+ # number of batches that one thread will process per rate interval
251
+ pc.rate_limit = 60
252
+ # rate interval in seconds
253
+ pc.rate_interval = 60
254
+ # mix / max redis job queue thresholds per box-item for default / aggressive / low-priority poll tactics
255
+ pc.min_queue_size = 10
256
+ pc.max_queue_size = 100
257
+ # min redis job queue time lag threshold per box-item for low-priority poll tactic (in seconds)
258
+ pc.min_queue_timelag = 5
259
+ # throttling delay for default / aggressive / low-priority poll tactics (in seconds)
260
+ pc.queue_delay = 0.1
261
+ end
262
+ c.processor = ActiveSupport::OrderedOptions.new.tap do |pc|
263
+ # max threads count (per worker process)
264
+ pc.threads_count = 4
265
+ # maximum processing time of the batch, after which the batch will be considered hung and processing will be aborted
266
+ pc.general_timeout = 120
267
+ # BRPOP delay (in seconds) for polling redis job queue per box-item
268
+ pc.brpop_delay = 2
269
+ end
232
270
  end
233
271
  ```
234
272
 
@@ -292,7 +330,7 @@ outbox_items:
292
330
  - exponential_backoff
293
331
  ```
294
332
 
295
- #### Compacted log
333
+ #### Latest available
296
334
 
297
335
  This strategy ensures idempotency. In short, if a message fails and a later message with the same event_key has already been delivered, then you most likely do not want to re-deliver the first one when it is retried.
298
336
 
@@ -303,10 +341,10 @@ outbox_items:
303
341
  ...
304
342
  retry_strategies:
305
343
  - exponential_backoff
306
- - compacted_log
344
+ - latest_available
307
345
  ```
308
346
 
309
- The exponential backoff strategy should be used in conjunction with the compact log strategy, and it should come last to minimize the number of database queries.
347
+ The exponential backoff strategy should be used in conjunction with the latest available strategy, and it should come last to minimize the number of database queries.
310
348
 
311
349
  ### Partition strategies
312
350
 
@@ -428,13 +466,24 @@ end
428
466
 
429
467
  The gem is optionally integrated with OpenTelemetry. If your main application has `opentelemetry-*` gems, the tracing will be configured automatically.
430
468
 
431
- ## CLI Arguments
469
+ ## CLI Arguments (v1: DEPRECATED)
432
470
 
433
471
  | Key | Description |
434
472
  |-----------------------|---------------------------------------------------------------------------|
435
473
  | `--boxes or -b` | Outbox/Inbox processors to start` |
436
474
  | `--concurrency or -c` | Number of threads. Default 10. |
437
475
 
476
+ ## CLI Arguments (v2: default)
477
+
478
+ | Key | Description |
479
+ |----------------------------|----------------------------------------------------------------------|
480
+ | `--boxes or -b` | Outbox/Inbox processors to start` |
481
+ | `--concurrency or -c` | Number of process threads. Default 4. |
482
+ | `--poll-concurrency or -p` | Number of poller partitions. Default 6. |
483
+ | `--poll-threads or -n` | Number of poll threads. Default 1. |
484
+ | `--poll-tactic or -t` | Poll tactic. Default "default". |
485
+ | `--worker-version or -w` | Worker version. Default 2. |
486
+
438
487
  ## Development & Test
439
488
 
440
489
  ### Installation
@@ -5,6 +5,7 @@ module Sbmt
5
5
  class ProcessItem < Sbmt::Outbox::DryInteractor
6
6
  param :item_class, reader: :private
7
7
  param :item_id, reader: :private
8
+ option :worker_version, reader: :private, optional: true, default: -> { 1 }
8
9
 
9
10
  METRICS_COUNTERS = %i[error_counter retry_counter sent_counter fetch_error_counter discarded_counter].freeze
10
11
 
@@ -254,7 +255,7 @@ module Sbmt
254
255
  end
255
256
 
256
257
  def labels_for(item)
257
- {type: box_type, name: box_name, owner: owner, partition: item&.partition}
258
+ {worker_version: worker_version, type: box_type, name: box_name, owner: owner, partition: item&.partition}
258
259
  end
259
260
 
260
261
  def counters
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ module RetryStrategies
6
+ class Base < Outbox::DryInteractor
7
+ param :item
8
+
9
+ def call
10
+ raise NotImplementedError, "Implement #call for Sbmt::Outbox::RetryStrategies::Base"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -3,38 +3,8 @@
3
3
  module Sbmt
4
4
  module Outbox
5
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
6
+ class CompactedLog < LatestAvailable
7
+ # exists only as alias for backward compatibility
38
8
  end
39
9
  end
40
10
  end
@@ -3,13 +3,11 @@
3
3
  module Sbmt
4
4
  module Outbox
5
5
  module RetryStrategies
6
- class ExponentialBackoff < Outbox::DryInteractor
7
- param :outbox_item
8
-
6
+ class ExponentialBackoff < Base
9
7
  def call
10
- delay = backoff(outbox_item.config).interval_at(outbox_item.errors_count - 1)
8
+ delay = backoff(item.config).interval_at(item.errors_count - 1)
11
9
 
12
- still_early = outbox_item.processed_at + delay.seconds > Time.current
10
+ still_early = item.processed_at + delay.seconds > Time.current
13
11
 
14
12
  if still_early
15
13
  Failure(:skip_processing)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ module RetryStrategies
6
+ class LatestAvailable < Base
7
+ def call
8
+ unless item.has_attribute?(:event_key)
9
+ return Failure(:missing_event_key)
10
+ end
11
+
12
+ if item.event_key.nil?
13
+ return Failure(:empty_event_key)
14
+ end
15
+
16
+ if delivered_later?
17
+ Failure(:discard_item)
18
+ else
19
+ Success()
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def delivered_later?
26
+ scope = item.class
27
+ .where("id > ?", item)
28
+ .where(event_key: item.event_key)
29
+
30
+ if item.has_attribute?(:event_name) && item.event_name.present?
31
+ scope = scope.where(event_name: item.event_name)
32
+ end
33
+
34
+ scope.exists?
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Outbox
5
+ module RetryStrategies
6
+ class NoDelay < Base
7
+ def call
8
+ Success()
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -20,15 +20,16 @@ module Sbmt
20
20
  @config ||= lookup_config.new(box_name)
21
21
  end
22
22
 
23
+ def calc_bucket_partitions(count)
24
+ (0...count).to_a
25
+ .each_with_object({}) do |x, m|
26
+ m[x] = (0...config.bucket_size).to_a
27
+ .select { |p| p % count == x }
28
+ end
29
+ end
30
+
23
31
  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
+ @partition_buckets ||= calc_bucket_partitions(config.partition_size)
32
33
  end
33
34
 
34
35
  def bucket_partitions
@@ -23,7 +23,11 @@ module Sbmt
23
23
  end
24
24
 
25
25
  def partition_size
26
- @partition_size ||= (options[:partition_size] || 1).to_i
26
+ @partition_size ||= (partition_size_raw || 1).to_i
27
+ end
28
+
29
+ def partition_size_raw
30
+ @partition_size_raw ||= options[:partition_size]
27
31
  end
28
32
 
29
33
  def retention
@@ -47,7 +51,12 @@ module Sbmt
47
51
  end
48
52
 
49
53
  def retry_strategies
50
- @retry_strategies ||= Array.wrap(options[:retry_strategies]).map do |str_name|
54
+ return @retry_strategies if defined?(@retry_strategies)
55
+
56
+ configured_strategies = options[:retry_strategies]
57
+ strategies = configured_strategies.presence || %w[exponential_backoff latest_available]
58
+
59
+ @retry_strategies ||= Array.wrap(strategies).map do |str_name|
51
60
  "Sbmt::Outbox::RetryStrategies::#{str_name.camelize}".constantize
52
61
  end
53
62
  end
@@ -3,6 +3,8 @@
3
3
  Yabeda.configure do
4
4
  # error_counter retry_counter sent_counter fetch_error_counter discarded_counter
5
5
  group :outbox do
6
+ default_tag(:worker_version, 1)
7
+
6
8
  counter :created_counter,
7
9
  tags: %i[type name partition owner],
8
10
  comment: "The total number of created messages"
@@ -44,28 +46,53 @@ Yabeda.configure do
44
46
  end
45
47
 
46
48
  group :box_worker do
49
+ default_tag(:worker_version, 1)
50
+ default_tag(:worker_name, "worker")
51
+
47
52
  counter :job_counter,
48
- tags: %i[type name partition worker_number state],
53
+ tags: %i[type name partition state],
49
54
  comment: "The total number of processed jobs"
50
55
 
51
56
  counter :job_timeout_counter,
52
- tags: %i[type name partition_key worker_number],
57
+ tags: %i[type name partition_key],
53
58
  comment: "Requeue of a job that occurred while processing the batch"
54
59
 
55
60
  counter :job_items_counter,
56
- tags: %i[type name partition worker_number],
61
+ tags: %i[type name partition],
57
62
  comment: "The total number of processed items in jobs"
58
63
 
59
64
  histogram :job_execution_runtime,
60
65
  comment: "A histogram of the job execution time",
61
66
  unit: :seconds,
62
- tags: %i[type name partition worker_number],
67
+ tags: %i[type name partition],
63
68
  buckets: [0.5, 1, 2.5, 5, 10, 15, 30, 45, 60, 90, 120, 180, 240, 300, 600]
64
69
 
65
70
  histogram :item_execution_runtime,
66
71
  comment: "A histogram of the item execution time",
67
72
  unit: :seconds,
68
- tags: %i[type name partition worker_number],
73
+ tags: %i[type name partition],
74
+ buckets: [0.5, 1, 2.5, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 300]
75
+
76
+ counter :batches_per_poll_counter,
77
+ tags: %i[type name partition],
78
+ comment: "The total number of poll batches per poll"
79
+
80
+ gauge :redis_job_queue_size,
81
+ tags: %i[type name partition],
82
+ comment: "The total size of redis job queue"
83
+
84
+ gauge :redis_job_queue_time_lag,
85
+ tags: %i[type name partition],
86
+ comment: "The total time lag of redis job queue"
87
+
88
+ counter :poll_throttling_counter,
89
+ tags: %i[type name partition throttler status],
90
+ comment: "The total number of poll throttlings"
91
+
92
+ histogram :poll_throttling_runtime,
93
+ comment: "A histogram of the poll throttling time",
94
+ unit: :seconds,
95
+ tags: %i[type name partition throttler],
69
96
  buckets: [0.5, 1, 2.5, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 300]
70
97
  end
71
98
  end
@@ -44,8 +44,8 @@ module Outbox
44
44
  end
45
45
 
46
46
  add_index :#{table_name}, :uuid, unique: true
47
- add_index :#{table_name}, [:status, :bucket]
48
- add_index :#{table_name}, :event_key
47
+ add_index :#{table_name}, [:status, :bucket, :errors_count]
48
+ add_index :#{table_name}, [:event_key, :id]
49
49
  add_index :#{table_name}, :created_at
50
50
  RUBY
51
51
 
@@ -20,15 +20,47 @@ module Sbmt
20
20
  option :concurrency,
21
21
  aliases: "-c",
22
22
  type: :numeric,
23
- default: 10,
24
- desc: "Number of threads"
23
+ desc: "Number of threads (processor)"
24
+ option :poll_concurrency,
25
+ aliases: "-p",
26
+ type: :numeric,
27
+ desc: "Number of poller partitions"
28
+ option :poll_threads,
29
+ aliases: "-n",
30
+ type: :numeric,
31
+ desc: "Number of threads (poller)"
32
+ option :poll_tactic,
33
+ aliases: "-t",
34
+ type: :string,
35
+ desc: "Poll tactic: [default, low-priority, aggressive]"
36
+ option :worker_version,
37
+ aliases: "-w",
38
+ type: :numeric,
39
+ desc: "Worker version: [1 | 2]"
25
40
  def start
26
41
  load_environment
27
42
 
28
- worker = Sbmt::Outbox::Worker.new(
29
- boxes: format_boxes(options[:box]),
30
- concurrency: options[:concurrency]
31
- )
43
+ version = options[:worker_version] || Outbox.default_worker_version
44
+
45
+ boxes = format_boxes(options[:box])
46
+ check_deprecations!(boxes, version)
47
+
48
+ worker = if version == 1
49
+ Sbmt::Outbox::V1::Worker.new(
50
+ boxes: boxes,
51
+ concurrency: options[:concurrency] || 10
52
+ )
53
+ elsif version == 2
54
+ Sbmt::Outbox::V2::Worker.new(
55
+ boxes: boxes,
56
+ poll_tactic: options[:poll_tactic],
57
+ poller_threads_count: options[:poll_threads],
58
+ poller_partitions_count: options[:poll_concurrency],
59
+ processor_concurrency: options[:concurrency] || 4
60
+ )
61
+ else
62
+ raise "Worker version #{version} is invalid, available versions: 1|2"
63
+ end
32
64
 
33
65
  Sbmt::Outbox.current_worker = worker
34
66
 
@@ -45,11 +77,22 @@ module Sbmt
45
77
 
46
78
  private
47
79
 
80
+ def check_deprecations!(boxes, version)
81
+ return unless version == 2
82
+
83
+ boxes.each do |item_class|
84
+ next if item_class.config.partition_size_raw.blank?
85
+
86
+ raise "partition_size option is invalid and cannot be used with worker v2, please remove it from config/outbox.yml for #{item_class.name.underscore}"
87
+ end
88
+ end
89
+
48
90
  def load_environment
49
91
  load(lookup_outboxfile)
50
92
 
51
93
  require "sbmt/outbox"
52
- require "sbmt/outbox/worker"
94
+ require "sbmt/outbox/v1/worker"
95
+ require "sbmt/outbox/v2/worker"
53
96
  end
54
97
 
55
98
  def lookup_outboxfile
@@ -26,6 +26,31 @@ module Sbmt
26
26
  c.rate_interval = 60
27
27
  c.shuffle_jobs = true
28
28
  end
29
+ c.default_worker_version = 2
30
+
31
+ # worker v2
32
+ c.poller = ActiveSupport::OrderedOptions.new.tap do |pc|
33
+ pc.concurrency = 6
34
+ pc.threads_count = 2
35
+ pc.general_timeout = 60
36
+ pc.regular_items_batch_size = 200
37
+ pc.retryable_items_batch_size = 100
38
+
39
+ pc.tactic = "default"
40
+ pc.rate_limit = 60
41
+ pc.rate_interval = 60
42
+ pc.min_queue_size = 10
43
+ pc.max_queue_size = 100
44
+ pc.min_queue_timelag = 5
45
+ pc.queue_delay = 0.1
46
+ end
47
+ c.processor = ActiveSupport::OrderedOptions.new.tap do |pc|
48
+ pc.threads_count = 4
49
+ pc.general_timeout = 120
50
+ pc.cutoff_timeout = 60
51
+ pc.brpop_delay = 1
52
+ end
53
+
29
54
  c.database_switcher = "Sbmt::Outbox::DatabaseSwitcher"
30
55
  c.batch_process_middlewares = []
31
56
  c.item_process_middlewares = []
@@ -5,6 +5,12 @@ module Sbmt
5
5
  class Logger
6
6
  delegate :logger, to: :Rails
7
7
 
8
+ def log_debug(message, **params)
9
+ with_tags(**params) do
10
+ logger.debug(message)
11
+ end
12
+ end
13
+
8
14
  def log_info(message, **params)
9
15
  with_tags(**params) do
10
16
  logger.info(message)
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sbmt/outbox/v1/throttler"
4
+
5
+ module Sbmt
6
+ module Outbox
7
+ module V1
8
+ class ThreadPool
9
+ BREAK = Object.new.freeze
10
+ SKIPPED = Object.new.freeze
11
+ PROCESSED = Object.new.freeze
12
+
13
+ def initialize(&block)
14
+ self.task_source = block
15
+ self.task_mutex = Mutex.new
16
+ self.stopped = true
17
+ end
18
+
19
+ def next_task
20
+ task_mutex.synchronize do
21
+ return if stopped
22
+ item = task_source.call
23
+
24
+ if item == BREAK
25
+ self.stopped = true
26
+ return
27
+ end
28
+
29
+ item
30
+ end
31
+ end
32
+
33
+ def start(concurrency:)
34
+ self.stopped = false
35
+ result = run_threads(count: concurrency) do |item|
36
+ yield worker_number, item
37
+ end
38
+
39
+ raise result if result.is_a?(Exception)
40
+ nil
41
+ ensure
42
+ self.stopped = true
43
+ end
44
+
45
+ def stop
46
+ self.stopped = true
47
+ end
48
+
49
+ def worker_number
50
+ Thread.current["thread_pool_worker_number:#{object_id}"]
51
+ end
52
+
53
+ private
54
+
55
+ attr_accessor :task_source, :task_mutex, :stopped
56
+
57
+ def run_threads(count:)
58
+ exception = nil
59
+
60
+ in_threads(count: count) do |worker_num|
61
+ self.worker_number = worker_num
62
+ # We don't want to start all threads at the same time
63
+ random_sleep = rand * (worker_num + 1)
64
+
65
+ throttler = Throttler.new(
66
+ limit: Outbox.config.worker.rate_limit,
67
+ interval: Outbox.config.worker.rate_interval + random_sleep
68
+ )
69
+
70
+ sleep(random_sleep)
71
+
72
+ last_result = nil
73
+ until exception
74
+ throttler.wait if last_result == PROCESSED
75
+ item = next_task
76
+ break unless item
77
+
78
+ begin
79
+ last_result = yield item
80
+ rescue Exception => e # rubocop:disable Lint/RescueException
81
+ exception = e
82
+ end
83
+ end
84
+ end
85
+
86
+ exception
87
+ end
88
+
89
+ def in_threads(count:)
90
+ threads = []
91
+
92
+ Thread.handle_interrupt(Exception => :never) do
93
+ Thread.handle_interrupt(Exception => :immediate) do
94
+ count.times do |i|
95
+ threads << Thread.new { yield(i) }
96
+ end
97
+ threads.map(&:value)
98
+ end
99
+ ensure
100
+ threads.each(&:kill)
101
+ end
102
+ end
103
+
104
+ def worker_number=(num)
105
+ Thread.current["thread_pool_worker_number:#{object_id}"] = num
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end