deimos-ruby 2.5.4 → 2.6.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/CHANGELOG.md +5 -1
- data/README.md +36 -0
- data/docs/CONFIGURATION.md +1 -0
- data/lib/deimos/active_record_consume/batch_consumption.rb +127 -30
- data/lib/deimos/active_record_consume/batch_record.rb +3 -0
- data/lib/deimos/active_record_consumer.rb +5 -0
- data/lib/deimos/exceptions.rb +17 -0
- data/lib/deimos/ext/consumer_route.rb +3 -1
- data/lib/deimos/utils/deadlock_retry.rb +10 -1
- data/lib/deimos/version.rb +1 -1
- data/lib/deimos.rb +1 -0
- data/spec/active_record_batch_consumer_spec.rb +285 -0
- data/spec/utils/deadlock_retry_spec.rb +37 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3399390b0e3dc85c67c95c4d633d3ce65efb80258848e73c6b0fa5d9244db13e
|
|
4
|
+
data.tar.gz: d236d19cd353f581121dbf561cba34ff78a192bd636ac992fcb1ad87b5913375
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 502f900a5d5220c55d13bbc50ab329c826d3da953b14adfbaaaf1668e229d2ede46268729ed3c7bc631045feb6927fe85126a6b990f28b4f00630efdb0bb5501
|
|
7
|
+
data.tar.gz: 44d082485bfb68be5b2c49f228ab29626eb5c7b80b98873284a7844fba55c881ac805d0ce9db2c9d2f44f8b68eb781f541a5d1d0099b94f2f7ee236a29197093
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,11 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## 2.6.0 - 2026-08-13
|
|
9
|
+
|
|
10
|
+
- Breaking: a failed batch database write is now retried one record at a time, so one unpersistable record no longer loses the whole batch. `Deimos::BatchFallbackError` is raised naming the keys that still failed.
|
|
11
|
+
- Feature: new `batch_message_fallback` topic setting, default true. Set it to false to keep the previous all-or-nothing behaviour.
|
|
12
|
+
- Feature: new `deimos.batch_consumption.initial_failure` instrumentation event, fired when a batch write fails and the records are about to be retried individually.
|
|
9
13
|
|
|
10
14
|
## 2.5.4 - 2026-07-15
|
|
11
15
|
|
data/README.md
CHANGED
|
@@ -323,6 +323,12 @@ produced by Phobos and RubyKafka):
|
|
|
323
323
|
* `deimos.batch_consumption.invalid_records` - sent when the consumer has rejected records returned from `filtered_records`. Limited by `max_db_batch_size`.
|
|
324
324
|
* consumer: class of the consumer that rejected these records
|
|
325
325
|
* records: Rejected records (of type `Deimos::ActiveRecordConsume::BatchRecord`)
|
|
326
|
+
* `deimos.batch_consumption.initial_failure` - sent when a bulk database write failed and the consumer is about to retry it one record at a time. Fired once per failing write, and fired even if nothing ends up being salvaged.
|
|
327
|
+
* consumer: class of the consumer that is retrying
|
|
328
|
+
* topic: name of the topic being consumed
|
|
329
|
+
* operation: `:upsert_records` or `:remove_records`
|
|
330
|
+
* count: number of records or messages in the write that failed
|
|
331
|
+
* error: the exception the bulk write raised
|
|
326
332
|
|
|
327
333
|
# Consumers
|
|
328
334
|
|
|
@@ -565,6 +571,36 @@ By default, batches will be compacted before processing, i.e. only the last
|
|
|
565
571
|
message for each unique key in a batch will actually be processed. To change
|
|
566
572
|
this behaviour, call `compacted false` inside of your consumer definition.
|
|
567
573
|
|
|
574
|
+
#### Failure handling
|
|
575
|
+
|
|
576
|
+
Because a batch is written in a single SQL statement inside a single transaction, one record
|
|
577
|
+
which cannot be persisted would otherwise roll back every other record written alongside it.
|
|
578
|
+
To avoid losing an entire batch to one bad record, Deimos retries the database write one record
|
|
579
|
+
at a time when the bulk write fails. Everything that can be saved on its own is saved, and once
|
|
580
|
+
the whole batch has been attempted a single `Deimos::BatchFallbackError` is raised. Its
|
|
581
|
+
`failures` attribute holds the `[message, error]` pairs that could not be saved, and its message
|
|
582
|
+
names their keys. From there it is handled by `reraise_errors`/`fatal_error` like any other
|
|
583
|
+
consumer error.
|
|
584
|
+
|
|
585
|
+
Only the database write is retried. Message-level work — `pre_process`, building and filtering
|
|
586
|
+
records, and the `valid_records`/`invalid_records` events — happens exactly once for the group
|
|
587
|
+
either way, so nothing is applied twice. The
|
|
588
|
+
`deimos.batch_consumption.initial_failure` event fires when a bulk write fails and the records
|
|
589
|
+
are about to be retried individually.
|
|
590
|
+
|
|
591
|
+
The original error is reraised unchanged, rather than wrapped, in three cases:
|
|
592
|
+
|
|
593
|
+
* Writes of a single record, where there is nothing to isolate.
|
|
594
|
+
* Deadlocks and lock wait timeouts, which are transient contention on the whole write (already
|
|
595
|
+
retried by `DeadlockRetry`) rather than a problem with a particular record.
|
|
596
|
+
* When nothing could be saved on its own. Isolating salvaged nothing, so the failure was
|
|
597
|
+
never about one bad record - it is something systemic, such as an unreachable database - and
|
|
598
|
+
the original error describes that better.
|
|
599
|
+
|
|
600
|
+
Retrying row by row trades throughput for durability, which is not always the right trade for a
|
|
601
|
+
very large batch on a hot topic. Set `batch_message_fallback false` on the topic to keep
|
|
602
|
+
the previous all-or-nothing behaviour and let the whole batch fail.
|
|
603
|
+
|
|
568
604
|
A sample batch consumer would look as follows:
|
|
569
605
|
|
|
570
606
|
```ruby
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -133,6 +133,7 @@ The following are additional settings that can be added to the `topic` block in
|
|
|
133
133
|
| replace_associations | true | If false, append to associations in multi-table imports rather than replacing them. |
|
|
134
134
|
| bulk_import_id_generator | nil | Block to determine the bulk_import_id generated during bulk consumption. If no block is specified the provided/default block from the consumers configuration will be used. |
|
|
135
135
|
| save_associations_first | false | Whether to save associated records of primary class prior to upserting primary records. Foreign key of associated records are assigned to the record class prior to saving the record class |
|
|
136
|
+
| batch_message_fallback | true | When a bulk database write fails, retry it one record at a time so that a single failed record doesn't lose the whole batch. Set to false to skip this behavior, e.g. where retrying row by row is too slow. |
|
|
136
137
|
|
|
137
138
|
### Defining Consumers
|
|
138
139
|
|
|
@@ -24,6 +24,7 @@ module Deimos
|
|
|
24
24
|
# in the same operation as they would interfere with each other. Thus
|
|
25
25
|
# they are split
|
|
26
26
|
# @return [void]
|
|
27
|
+
# @raise [BatchFallbackError] if some messages could not be saved even on their own.
|
|
27
28
|
def consume_batch
|
|
28
29
|
filtered = messages.select { |message| process_message?(message) }
|
|
29
30
|
skipped_count = messages.size - filtered.size
|
|
@@ -39,11 +40,15 @@ module Deimos
|
|
|
39
40
|
Deimos.config.tracer.active_span.set_tag('topic', tag)
|
|
40
41
|
|
|
41
42
|
Karafka.monitor.instrument('deimos.ar_consumer.consume_batch', { topic: tag }) do
|
|
42
|
-
if @compacted && deimos_messages.map(&:key).compact.any?
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
failures = if @compacted && deimos_messages.map(&:key).compact.any?
|
|
44
|
+
update_database(compact_messages(deimos_messages))
|
|
45
|
+
else
|
|
46
|
+
uncompacted_update(deimos_messages)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Raised only once every slice and group has been attempted, so that a message which
|
|
50
|
+
# can't be persisted never stops the rest of the batch from being saved.
|
|
51
|
+
raise BatchFallbackError, failures if failures.any?
|
|
47
52
|
end
|
|
48
53
|
|
|
49
54
|
post_process_batch(deimos_messages)
|
|
@@ -128,45 +133,40 @@ module Deimos
|
|
|
128
133
|
# All messages are split into slices containing only unique keys, and
|
|
129
134
|
# each slice is handles as its own batch.
|
|
130
135
|
# @param messages [Array<Message>] List of messages.
|
|
131
|
-
# @return [
|
|
136
|
+
# @return [Array<Array(Message, StandardError)>] messages that could not be saved even on
|
|
137
|
+
# their own, paired with their error.
|
|
132
138
|
def uncompacted_update(messages)
|
|
133
139
|
BatchSlicer.
|
|
134
140
|
slice(messages).
|
|
135
|
-
|
|
141
|
+
flat_map(&method(:update_database))
|
|
136
142
|
end
|
|
137
143
|
|
|
138
144
|
# Perform database operations for a group of messages.
|
|
139
145
|
# All messages with payloads are passed to upsert_records.
|
|
140
146
|
# All tombstones messages are passed to remove_records.
|
|
141
147
|
# @param messages [Array<Message>] List of messages.
|
|
142
|
-
# @return [
|
|
148
|
+
# @return [Array<Array(Message, StandardError)>] messages that could not be saved even on
|
|
149
|
+
# their own, paired with their error.
|
|
143
150
|
def update_database(messages)
|
|
144
151
|
# Find all upserted records (i.e. that have a payload) and all
|
|
145
152
|
# deleted record (no payload)
|
|
146
153
|
removed, upserted = messages.partition { |m| delete_record?(m) }
|
|
147
154
|
|
|
148
155
|
max_db_batch_size = self.class.config[:max_db_batch_size]
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
upserted.each_slice(max_db_batch_size) { |group| upsert_records(group) }
|
|
152
|
-
else
|
|
153
|
-
upsert_records(upserted)
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
+
upsert_groups = max_db_batch_size ? upserted.each_slice(max_db_batch_size).to_a : [upserted]
|
|
157
|
+
remove_groups = max_db_batch_size ? removed.each_slice(max_db_batch_size).to_a : [removed]
|
|
156
158
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if max_db_batch_size
|
|
160
|
-
removed.each_slice(max_db_batch_size) { |group| remove_records(group) }
|
|
161
|
-
else
|
|
162
|
-
remove_records(removed)
|
|
163
|
-
end
|
|
159
|
+
upsert_groups.reject(&:empty?).flat_map { |group| upsert_records(group) } +
|
|
160
|
+
remove_groups.reject(&:empty?).flat_map { |group| remove_group(group) }
|
|
164
161
|
end
|
|
165
162
|
|
|
166
|
-
# Upsert any non-deleted records
|
|
163
|
+
# Upsert any non-deleted records. Everything that operates on the messages - pre-processing,
|
|
164
|
+
# building and filtering records, instrumentation - happens exactly once here; only the
|
|
165
|
+
# database write is retried if it fails, so nothing gets applied twice.
|
|
167
166
|
# @param messages [Array<Message>] List of messages for a group of
|
|
168
167
|
# records to either be updated or inserted.
|
|
169
|
-
# @return [
|
|
168
|
+
# @return [Array<Array(Message, StandardError)>] messages whose records could not be saved
|
|
169
|
+
# even on their own, paired with their error.
|
|
170
170
|
def upsert_records(messages)
|
|
171
171
|
record_list = build_records(messages)
|
|
172
172
|
invalid = filter_records(record_list)
|
|
@@ -176,7 +176,7 @@ module Deimos
|
|
|
176
176
|
consumer: self.class
|
|
177
177
|
})
|
|
178
178
|
end
|
|
179
|
-
return if record_list.empty?
|
|
179
|
+
return [] if record_list.empty?
|
|
180
180
|
|
|
181
181
|
key_col_proc = self.method(:key_columns).to_proc
|
|
182
182
|
col_proc = self.method(:columns).to_proc
|
|
@@ -188,10 +188,104 @@ module Deimos
|
|
|
188
188
|
bulk_import_id_generator: self.bulk_import_id_generator,
|
|
189
189
|
save_associations_first: self.save_associations_first,
|
|
190
190
|
bulk_import_id_column: self.bulk_import_id_column)
|
|
191
|
+
saved, failures = save_record_list(record_list, updater)
|
|
191
192
|
Karafka.monitor.instrument('deimos.batch_consumption.valid_records', {
|
|
192
|
-
records:
|
|
193
|
+
records: saved,
|
|
193
194
|
consumer: self.class
|
|
194
195
|
})
|
|
196
|
+
failures
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Write a list of records to the database. The list is written in a single statement inside
|
|
200
|
+
# a single transaction, so one record which can't be persisted would otherwise take down
|
|
201
|
+
# every other record written alongside it. Unless the topic turns
|
|
202
|
+
# `batch_message_fallback` off, retry the write one record at a time so the healthy
|
|
203
|
+
# ones still land - and only the write, so that message-level work isn't repeated.
|
|
204
|
+
# @param record_list [BatchRecordList]
|
|
205
|
+
# @param updater [MassUpdater]
|
|
206
|
+
# @return [Array(Array<ActiveRecord::Base>, Array<Array(Message, StandardError)>)] the
|
|
207
|
+
# records that were saved, and the messages that could not be saved with their error.
|
|
208
|
+
def save_record_list(record_list, updater)
|
|
209
|
+
[updater.mass_update(record_list), []]
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
raise unless self.batch_message_fallback
|
|
212
|
+
# Nothing to isolate from a single record, and deadlocks/lock wait timeouts are transient
|
|
213
|
+
# contention on the whole write which DeadlockRetry has already retried - they don't point
|
|
214
|
+
# at a bad record, so retrying row by row only multiplies the work.
|
|
215
|
+
raise if record_list.batch_records.size <= 1 || Deimos::Utils::DeadlockRetry.deadlock?(e)
|
|
216
|
+
|
|
217
|
+
save_records_individually(record_list, updater, e)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @param record_list [BatchRecordList]
|
|
221
|
+
# @param updater [MassUpdater]
|
|
222
|
+
# @param batch_error [StandardError] the error the bulk write raised.
|
|
223
|
+
# @return [Array(Array<ActiveRecord::Base>, Array<Array(Message, StandardError)>)]
|
|
224
|
+
def save_records_individually(record_list, updater, batch_error)
|
|
225
|
+
report_initial_failure(:upsert_records, record_list.batch_records.size, batch_error)
|
|
226
|
+
|
|
227
|
+
saved = []
|
|
228
|
+
failures = []
|
|
229
|
+
record_list.batch_records.each do |batch_record|
|
|
230
|
+
saved.concat(updater.mass_update(BatchRecordList.new([batch_record])))
|
|
231
|
+
rescue StandardError => e
|
|
232
|
+
failures << [batch_record.message, e]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Nothing could be saved on its own, so this was never about one bad record - it's
|
|
236
|
+
# something systemic (the database is unreachable, ...). The original error describes that
|
|
237
|
+
# better than a BatchFallbackError listing every key.
|
|
238
|
+
raise batch_error if saved.empty?
|
|
239
|
+
|
|
240
|
+
[saved, failures]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Delete the records for a group of tombstones, falling back to one message at a time if the
|
|
244
|
+
# bulk delete fails. Unlike upserts there is no record building, pre-processing or
|
|
245
|
+
# instrumentation on this path, so the whole operation can safely be retried per message.
|
|
246
|
+
# @param messages [Array<Message>]
|
|
247
|
+
# @return [Array<Array(Message, StandardError)>]
|
|
248
|
+
def remove_group(messages)
|
|
249
|
+
remove_records(messages)
|
|
250
|
+
[]
|
|
251
|
+
rescue StandardError => e
|
|
252
|
+
raise unless self.batch_message_fallback
|
|
253
|
+
raise if messages.size <= 1 || Deimos::Utils::DeadlockRetry.deadlock?(e)
|
|
254
|
+
|
|
255
|
+
report_initial_failure(:remove_records, messages.size, e)
|
|
256
|
+
|
|
257
|
+
failures = []
|
|
258
|
+
messages.each do |message|
|
|
259
|
+
remove_records([message])
|
|
260
|
+
rescue StandardError => individual_error
|
|
261
|
+
failures << [message, individual_error]
|
|
262
|
+
end
|
|
263
|
+
raise e if failures.size == messages.size
|
|
264
|
+
|
|
265
|
+
failures
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Log and announce that a bulk write failed and is about to be retried one at a time.
|
|
269
|
+
# @param operation [Symbol] `:upsert_records` or `:remove_records`.
|
|
270
|
+
# @param count [Integer] how many records or messages were in the failed write.
|
|
271
|
+
# @param error [StandardError]
|
|
272
|
+
# @return [void]
|
|
273
|
+
def report_initial_failure(operation, count, error)
|
|
274
|
+
Deimos::Logging.log_warn(
|
|
275
|
+
message: 'Batch database write failed, retrying one at a time',
|
|
276
|
+
handler: self.class.name,
|
|
277
|
+
topic: self.topic.name,
|
|
278
|
+
operation: operation,
|
|
279
|
+
count: count,
|
|
280
|
+
error_message: error.message
|
|
281
|
+
)
|
|
282
|
+
Karafka.monitor.instrument('deimos.batch_consumption.initial_failure', {
|
|
283
|
+
consumer: self.class,
|
|
284
|
+
topic: self.topic.name,
|
|
285
|
+
operation: operation,
|
|
286
|
+
count: count,
|
|
287
|
+
error: error
|
|
288
|
+
})
|
|
195
289
|
end
|
|
196
290
|
|
|
197
291
|
# @param record_list [BatchRecordList]
|
|
@@ -226,10 +320,13 @@ module Deimos
|
|
|
226
320
|
self.bulk_import_id_column
|
|
227
321
|
end
|
|
228
322
|
|
|
229
|
-
BatchRecord.new(klass: @klass,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
323
|
+
record = BatchRecord.new(klass: @klass,
|
|
324
|
+
attributes: attrs,
|
|
325
|
+
bulk_import_column: col,
|
|
326
|
+
bulk_import_id_generator: self.bulk_import_id_generator)
|
|
327
|
+
# Keep the message so a record which can't be saved can be reported by its Kafka key.
|
|
328
|
+
record.message = m
|
|
329
|
+
record
|
|
233
330
|
end
|
|
234
331
|
BatchRecordList.new(records.compact)
|
|
235
332
|
end
|
|
@@ -18,6 +18,9 @@ module Deimos
|
|
|
18
18
|
attr_accessor :bulk_import_column
|
|
19
19
|
# @return [Boolean] true if the primary key was supplied in the input attributes,
|
|
20
20
|
attr_accessor :primary_key_preset
|
|
21
|
+
# @return [Deimos::Message,nil] the message this record was built from, if any. Used to
|
|
22
|
+
# report the Kafka key of a record which could not be saved. Not set on sub-records.
|
|
23
|
+
attr_accessor :message
|
|
21
24
|
|
|
22
25
|
delegate :valid?, :errors, :send, :attributes, to: :record
|
|
23
26
|
|
data/lib/deimos/exceptions.rb
CHANGED
|
@@ -2,4 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module Deimos
|
|
4
4
|
class MissingImplementationError < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when a batch database operation failed and the messages were retried one at a time.
|
|
7
|
+
# Every message that could be saved on its own has been saved; this carries the ones that could
|
|
8
|
+
# not, so that the offending keys show up in logging and error reporting.
|
|
9
|
+
class BatchFallbackError < StandardError
|
|
10
|
+
# @return [Array<Array(Deimos::Message, StandardError)>] each message that failed on its own,
|
|
11
|
+
# paired with the error it raised.
|
|
12
|
+
attr_reader :failures
|
|
13
|
+
|
|
14
|
+
# @param failures [Array<Array(Deimos::Message, StandardError)>]
|
|
15
|
+
def initialize(failures)
|
|
16
|
+
@failures = failures
|
|
17
|
+
details = failures.map { |message, error| "#{message.key.inspect} (#{error.message})" }
|
|
18
|
+
super("#{failures.size} message(s) could not be saved individually after the batch " \
|
|
19
|
+
"failed. Failed keys: #{details.join(', ')}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
5
22
|
end
|
|
@@ -10,7 +10,8 @@ module Deimos
|
|
|
10
10
|
each_message
|
|
11
11
|
reraise_errors
|
|
12
12
|
fatal_error
|
|
13
|
-
save_associations_first
|
|
13
|
+
save_associations_first
|
|
14
|
+
batch_message_fallback).freeze
|
|
14
15
|
Config = Struct.new(*FIELDS, keyword_init: true)
|
|
15
16
|
|
|
16
17
|
FIELDS.each do |field|
|
|
@@ -19,6 +20,7 @@ module Deimos
|
|
|
19
20
|
bulk_import_id_column: :bulk_import_id,
|
|
20
21
|
replace_associations: true,
|
|
21
22
|
each_message: false,
|
|
23
|
+
batch_message_fallback: true,
|
|
22
24
|
reraise_errors: Rails.env.test?,
|
|
23
25
|
bulk_import_id_generator: proc { SecureRandom.uuid },
|
|
24
26
|
fatal_error: proc { false }
|
|
@@ -21,6 +21,15 @@ module Deimos
|
|
|
21
21
|
'deadlock detected'
|
|
22
22
|
].freeze
|
|
23
23
|
|
|
24
|
+
# Whether the given exception is a deadlock or lock wait timeout, i.e. transient
|
|
25
|
+
# contention on the database rather than a problem with the data being written.
|
|
26
|
+
# @param error [Exception]
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
def deadlock?(error)
|
|
29
|
+
error.is_a?(ActiveRecord::StatementInvalid) &&
|
|
30
|
+
DEADLOCK_MESSAGES.any? { |m| error.message.include?(m) }
|
|
31
|
+
end
|
|
32
|
+
|
|
24
33
|
# Retry the given block when encountering a deadlock. For any other
|
|
25
34
|
# exceptions, they are reraised. This is used to handle cases where
|
|
26
35
|
# the database may be busy but the transaction would succeed if
|
|
@@ -45,7 +54,7 @@ module Deimos
|
|
|
45
54
|
end
|
|
46
55
|
rescue ActiveRecord::StatementInvalid => e
|
|
47
56
|
# Reraise if not a known deadlock
|
|
48
|
-
raise
|
|
57
|
+
raise unless deadlock?(e)
|
|
49
58
|
|
|
50
59
|
# Reraise if all retries exhausted
|
|
51
60
|
raise if count <= 0
|
data/lib/deimos/version.rb
CHANGED
data/lib/deimos.rb
CHANGED
|
@@ -460,6 +460,291 @@ module ActiveRecordBatchConsumerTest
|
|
|
460
460
|
end
|
|
461
461
|
end
|
|
462
462
|
|
|
463
|
+
describe 'individual fallback when a batch operation fails' do
|
|
464
|
+
# `test_id: ''` passes Avro validation (it's a string) but fails the model's
|
|
465
|
+
# `validates :test_id, presence: true`, so `MassUpdater` raises before it issues any SQL.
|
|
466
|
+
# That's the poison-message case: one record that can never be persisted.
|
|
467
|
+
let(:poison_payload) { { test_id: '', some_int: 3 } }
|
|
468
|
+
|
|
469
|
+
let(:consumer_class) do
|
|
470
|
+
Class.new(described_class) do
|
|
471
|
+
record_class Widget
|
|
472
|
+
compacted false
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
before(:each) do
|
|
477
|
+
register_consumer(consumer_class, 'MySchema',
|
|
478
|
+
key_config: { plain: true },
|
|
479
|
+
configs: { reraise_errors: true })
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
it 'should not raise when the batch succeeds' do
|
|
483
|
+
publish_batch(
|
|
484
|
+
[
|
|
485
|
+
{ key: 1, payload: { test_id: 'abc', some_int: 1 } },
|
|
486
|
+
{ key: 2, payload: { test_id: 'def', some_int: 2 } }
|
|
487
|
+
]
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
expect(all_widgets).
|
|
491
|
+
to contain_exactly(have_attributes(id: 1, test_id: 'abc'),
|
|
492
|
+
have_attributes(id: 2, test_id: 'def'))
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
context 'when one record in the batch cannot be persisted' do
|
|
496
|
+
|
|
497
|
+
it 'should save every record except the bad one and report the failed key' do
|
|
498
|
+
expect {
|
|
499
|
+
publish_batch(
|
|
500
|
+
[
|
|
501
|
+
{ key: 1, payload: { test_id: 'abc', some_int: 1 } },
|
|
502
|
+
{ key: 2, payload: poison_payload },
|
|
503
|
+
{ key: 3, payload: { test_id: 'ghi', some_int: 3 } }
|
|
504
|
+
]
|
|
505
|
+
)
|
|
506
|
+
}.to raise_error(Deimos::BatchFallbackError, /Failed keys: "2"/)
|
|
507
|
+
|
|
508
|
+
expect(all_widgets).
|
|
509
|
+
to contain_exactly(have_attributes(id: 1, test_id: 'abc', some_int: 1),
|
|
510
|
+
have_attributes(id: 3, test_id: 'ghi', some_int: 3))
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
it 'should collect the failures of every bad record in the batch' do
|
|
514
|
+
expect {
|
|
515
|
+
publish_batch(
|
|
516
|
+
[
|
|
517
|
+
{ key: 1, payload: poison_payload },
|
|
518
|
+
{ key: 2, payload: { test_id: 'def', some_int: 2 } },
|
|
519
|
+
{ key: 3, payload: poison_payload }
|
|
520
|
+
]
|
|
521
|
+
)
|
|
522
|
+
}.to raise_error(Deimos::BatchFallbackError) { |error|
|
|
523
|
+
expect(error.failures.map { |message, _| message.key }).to contain_exactly('1', '3')
|
|
524
|
+
expect(error.failures.map(&:last)).to all(be_a(ActiveRecord::RecordInvalid))
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
expect(all_widgets).to contain_exactly(have_attributes(id: 2, test_id: 'def'))
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
it 'should raise the original error when no record could be saved on its own' do
|
|
531
|
+
# Isolating salvaged nothing, so the failure was never about one bad message. The
|
|
532
|
+
# original error is more useful than a BatchFallbackError listing every key.
|
|
533
|
+
expect {
|
|
534
|
+
publish_batch(
|
|
535
|
+
[
|
|
536
|
+
{ key: 1, payload: poison_payload },
|
|
537
|
+
{ key: 2, payload: poison_payload }
|
|
538
|
+
]
|
|
539
|
+
)
|
|
540
|
+
}.to raise_error(ActiveRecord::RecordInvalid)
|
|
541
|
+
|
|
542
|
+
expect(all_widgets).to be_empty
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
it 'should emit an instrumentation event naming the topic and operation' do
|
|
546
|
+
events = []
|
|
547
|
+
Karafka.monitor.subscribe('deimos.batch_consumption.initial_failure') do |event|
|
|
548
|
+
events << event.payload
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
expect {
|
|
552
|
+
publish_batch(
|
|
553
|
+
[
|
|
554
|
+
{ key: 1, payload: { test_id: 'abc', some_int: 1 } },
|
|
555
|
+
{ key: 2, payload: poison_payload }
|
|
556
|
+
]
|
|
557
|
+
)
|
|
558
|
+
}.to raise_error(Deimos::BatchFallbackError)
|
|
559
|
+
|
|
560
|
+
expect(events.size).to eq(1)
|
|
561
|
+
expect(events.first).to include(consumer: consumer_class,
|
|
562
|
+
topic: 'my-topic',
|
|
563
|
+
operation: :upsert_records,
|
|
564
|
+
count: 2)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
it 'should only re-run the database write, not the message-level processing' do
|
|
568
|
+
# Retrying by breaking the batch into writes of one must not re-run anything that
|
|
569
|
+
# already ran for the batch as a whole. pre_process is called once for the group, and
|
|
570
|
+
# valid_records is announced once with every record that made it, rather than once per
|
|
571
|
+
# record.
|
|
572
|
+
pre_processed = []
|
|
573
|
+
allow_any_instance_of(consumer_class).to receive(:pre_process) do |_, messages|
|
|
574
|
+
pre_processed << messages.map(&:key)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
valid_records = []
|
|
578
|
+
Karafka.monitor.subscribe('deimos.batch_consumption.valid_records') do |event|
|
|
579
|
+
valid_records << event.payload[:records]
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
expect {
|
|
583
|
+
publish_batch(
|
|
584
|
+
[
|
|
585
|
+
{ key: 1, payload: { test_id: 'abc', some_int: 1 } },
|
|
586
|
+
{ key: 2, payload: poison_payload },
|
|
587
|
+
{ key: 3, payload: { test_id: 'ghi', some_int: 3 } }
|
|
588
|
+
]
|
|
589
|
+
)
|
|
590
|
+
}.to raise_error(Deimos::BatchFallbackError)
|
|
591
|
+
|
|
592
|
+
expect(pre_processed).to eq([%w(1 2 3)])
|
|
593
|
+
expect(valid_records.size).to eq(1)
|
|
594
|
+
expect(valid_records.first.map(&:test_id)).to contain_exactly('abc', 'ghi')
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
it 'should not retry individually when the batch failed on a deadlock' do
|
|
598
|
+
allow(Deimos::Utils::DeadlockRetry).to receive(:sleep)
|
|
599
|
+
allow(Widget).to receive(:import!).
|
|
600
|
+
and_raise(ActiveRecord::Deadlocked.new('Lock wait timeout exceeded'))
|
|
601
|
+
|
|
602
|
+
expect {
|
|
603
|
+
publish_batch(
|
|
604
|
+
[
|
|
605
|
+
{ key: 1, payload: { test_id: 'abc', some_int: 1 } },
|
|
606
|
+
{ key: 2, payload: { test_id: 'def', some_int: 2 } }
|
|
607
|
+
]
|
|
608
|
+
)
|
|
609
|
+
}.to raise_error(ActiveRecord::Deadlocked)
|
|
610
|
+
|
|
611
|
+
# Only DeadlockRetry's own 3 attempts - no per-message retries on top.
|
|
612
|
+
expect(Widget).to have_received(:import!).exactly(3).times
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
context 'with a single-message batch' do
|
|
617
|
+
it 'should raise the original error rather than wrapping it' do
|
|
618
|
+
expect {
|
|
619
|
+
publish_batch([{ key: 1, payload: poison_payload }])
|
|
620
|
+
}.to raise_error(ActiveRecord::RecordInvalid)
|
|
621
|
+
|
|
622
|
+
expect(all_widgets).to be_empty
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
context 'when batch_message_fallback is turned off' do
|
|
627
|
+
before(:each) do
|
|
628
|
+
register_consumer(consumer_class, 'MySchema',
|
|
629
|
+
key_config: { plain: true },
|
|
630
|
+
configs: { reraise_errors: true,
|
|
631
|
+
batch_message_fallback: false })
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
it 'should lose the whole batch to a single bad record' do
|
|
635
|
+
expect {
|
|
636
|
+
publish_batch(
|
|
637
|
+
[
|
|
638
|
+
{ key: 1, payload: { test_id: 'abc', some_int: 1 } },
|
|
639
|
+
{ key: 2, payload: poison_payload },
|
|
640
|
+
{ key: 3, payload: { test_id: 'ghi', some_int: 3 } }
|
|
641
|
+
]
|
|
642
|
+
)
|
|
643
|
+
}.to raise_error(ActiveRecord::RecordInvalid)
|
|
644
|
+
|
|
645
|
+
expect(all_widgets).to be_empty
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
it 'should not attempt any individual writes' do
|
|
649
|
+
allow(Widget).to receive(:import!).and_call_original
|
|
650
|
+
|
|
651
|
+
expect {
|
|
652
|
+
publish_batch(
|
|
653
|
+
[
|
|
654
|
+
{ key: 1, payload: { test_id: 'abc', some_int: 1 } },
|
|
655
|
+
{ key: 2, payload: poison_payload }
|
|
656
|
+
]
|
|
657
|
+
)
|
|
658
|
+
}.to raise_error(ActiveRecord::RecordInvalid)
|
|
659
|
+
|
|
660
|
+
# The batch write raises during validation, before any import is issued.
|
|
661
|
+
expect(Widget).not_to have_received(:import!)
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
context 'with keys repeated across BatchSlicer slices' do
|
|
666
|
+
it 'should still process the later slices after an earlier one fails' do
|
|
667
|
+
# Key 1 appears twice, so BatchSlicer splits the batch into two slices:
|
|
668
|
+
# [key 1 (poison), key 2] and [key 1 (valid)]. The valid update for key 1 sits in the
|
|
669
|
+
# second slice, so it only lands if a failure in the first slice doesn't abort the rest.
|
|
670
|
+
expect {
|
|
671
|
+
publish_batch(
|
|
672
|
+
[
|
|
673
|
+
{ key: 1, payload: poison_payload },
|
|
674
|
+
{ key: 1, payload: { test_id: 'later', some_int: 2 } },
|
|
675
|
+
{ key: 2, payload: { test_id: 'ok', some_int: 3 } }
|
|
676
|
+
]
|
|
677
|
+
)
|
|
678
|
+
}.to raise_error(Deimos::BatchFallbackError, /Failed keys: "1"/)
|
|
679
|
+
|
|
680
|
+
expect(all_widgets).
|
|
681
|
+
to contain_exactly(have_attributes(id: 1, test_id: 'later', some_int: 2),
|
|
682
|
+
have_attributes(id: 2, test_id: 'ok', some_int: 3))
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
context 'with max_db_batch_size' do
|
|
687
|
+
let(:consumer_class) do
|
|
688
|
+
Class.new(described_class) do
|
|
689
|
+
record_class Widget
|
|
690
|
+
compacted false
|
|
691
|
+
max_db_batch_size 2
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
it 'should still process the later groups after an earlier one fails' do
|
|
696
|
+
expect {
|
|
697
|
+
publish_batch(
|
|
698
|
+
[
|
|
699
|
+
{ key: 1, payload: { test_id: 'abc', some_int: 1 } },
|
|
700
|
+
{ key: 2, payload: poison_payload },
|
|
701
|
+
{ key: 3, payload: { test_id: 'ghi', some_int: 3 } },
|
|
702
|
+
{ key: 4, payload: { test_id: 'jkl', some_int: 4 } }
|
|
703
|
+
]
|
|
704
|
+
)
|
|
705
|
+
}.to raise_error(Deimos::BatchFallbackError, /Failed keys: "2"/)
|
|
706
|
+
|
|
707
|
+
expect(all_widgets).
|
|
708
|
+
to contain_exactly(have_attributes(id: 1, test_id: 'abc'),
|
|
709
|
+
have_attributes(id: 3, test_id: 'ghi'),
|
|
710
|
+
have_attributes(id: 4, test_id: 'jkl'))
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
context 'when removing records fails' do
|
|
715
|
+
let(:consumer_class) do
|
|
716
|
+
Class.new(described_class) do
|
|
717
|
+
record_class Widget
|
|
718
|
+
compacted false
|
|
719
|
+
|
|
720
|
+
def remove_records(messages)
|
|
721
|
+
raise 'cannot delete widget 2' if messages.any? { |m| m.key.to_s == '2' }
|
|
722
|
+
|
|
723
|
+
super
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
it 'should delete the records it can and report the one it cannot' do
|
|
729
|
+
Widget.create!(id: 1, test_id: 'abc', some_int: 1)
|
|
730
|
+
Widget.create!(id: 2, test_id: 'def', some_int: 2)
|
|
731
|
+
Widget.create!(id: 3, test_id: 'ghi', some_int: 3)
|
|
732
|
+
|
|
733
|
+
expect {
|
|
734
|
+
publish_batch(
|
|
735
|
+
[
|
|
736
|
+
{ key: 1, payload: nil },
|
|
737
|
+
{ key: 2, payload: nil },
|
|
738
|
+
{ key: 3, payload: nil }
|
|
739
|
+
]
|
|
740
|
+
)
|
|
741
|
+
}.to raise_error(Deimos::BatchFallbackError, /Failed keys: "2"/)
|
|
742
|
+
|
|
743
|
+
expect(all_widgets).to contain_exactly(have_attributes(id: 2, test_id: 'def'))
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
463
748
|
describe 'skipping records' do
|
|
464
749
|
before(:each) do
|
|
465
750
|
register_consumer(consumer_class,
|
|
@@ -7,6 +7,43 @@ RSpec.describe Deimos::Utils::DeadlockRetry do
|
|
|
7
7
|
allow(described_class).to receive(:sleep)
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
# `deadlock?` matches against the exception message, so the examples below are literal
|
|
11
|
+
# fragments as each engine emits them, padded with surrounding text to pin substring rather
|
|
12
|
+
# than equality matching. No database is exercised here - these are hand-built exceptions, so
|
|
13
|
+
# they verify the predicate agrees with DEADLOCK_MESSAGES, not that any engine really produces
|
|
14
|
+
# those strings. See the `each_db_config` specs for tests that run against real engines.
|
|
15
|
+
describe '.deadlock?' do
|
|
16
|
+
it 'should match MySQL deadlocks and lock wait timeouts' do
|
|
17
|
+
expect(described_class).to be_deadlock(
|
|
18
|
+
ActiveRecord::Deadlocked.new('Mysql2::Error: Deadlock found when trying to get lock')
|
|
19
|
+
)
|
|
20
|
+
expect(described_class).to be_deadlock(
|
|
21
|
+
ActiveRecord::StatementInvalid.new('Lock wait timeout exceeded; try restarting')
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'should match Postgres deadlock detection' do
|
|
26
|
+
expect(described_class).to be_deadlock(
|
|
27
|
+
ActiveRecord::Deadlocked.new('PG::TRDeadlockDetected: ERROR: deadlock detected')
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'should not match a database error whose message is not a known deadlock' do
|
|
32
|
+
expect(described_class).not_to be_deadlock(ActiveRecord::StatementInvalid.new('Oops!!'))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'should not match a non-database error that happens to mention a deadlock' do
|
|
36
|
+
expect(described_class).not_to be_deadlock(StandardError.new('deadlock detected'))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'should not match validation failures' do
|
|
40
|
+
# Batch consumption depends on this: if a RecordInvalid looked like a deadlock, the guard
|
|
41
|
+
# in `save_record_list` would reraise it and the individual fallback would never fire for
|
|
42
|
+
# the poison-record case it exists to handle.
|
|
43
|
+
expect(described_class).not_to be_deadlock(ActiveRecord::RecordInvalid.new)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
10
47
|
describe 'deadlock handling' do
|
|
11
48
|
let(:batch) { [{ key: 1, payload: { test_id: 'abc', some_int: 3 } }] }
|
|
12
49
|
|