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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd09242a06bc94c004fb1fbeff2f6462e3b94d9aefce48894a1378eafdad1aa6
4
- data.tar.gz: 1d9c45e6048b08216fc9ef6c424c204c02d14d661e33a516da7c248cc8667e6a
3
+ metadata.gz: 3399390b0e3dc85c67c95c4d633d3ce65efb80258848e73c6b0fa5d9244db13e
4
+ data.tar.gz: d236d19cd353f581121dbf561cba34ff78a192bd636ac992fcb1ad87b5913375
5
5
  SHA512:
6
- metadata.gz: 34bed6ee88463b0345bb3d361bc489d57fdc1acbe472937b49e8ef6ae552e4cf9b9ca390606f180aeeebfb5864646dd45dd4fe3b1a6e35fa752d9d3fa65ee6c3
7
- data.tar.gz: cd7df7022342e21b40c1d68b6696f0026bbaff946e34b04de7cc4ca46fc82930c0fa19316150196ddf5bf19bf1f908116ffedc380e332a5c9352d954e063bf69
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
- ## Unreleased
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
@@ -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
- update_database(compact_messages(deimos_messages))
44
- else
45
- uncompacted_update(deimos_messages)
46
- end
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 [void]
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
- each(&method(:update_database))
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 [void]
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
- if upserted.any?
150
- if max_db_batch_size
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
- return if removed.empty?
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 [void]
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: updater.mass_update(record_list),
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
- attributes: attrs,
231
- bulk_import_column: col,
232
- bulk_import_id_generator: self.bulk_import_id_generator)
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
 
@@ -65,6 +65,11 @@ module Deimos
65
65
  topic.save_associations_first
66
66
  end
67
67
 
68
+ # @return [Boolean]
69
+ def batch_message_fallback
70
+ topic.batch_message_fallback
71
+ end
72
+
68
73
  def key_decoder
69
74
  self.topic.serializers[:key]&.backend
70
75
  end
@@ -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).freeze
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 if DEADLOCK_MESSAGES.none? { |m| e.message.include?(m) }
57
+ raise unless deadlock?(e)
49
58
 
50
59
  # Reraise if all retries exhausted
51
60
  raise if count <= 0
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Deimos
4
- VERSION = '2.5.4'
4
+ VERSION = '2.6.0'
5
5
  end
data/lib/deimos.rb CHANGED
@@ -49,6 +49,7 @@ module Deimos
49
49
  deimos.encode_message
50
50
  deimos.batch_consumption.invalid_records
51
51
  deimos.batch_consumption.valid_records
52
+ deimos.batch_consumption.initial_failure
52
53
  deimos.outbox.produce
53
54
  ).freeze
54
55
 
@@ -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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deimos-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.4
4
+ version: 2.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Orner