sbmt-outbox 5.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +440 -0
- data/Rakefile +3 -0
- data/app/interactors/sbmt/outbox/base_create_item.rb +55 -0
- data/app/interactors/sbmt/outbox/create_inbox_item.rb +10 -0
- data/app/interactors/sbmt/outbox/create_outbox_item.rb +10 -0
- data/app/interactors/sbmt/outbox/dry_interactor.rb +16 -0
- data/app/interactors/sbmt/outbox/partition_strategies/hash_partitioning.rb +20 -0
- data/app/interactors/sbmt/outbox/partition_strategies/number_partitioning.rb +26 -0
- data/app/interactors/sbmt/outbox/process_item.rb +269 -0
- data/app/interactors/sbmt/outbox/retry_strategies/compacted_log.rb +41 -0
- data/app/interactors/sbmt/outbox/retry_strategies/exponential_backoff.rb +34 -0
- data/app/jobs/sbmt/outbox/base_delete_stale_items_job.rb +78 -0
- data/app/jobs/sbmt/outbox/delete_stale_inbox_items_job.rb +15 -0
- data/app/jobs/sbmt/outbox/delete_stale_outbox_items_job.rb +15 -0
- data/app/models/sbmt/outbox/base_item.rb +165 -0
- data/app/models/sbmt/outbox/base_item_config.rb +106 -0
- data/app/models/sbmt/outbox/inbox_item.rb +38 -0
- data/app/models/sbmt/outbox/inbox_item_config.rb +13 -0
- data/app/models/sbmt/outbox/outbox_item.rb +52 -0
- data/app/models/sbmt/outbox/outbox_item_config.rb +13 -0
- data/config/initializers/schked.rb +9 -0
- data/config/initializers/yabeda.rb +71 -0
- data/config/schedule.rb +9 -0
- data/exe/outbox +16 -0
- data/lib/generators/helpers/config.rb +46 -0
- data/lib/generators/helpers/initializer.rb +41 -0
- data/lib/generators/helpers/items.rb +17 -0
- data/lib/generators/helpers/migration.rb +73 -0
- data/lib/generators/helpers/paas.rb +17 -0
- data/lib/generators/helpers/values.rb +49 -0
- data/lib/generators/helpers.rb +8 -0
- data/lib/generators/outbox/install/USAGE +10 -0
- data/lib/generators/outbox/install/install_generator.rb +33 -0
- data/lib/generators/outbox/install/templates/Outboxfile +3 -0
- data/lib/generators/outbox/install/templates/outbox.rb +32 -0
- data/lib/generators/outbox/install/templates/outbox.yml +51 -0
- data/lib/generators/outbox/item/USAGE +12 -0
- data/lib/generators/outbox/item/item_generator.rb +94 -0
- data/lib/generators/outbox/item/templates/inbox_item.rb.tt +7 -0
- data/lib/generators/outbox/item/templates/outbox_item.rb.tt +16 -0
- data/lib/generators/outbox/transport/USAGE +19 -0
- data/lib/generators/outbox/transport/templates/inbox_transport.yml.erb +9 -0
- data/lib/generators/outbox/transport/templates/outbox_transport.yml.erb +10 -0
- data/lib/generators/outbox/transport/transport_generator.rb +60 -0
- data/lib/generators/outbox.rb +23 -0
- data/lib/sbmt/outbox/ascii_art.rb +62 -0
- data/lib/sbmt/outbox/cli.rb +100 -0
- data/lib/sbmt/outbox/database_switcher.rb +15 -0
- data/lib/sbmt/outbox/engine.rb +45 -0
- data/lib/sbmt/outbox/error_tracker.rb +26 -0
- data/lib/sbmt/outbox/errors.rb +14 -0
- data/lib/sbmt/outbox/instrumentation/open_telemetry_loader.rb +34 -0
- data/lib/sbmt/outbox/logger.rb +35 -0
- data/lib/sbmt/outbox/middleware/builder.rb +23 -0
- data/lib/sbmt/outbox/middleware/open_telemetry/tracing_create_item_middleware.rb +42 -0
- data/lib/sbmt/outbox/middleware/open_telemetry/tracing_item_process_middleware.rb +49 -0
- data/lib/sbmt/outbox/middleware/runner.rb +29 -0
- data/lib/sbmt/outbox/middleware/sentry/tracing_batch_process_middleware.rb +48 -0
- data/lib/sbmt/outbox/middleware/sentry/tracing_item_process_middleware.rb +65 -0
- data/lib/sbmt/outbox/middleware/sentry/transaction.rb +28 -0
- data/lib/sbmt/outbox/probes/probe.rb +38 -0
- data/lib/sbmt/outbox/redis_client_factory.rb +36 -0
- data/lib/sbmt/outbox/tasks/delete_failed_items.rake +17 -0
- data/lib/sbmt/outbox/tasks/retry_failed_items.rake +20 -0
- data/lib/sbmt/outbox/thread_pool.rb +108 -0
- data/lib/sbmt/outbox/throttler.rb +52 -0
- data/lib/sbmt/outbox/version.rb +7 -0
- data/lib/sbmt/outbox/worker.rb +233 -0
- data/lib/sbmt/outbox.rb +136 -0
- data/lib/sbmt-outbox.rb +3 -0
- 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
|
data/lib/sbmt/outbox.rb
ADDED
@@ -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
|
data/lib/sbmt-outbox.rb
ADDED