ruby_event_store-outbox 0.0.30 → 0.1.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.
- checksums.yaml +4 -4
- data/lib/ruby_event_store/outbox/cli.rb +7 -1
- data/lib/ruby_event_store/outbox/configuration.rb +7 -3
- data/lib/ruby_event_store/outbox/consumer.rb +11 -136
- data/lib/ruby_event_store/outbox/repository.rb +134 -15
- data/lib/ruby_event_store/outbox/runner.rb +2 -0
- data/lib/ruby_event_store/outbox/version.rb +1 -1
- metadata +3 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 72cede48801be6a8a32295f690fc6a7a4091ddda2613791765b0d6127ea9eaec
|
4
|
+
data.tar.gz: 7a20be4c5a340e2305a760ecb104547dd7cf728d68e97717c38fde97ef404dac
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e5412c791d66024632f3ff638d0bc72b7d4b6fe7535c5f736ecaff420e19d8059d8b97e8e0c34de5032433320df2fd329affb7a8c6356f60ed406d8dfaeb0953
|
7
|
+
data.tar.gz: f5375242631ef96645463bf23d1507b491a9259231d3082158a5ddcb4b2a35e2afbe473aea2dd7cec7c73629f735525a972ab673a1e6779d6e7a88b53ec7c378
|
@@ -20,7 +20,8 @@ module RubyEventStore
|
|
20
20
|
metrics_url: nil,
|
21
21
|
cleanup_strategy: :none,
|
22
22
|
cleanup_limit: :all,
|
23
|
-
sleep_on_empty: 0.5
|
23
|
+
sleep_on_empty: 0.5,
|
24
|
+
locking: true,
|
24
25
|
}
|
25
26
|
Options = Struct.new(*DEFAULTS.keys)
|
26
27
|
|
@@ -84,6 +85,10 @@ module RubyEventStore
|
|
84
85
|
"How long to sleep before next check when there was nothing to do. Default: 0.5"
|
85
86
|
) { |sleep_on_empty| options.sleep_on_empty = sleep_on_empty }
|
86
87
|
|
88
|
+
option_parser.on("-l", "--[no-]lock", "Lock split key in consumer") do |locking|
|
89
|
+
options.locking = locking
|
90
|
+
end
|
91
|
+
|
87
92
|
option_parser.on_tail("--version", "Show version") do
|
88
93
|
puts VERSION
|
89
94
|
exit
|
@@ -112,6 +117,7 @@ module RubyEventStore
|
|
112
117
|
cleanup: options.cleanup_strategy,
|
113
118
|
cleanup_limit: options.cleanup_limit,
|
114
119
|
sleep_on_empty: options.sleep_on_empty,
|
120
|
+
locking: options.locking
|
115
121
|
)
|
116
122
|
metrics = Metrics.from_url(options.metrics_url)
|
117
123
|
outbox_consumer =
|
@@ -11,7 +11,8 @@ module RubyEventStore
|
|
11
11
|
redis_url:,
|
12
12
|
cleanup:,
|
13
13
|
cleanup_limit:,
|
14
|
-
sleep_on_empty
|
14
|
+
sleep_on_empty:,
|
15
|
+
locking:
|
15
16
|
)
|
16
17
|
@split_keys = split_keys
|
17
18
|
@message_format = message_format
|
@@ -21,6 +22,7 @@ module RubyEventStore
|
|
21
22
|
@cleanup = cleanup
|
22
23
|
@cleanup_limit = cleanup_limit
|
23
24
|
@sleep_on_empty = sleep_on_empty
|
25
|
+
@locking = locking
|
24
26
|
freeze
|
25
27
|
end
|
26
28
|
|
@@ -33,7 +35,8 @@ module RubyEventStore
|
|
33
35
|
redis_url: overriden_options.fetch(:redis_url, redis_url),
|
34
36
|
cleanup: overriden_options.fetch(:cleanup, cleanup),
|
35
37
|
cleanup_limit: overriden_options.fetch(:cleanup_limit, cleanup_limit),
|
36
|
-
sleep_on_empty: overriden_options.fetch(:sleep_on_empty, sleep_on_empty)
|
38
|
+
sleep_on_empty: overriden_options.fetch(:sleep_on_empty, sleep_on_empty),
|
39
|
+
locking: overriden_options.fetch(:locking, locking),
|
37
40
|
)
|
38
41
|
end
|
39
42
|
|
@@ -44,7 +47,8 @@ module RubyEventStore
|
|
44
47
|
:redis_url,
|
45
48
|
:cleanup,
|
46
49
|
:cleanup_limit,
|
47
|
-
:sleep_on_empty
|
50
|
+
:sleep_on_empty,
|
51
|
+
:locking
|
48
52
|
end
|
49
53
|
end
|
50
54
|
end
|
@@ -23,12 +23,13 @@ module RubyEventStore
|
|
23
23
|
@metrics = metrics
|
24
24
|
@tempo = Tempo.new(configuration.batch_size)
|
25
25
|
@consumer_uuid = consumer_uuid
|
26
|
+
@locking = configuration.locking
|
26
27
|
|
27
28
|
raise "Unknown format" if configuration.message_format != SIDEKIQ5_FORMAT
|
28
29
|
redis_config = RedisClient.config(url: configuration.redis_url)
|
29
30
|
@processor = SidekiqProcessor.new(redis_config.new_client)
|
30
31
|
|
31
|
-
@repository = Repository.new(configuration.database_url)
|
32
|
+
@repository = Repository.new(configuration.database_url, logger, metrics)
|
32
33
|
@cleanup_strategy = CleanupStrategies.build(configuration, repository)
|
33
34
|
end
|
34
35
|
|
@@ -43,56 +44,14 @@ module RubyEventStore
|
|
43
44
|
end
|
44
45
|
|
45
46
|
def handle_split(fetch_specification)
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
batch_result = BatchResult.empty
|
56
|
-
batch.each do |record|
|
57
|
-
handle_failure(batch_result) do
|
58
|
-
now = @clock.now.utc
|
59
|
-
processor.process(record, now)
|
60
|
-
|
61
|
-
repository.mark_as_enqueued(record, now)
|
62
|
-
something_processed |= true
|
63
|
-
batch_result.count_success!
|
64
|
-
end
|
65
|
-
end
|
66
|
-
|
67
|
-
metrics.write_point_queue(
|
68
|
-
enqueued: batch_result.success_count,
|
69
|
-
failed: batch_result.failed_count,
|
70
|
-
format: fetch_specification.message_format,
|
71
|
-
split_key: fetch_specification.split_key,
|
72
|
-
remaining: get_remaining_count(fetch_specification)
|
73
|
-
)
|
74
|
-
|
75
|
-
logger.info "Sent #{batch_result.success_count} messages from outbox table"
|
76
|
-
|
77
|
-
refresh_successful = refresh_lock_for_process(obtained_lock)
|
78
|
-
break unless refresh_successful
|
79
|
-
end
|
80
|
-
|
81
|
-
unless something_processed
|
82
|
-
metrics.write_point_queue(
|
83
|
-
format: fetch_specification.message_format,
|
84
|
-
split_key: fetch_specification.split_key,
|
85
|
-
remaining: get_remaining_count(fetch_specification)
|
86
|
-
)
|
87
|
-
end
|
88
|
-
|
89
|
-
release_lock_for_process(fetch_specification)
|
90
|
-
|
91
|
-
cleanup(fetch_specification)
|
92
|
-
|
93
|
-
processor.after_batch
|
94
|
-
|
95
|
-
something_processed
|
47
|
+
repository.with_next_batch(fetch_specification, tempo.batch_size, consumer_uuid, locking, @clock) do |record|
|
48
|
+
now = @clock.now.utc
|
49
|
+
processor.process(record, now)
|
50
|
+
repository.mark_as_enqueued(record, now)
|
51
|
+
end.tap do
|
52
|
+
cleanup(fetch_specification)
|
53
|
+
processor.after_batch
|
54
|
+
end.success_count > 0
|
96
55
|
end
|
97
56
|
|
98
57
|
private
|
@@ -103,86 +62,10 @@ module RubyEventStore
|
|
103
62
|
:processor,
|
104
63
|
:consumer_uuid,
|
105
64
|
:repository,
|
65
|
+
:locking,
|
106
66
|
:cleanup_strategy,
|
107
67
|
:tempo
|
108
68
|
|
109
|
-
def handle_failure(batch_result)
|
110
|
-
retried = false
|
111
|
-
yield
|
112
|
-
rescue RetriableRedisError => error
|
113
|
-
if retried
|
114
|
-
batch_result.count_failed!
|
115
|
-
log_error(error)
|
116
|
-
else
|
117
|
-
retried = true
|
118
|
-
retry
|
119
|
-
end
|
120
|
-
rescue => error
|
121
|
-
batch_result.count_failed!
|
122
|
-
log_error(error)
|
123
|
-
end
|
124
|
-
|
125
|
-
def log_error(e)
|
126
|
-
e.full_message.split($/).each { |line| logger.error(line) }
|
127
|
-
end
|
128
|
-
|
129
|
-
def obtain_lock_for_process(fetch_specification)
|
130
|
-
result = repository.obtain_lock_for_process(fetch_specification, consumer_uuid, clock: @clock)
|
131
|
-
case result
|
132
|
-
when :deadlocked
|
133
|
-
logger.warn "Obtaining lock for split_key '#{fetch_specification.split_key}' failed (deadlock)"
|
134
|
-
metrics.write_operation_result("obtain", "deadlocked")
|
135
|
-
false
|
136
|
-
when :lock_timeout
|
137
|
-
logger.warn "Obtaining lock for split_key '#{fetch_specification.split_key}' failed (lock timeout)"
|
138
|
-
metrics.write_operation_result("obtain", "lock_timeout")
|
139
|
-
false
|
140
|
-
when :taken
|
141
|
-
logger.debug "Obtaining lock for split_key '#{fetch_specification.split_key}' unsuccessful (taken)"
|
142
|
-
metrics.write_operation_result("obtain", "taken")
|
143
|
-
false
|
144
|
-
else
|
145
|
-
result
|
146
|
-
end
|
147
|
-
end
|
148
|
-
|
149
|
-
def release_lock_for_process(fetch_specification)
|
150
|
-
result = repository.release_lock_for_process(fetch_specification, consumer_uuid)
|
151
|
-
case result
|
152
|
-
when :deadlocked
|
153
|
-
logger.warn "Releasing lock for split_key '#{fetch_specification.split_key}' failed (deadlock)"
|
154
|
-
metrics.write_operation_result("release", "deadlocked")
|
155
|
-
when :lock_timeout
|
156
|
-
logger.warn "Releasing lock for split_key '#{fetch_specification.split_key}' failed (lock timeout)"
|
157
|
-
metrics.write_operation_result("release", "lock_timeout")
|
158
|
-
when :not_taken_by_this_process
|
159
|
-
logger.debug "Releasing lock for split_key '#{fetch_specification.split_key}' failed (not taken by this process)"
|
160
|
-
metrics.write_operation_result("release", "not_taken_by_this_process")
|
161
|
-
end
|
162
|
-
end
|
163
|
-
|
164
|
-
def refresh_lock_for_process(lock)
|
165
|
-
result = lock.refresh(clock: @clock)
|
166
|
-
case result
|
167
|
-
when :ok
|
168
|
-
return true
|
169
|
-
when :deadlocked
|
170
|
-
logger.warn "Refreshing lock for split_key '#{lock.split_key}' failed (deadlock)"
|
171
|
-
metrics.write_operation_result("refresh", "deadlocked")
|
172
|
-
return false
|
173
|
-
when :lock_timeout
|
174
|
-
logger.warn "Refreshing lock for split_key '#{lock.split_key}' failed (lock timeout)"
|
175
|
-
metrics.write_operation_result("refresh", "lock_timeout")
|
176
|
-
return false
|
177
|
-
when :stolen
|
178
|
-
logger.debug "Refreshing lock for split_key '#{lock.split_key}' unsuccessful (stolen)"
|
179
|
-
metrics.write_operation_result("refresh", "stolen")
|
180
|
-
return false
|
181
|
-
else
|
182
|
-
raise "Unexpected result #{result}"
|
183
|
-
end
|
184
|
-
end
|
185
|
-
|
186
69
|
def cleanup(fetch_specification)
|
187
70
|
result = cleanup_strategy.call(fetch_specification)
|
188
71
|
case result
|
@@ -194,14 +77,6 @@ module RubyEventStore
|
|
194
77
|
metrics.write_operation_result("cleanup", "lock_timeout")
|
195
78
|
end
|
196
79
|
end
|
197
|
-
|
198
|
-
def retrieve_batch(fetch_specification)
|
199
|
-
repository.retrieve_batch(fetch_specification, tempo.batch_size)
|
200
|
-
end
|
201
|
-
|
202
|
-
def get_remaining_count(fetch_specification)
|
203
|
-
repository.get_remaining_count(fetch_specification)
|
204
|
-
end
|
205
80
|
end
|
206
81
|
end
|
207
82
|
end
|
@@ -113,27 +113,21 @@ module RubyEventStore
|
|
113
113
|
end
|
114
114
|
end
|
115
115
|
|
116
|
-
def initialize(database_url)
|
116
|
+
def initialize(database_url, logger, metrics)
|
117
|
+
@logger = logger
|
118
|
+
@metrics = metrics
|
117
119
|
::ActiveRecord::Base.establish_connection(database_url) unless ::ActiveRecord::Base.connected?
|
118
120
|
if ::ActiveRecord::Base.connection.adapter_name == "Mysql2"
|
119
121
|
::ActiveRecord::Base.connection.execute("SET SESSION innodb_lock_wait_timeout = 1;")
|
120
122
|
end
|
121
123
|
end
|
122
124
|
|
123
|
-
def
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
end
|
130
|
-
|
131
|
-
def obtain_lock_for_process(fetch_specification, process_uuid, clock:)
|
132
|
-
Lock.obtain(fetch_specification, process_uuid, clock: clock)
|
133
|
-
end
|
134
|
-
|
135
|
-
def release_lock_for_process(fetch_specification, process_uuid)
|
136
|
-
Lock.release(fetch_specification, process_uuid)
|
125
|
+
def with_next_batch(fetch_specification, batch_size, consumer_uuid, locking, clock, &block)
|
126
|
+
if locking
|
127
|
+
with_next_locking_batch(fetch_specification, batch_size, consumer_uuid, clock, &block)
|
128
|
+
else
|
129
|
+
with_next_non_locking_batch(fetch_specification, batch_size, &block)
|
130
|
+
end
|
137
131
|
end
|
138
132
|
|
139
133
|
def mark_as_enqueued(record, now)
|
@@ -150,6 +144,131 @@ module RubyEventStore
|
|
150
144
|
rescue ::ActiveRecord::LockWaitTimeout
|
151
145
|
:lock_timeout
|
152
146
|
end
|
147
|
+
|
148
|
+
private
|
149
|
+
|
150
|
+
def with_next_locking_batch(fetch_specification, batch_size, consumer_uuid, clock, &block)
|
151
|
+
BatchResult.empty.tap do |result|
|
152
|
+
obtained_lock = obtain_lock_for_process(fetch_specification, consumer_uuid, clock: clock)
|
153
|
+
case obtained_lock
|
154
|
+
when :deadlocked
|
155
|
+
logger.warn "Obtaining lock for split_key '#{fetch_specification.split_key}' failed (deadlock)"
|
156
|
+
metrics.write_operation_result("obtain", "deadlocked")
|
157
|
+
return BatchResult.empty
|
158
|
+
when :lock_timeout
|
159
|
+
logger.warn "Obtaining lock for split_key '#{fetch_specification.split_key}' failed (lock timeout)"
|
160
|
+
metrics.write_operation_result("obtain", "lock_timeout")
|
161
|
+
return BatchResult.empty
|
162
|
+
when :taken
|
163
|
+
logger.debug "Obtaining lock for split_key '#{fetch_specification.split_key}' unsuccessful (taken)"
|
164
|
+
metrics.write_operation_result("obtain", "taken")
|
165
|
+
return BatchResult.empty
|
166
|
+
end
|
167
|
+
|
168
|
+
Consumer::MAXIMUM_BATCH_FETCHES_IN_ONE_LOCK.times do
|
169
|
+
batch = retrieve_batch(fetch_specification, batch_size).to_a
|
170
|
+
break if batch.empty?
|
171
|
+
batch.each do |record|
|
172
|
+
handle_execution(result) do
|
173
|
+
block.call(record)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
case (refresh_result = obtained_lock.refresh(clock: clock))
|
177
|
+
when :ok
|
178
|
+
when :deadlocked
|
179
|
+
logger.warn "Refreshing lock for split_key '#{lock.split_key}' failed (deadlock)"
|
180
|
+
metrics.write_operation_result("refresh", "deadlocked")
|
181
|
+
break
|
182
|
+
when :lock_timeout
|
183
|
+
logger.warn "Refreshing lock for split_key '#{lock.split_key}' failed (lock timeout)"
|
184
|
+
metrics.write_operation_result("refresh", "lock_timeout")
|
185
|
+
break
|
186
|
+
when :stolen
|
187
|
+
logger.debug "Refreshing lock for split_key '#{lock.split_key}' unsuccessful (stolen)"
|
188
|
+
metrics.write_operation_result("refresh", "stolen")
|
189
|
+
break
|
190
|
+
else
|
191
|
+
raise "Unexpected result #{refresh_result}"
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
case release_lock_for_process(fetch_specification, consumer_uuid)
|
196
|
+
when :deadlocked
|
197
|
+
logger.warn "Releasing lock for split_key '#{fetch_specification.split_key}' failed (deadlock)"
|
198
|
+
metrics.write_operation_result("release", "deadlocked")
|
199
|
+
when :lock_timeout
|
200
|
+
logger.warn "Releasing lock for split_key '#{fetch_specification.split_key}' failed (lock timeout)"
|
201
|
+
metrics.write_operation_result("release", "lock_timeout")
|
202
|
+
when :not_taken_by_this_process
|
203
|
+
logger.debug "Releasing lock for split_key '#{fetch_specification.split_key}' failed (not taken by this process)"
|
204
|
+
metrics.write_operation_result("release", "not_taken_by_this_process")
|
205
|
+
end
|
206
|
+
instrument_batch_result(fetch_specification, result)
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def with_next_non_locking_batch(fetch_specification, batch_size, &block)
|
211
|
+
BatchResult.empty.tap do |result|
|
212
|
+
Record.transaction do
|
213
|
+
batch = retrieve_batch(fetch_specification, batch_size).lock("FOR UPDATE SKIP LOCKED")
|
214
|
+
break if batch.empty?
|
215
|
+
batch.each do |record|
|
216
|
+
handle_execution(result) do
|
217
|
+
block.call(record)
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
instrument_batch_result(fetch_specification, result)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
def instrument_batch_result(fetch_specification, result)
|
227
|
+
metrics.write_point_queue(
|
228
|
+
enqueued: result.success_count,
|
229
|
+
failed: result.failed_count,
|
230
|
+
format: fetch_specification.message_format,
|
231
|
+
split_key: fetch_specification.split_key,
|
232
|
+
remaining: Record.remaining_for(fetch_specification).count
|
233
|
+
)
|
234
|
+
|
235
|
+
logger.info "Sent #{result.success_count} messages from outbox table"
|
236
|
+
end
|
237
|
+
|
238
|
+
def handle_execution(batch_result)
|
239
|
+
retried = false
|
240
|
+
yield
|
241
|
+
batch_result.count_success!
|
242
|
+
rescue RetriableRedisError => error
|
243
|
+
if retried
|
244
|
+
batch_result.count_failed!
|
245
|
+
log_error(error)
|
246
|
+
else
|
247
|
+
retried = true
|
248
|
+
retry
|
249
|
+
end
|
250
|
+
rescue => error
|
251
|
+
batch_result.count_failed!
|
252
|
+
log_error(error)
|
253
|
+
end
|
254
|
+
|
255
|
+
def log_error(e)
|
256
|
+
e.full_message.split($/).each { |line| logger.error(line) }
|
257
|
+
end
|
258
|
+
|
259
|
+
def retrieve_batch(fetch_specification, batch_size)
|
260
|
+
Record.remaining_for(fetch_specification).order("id ASC").limit(batch_size)
|
261
|
+
end
|
262
|
+
|
263
|
+
def obtain_lock_for_process(fetch_specification, process_uuid, clock:)
|
264
|
+
Lock.obtain(fetch_specification, process_uuid, clock: clock)
|
265
|
+
end
|
266
|
+
|
267
|
+
def release_lock_for_process(fetch_specification, process_uuid)
|
268
|
+
Lock.release(fetch_specification, process_uuid)
|
269
|
+
end
|
270
|
+
|
271
|
+
attr_reader :logger, :metrics
|
153
272
|
end
|
154
273
|
end
|
155
274
|
end
|
@@ -8,12 +8,14 @@ module RubyEventStore
|
|
8
8
|
@logger = logger
|
9
9
|
@sleep_on_empty = configuration.sleep_on_empty
|
10
10
|
@split_keys = configuration.split_keys
|
11
|
+
@locking = configuration.locking
|
11
12
|
@gracefully_shutting_down = false
|
12
13
|
prepare_traps
|
13
14
|
end
|
14
15
|
|
15
16
|
def run
|
16
17
|
logger.info("Initiated RubyEventStore::Outbox v#{VERSION}")
|
18
|
+
logger.info("Using #{@locking ? "locking" : "non-locking"} mode")
|
17
19
|
logger.info("Handling split keys: #{split_keys ? split_keys.join(", ") : "(all of them)"}")
|
18
20
|
|
19
21
|
while !@gracefully_shutting_down
|
metadata
CHANGED
@@ -1,14 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruby_event_store-outbox
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Arkency
|
8
|
-
autorequire:
|
9
8
|
bindir: bin
|
10
9
|
cert_chain: []
|
11
|
-
date: 2025-03-
|
10
|
+
date: 2025-03-28 00:00:00.000000000 Z
|
12
11
|
dependencies:
|
13
12
|
- !ruby/object:Gem::Dependency
|
14
13
|
name: ruby_event_store
|
@@ -38,7 +37,6 @@ dependencies:
|
|
38
37
|
- - ">="
|
39
38
|
- !ruby/object:Gem::Version
|
40
39
|
version: '6.0'
|
41
|
-
description:
|
42
40
|
email: dev@arkency.com
|
43
41
|
executables:
|
44
42
|
- res_outbox
|
@@ -79,7 +77,6 @@ metadata:
|
|
79
77
|
source_code_uri: https://github.com/RailsEventStore/rails_event_store
|
80
78
|
bug_tracker_uri: https://github.com/RailsEventStore/rails_event_store/issues
|
81
79
|
rubygems_mfa_required: 'true'
|
82
|
-
post_install_message:
|
83
80
|
rdoc_options: []
|
84
81
|
require_paths:
|
85
82
|
- lib
|
@@ -94,8 +91,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
91
|
- !ruby/object:Gem::Version
|
95
92
|
version: '0'
|
96
93
|
requirements: []
|
97
|
-
rubygems_version: 3.
|
98
|
-
signing_key:
|
94
|
+
rubygems_version: 3.6.6
|
99
95
|
specification_version: 4
|
100
96
|
summary: Active Record based outbox for Ruby Event Store
|
101
97
|
test_files: []
|