sbmt-outbox 5.0.4 → 6.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 (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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 838bf63855d71b86ca0ce7be93fa36c7b35496d8a450485bd1b8f41c29e76d6e
4
- data.tar.gz: b37e5777fad8e3d532bf8265513edf89354ffa5b3ce312a5da43b1dfa294165e
3
+ metadata.gz: 818de79692d37138c630bf2fce625a54becf3a8fa71eafe3c5435d573dbcf665
4
+ data.tar.gz: 2516c414d14a60851d53fd9f821845179ac12cfb88176a18c1f60d8ba8e38bc9
5
5
  SHA512:
6
- metadata.gz: 10ecf87fd36a86a85a29e003d4335cee56751a30720ff58fa7aa52395d3118eb1bb168c9685422da2f7dee758c34dc47b8ee32e8ecd0ec8bd103b83d8e041716
7
- data.tar.gz: 756e0a98911120354c14c27ca6732272c51c470a8fd2393d9318d60b60de126c959cf297c84a8b8c4ee75a26a2e48ae74396fd57f8d24a138cc849841da2be1f
6
+ metadata.gz: 0e1c0f1f55ef75e5c73352f0789dc51559656f5868a64f2ca8b0c97c10b2865cd37189dc6b3a5d0346673251800ed1c652df951734a5fe77029ca87dbb0fc1a3
7
+ data.tar.gz: f0aba9307dcfac3c8e330f1a8a1e40e33439a624c4c40c34ea9223c53c25acff2540ad03c72b00a71c1e963885ecee4e6befc5e5e94d4d78e13ecf6acac4c69e
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 = 2
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